Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Black Hole with Accretion Disk - WebGPU Simulator</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #0a0a0a 100%); | |
| min-height: 100vh; | |
| color: #fff; | |
| overflow-x: hidden; | |
| } | |
| header { | |
| padding: 1rem 2rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background: rgba(0, 0, 0, 0.5); | |
| backdrop-filter: blur(10px); | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .logo { | |
| font-size: 1.5rem; | |
| font-weight: bold; | |
| background: linear-gradient(90deg, #ff6b35, #f7c59f); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .built-with { | |
| font-size: 0.9rem; | |
| color: #888; | |
| } | |
| .built-with a { | |
| color: #ff6b35; | |
| text-decoration: none; | |
| transition: color 0.3s ease; | |
| } | |
| .built-with a:hover { | |
| color: #f7c59f; | |
| } | |
| main { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 2rem; | |
| gap: 2rem; | |
| } | |
| .canvas-container { | |
| position: relative; | |
| width: 100%; | |
| max-width: 900px; | |
| aspect-ratio: 16/9; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| box-shadow: 0 0 60px rgba(255, 107, 53, 0.3), | |
| 0 0 100px rgba(255, 107, 53, 0.1); | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| .controls { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
| gap: 1.5rem; | |
| width: 100%; | |
| max-width: 900px; | |
| padding: 1.5rem; | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 16px; | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .control-group label { | |
| font-size: 0.9rem; | |
| color: #ccc; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .control-group label span { | |
| color: #ff6b35; | |
| font-weight: bold; | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 8px; | |
| border-radius: 4px; | |
| background: linear-gradient(90deg, #333, #555); | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #ff6b35, #f7c59f); | |
| cursor: pointer; | |
| box-shadow: 0 0 10px rgba(255, 107, 53, 0.5); | |
| transition: transform 0.2s ease; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: linear-gradient(135deg, #ff6b35, #f7c59f); | |
| cursor: pointer; | |
| border: none; | |
| box-shadow: 0 0 10px rgba(255, 107, 53, 0.5); | |
| } | |
| .info-panel { | |
| width: 100%; | |
| max-width: 900px; | |
| padding: 1.5rem; | |
| background: rgba(255, 255, 255, 0.03); | |
| border-radius: 16px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .info-panel h2 { | |
| font-size: 1.2rem; | |
| margin-bottom: 1rem; | |
| color: #ff6b35; | |
| } | |
| .info-panel p { | |
| font-size: 0.9rem; | |
| line-height: 1.6; | |
| color: #aaa; | |
| } | |
| .error-message { | |
| padding: 2rem; | |
| background: rgba(255, 0, 0, 0.1); | |
| border: 1px solid rgba(255, 0, 0, 0.3); | |
| border-radius: 16px; | |
| text-align: center; | |
| max-width: 600px; | |
| } | |
| .error-message h2 { | |
| color: #ff4444; | |
| margin-bottom: 1rem; | |
| } | |
| .error-message p { | |
| color: #ccc; | |
| line-height: 1.6; | |
| } | |
| .stats { | |
| display: flex; | |
| gap: 2rem; | |
| flex-wrap: wrap; | |
| margin-top: 1rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .stat { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.25rem; | |
| } | |
| .stat-label { | |
| font-size: 0.8rem; | |
| color: #888; | |
| } | |
| .stat-value { | |
| font-size: 1.1rem; | |
| color: #ff6b35; | |
| font-weight: bold; | |
| } | |
| @media (max-width: 768px) { | |
| header { | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| text-align: center; | |
| } | |
| main { | |
| padding: 1rem; | |
| } | |
| .controls { | |
| grid-template-columns: 1fr; | |
| } | |
| .stats { | |
| justify-content: center; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo">🌌 Black Hole Simulator</div> | |
| <div class="built-with">Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" | |
| target="_blank">anycoder</a></div> | |
| </header> | |
| <main> | |
| <div class="canvas-container"> | |
| <canvas id="webgpu-canvas"></canvas> | |
| </div> | |
| <div class="controls"> | |
| <div class="control-group"> | |
| <label>Black Hole Mass <span id="mass-value">1.0</span></label> | |
| <input type="range" id="mass" min="0.5" max="3.0" step="0.1" value="1.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Disk Inner Radius <span id="inner-radius-value">3.0</span></label> | |
| <input type="range" id="inner-radius" min="2.0" max="5.0" step="0.1" value="3.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Disk Outer Radius <span id="outer-radius-value">12.0</span></label> | |
| <input type="range" id="outer-radius" min="8.0" max="20.0" step="0.5" value="12.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Disk Temperature <span id="temperature-value">1.0</span></label> | |
| <input type="range" id="temperature" min="0.3" max="2.0" step="0.1" value="1.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Camera Distance <span id="camera-dist-value">20.0</span></label> | |
| <input type="range" id="camera-dist" min="10.0" max="40.0" step="1.0" value="20.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>View Angle <span id="view-angle-value">75</span>°</label> | |
| <input type="range" id="view-angle" min="10" max="90" step="1" value="75"> | |
| </div> | |
| </div> | |
| <div class="info-panel"> | |
| <h2>About This Simulation</h2> | |
| <p> | |
| This is a real-time ray-traced simulation of a Schwarzschild black hole with an accretion disk, | |
| powered by WebGPU. The simulation uses general relativistic ray tracing to accurately depict | |
| gravitational lensing, photon sphere effects, and the characteristic appearance of matter | |
| spiraling into the event horizon. The accretion disk glows due to friction-heated matter, | |
| with colors ranging from red (cooler outer regions) to white-hot (near the inner edge). | |
| </p> | |
| <div class="stats"> | |
| <div class="stat"> | |
| <span class="stat-label">Schwarzschild Radius</span> | |
| <span class="stat-value" id="schwarzschild-radius">2.0 Rs</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Photon Sphere</span> | |
| <span class="stat-value" id="photon-sphere">3.0 Rs</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">ISCO</span> | |
| <span class="stat-value" id="isco">6.0 Rs</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">FPS</span> | |
| <span class="stat-value" id="fps">0</span> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| const shaderCode = ` | |
| struct Uniforms { | |
| resolution: vec2f, | |
| time: f32, | |
| mass: f32, | |
| innerRadius: f32, | |
| outerRadius: f32, | |
| temperature: f32, | |
| cameraDist: f32, | |
| viewAngle: f32, | |
| padding: f32, | |
| } | |
| @group(0) @binding(0) var<uniform> uniforms: Uniforms; | |
| struct VertexOutput { | |
| @builtin(position) position: vec4f, | |
| @location(0) uv: vec2f, | |
| } | |
| @vertex | |
| fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { | |
| var positions = array<vec2f, 6>( | |
| vec2f(-1.0, -1.0), | |
| vec2f(1.0, -1.0), | |
| vec2f(-1.0, 1.0), | |
| vec2f(-1.0, 1.0), | |
| vec2f(1.0, -1.0), | |
| vec2f(1.0, 1.0) | |
| ); | |
| var output: VertexOutput; | |
| output.position = vec4f(positions[vertexIndex], 0.0, 1.0); | |
| output.uv = positions[vertexIndex] * 0.5 + 0.5; | |
| return output; | |
| } | |
| const PI: f32 = 3.14159265359; | |
| const MAX_STEPS: i32 = 300; | |
| const STEP_SIZE: f32 = 0.15; | |
| fn rotateX(p: vec3f, angle: f32) -> vec3f { | |
| let c = cos(angle); | |
| let s = sin(angle); | |
| return vec3f(p.x, c * p.y - s * p.z, s * p.y + c * p.z); | |
| } | |
| fn rotateY(p: vec3f, angle: f32) -> vec3f { | |
| let c = cos(angle); | |
| let s = sin(angle); | |
| return vec3f(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); | |
| } | |
| fn hash(p: vec2f) -> f32 { | |
| let h = dot(p, vec2f(127.1, 311.7)); | |
| return fract(sin(h) * 43758.5453123); | |
| } | |
| fn noise(p: vec2f) -> f32 { | |
| let i = floor(p); | |
| let f = fract(p); | |
| let u = f * f * (3.0 - 2.0 * f); | |
| return mix( | |
| mix(hash(i + vec2f(0.0, 0.0)), hash(i + vec2f(1.0, 0.0)), u.x), | |
| mix(hash(i + vec2f(0.0, 1.0)), hash(i + vec2f(1.0, 1.0)), u.x), | |
| u.y | |
| ); | |
| } | |
| fn fbm(p: vec2f) -> f32 { | |
| var value: f32 = 0.0; | |
| var amplitude: f32 = 0.5; | |
| var frequency: f32 = 1.0; | |
| var pp = p; | |
| for (var i: i32 = 0; i < 5; i++) { | |
| value += amplitude * noise(pp * frequency); | |
| amplitude *= 0.5; | |
| frequency *= 2.0; | |
| } | |
| return value; | |
| } | |
| fn blackbodyColor(temp: f32) -> vec3f { | |
| let t = temp * 6500.0; | |
| var color: vec3f; | |
| if (t < 6600.0) { | |
| color.r = 1.0; | |
| color.g = saturate(0.39 * log(t / 100.0) - 0.634); | |
| if (t < 2000.0) { | |
| color.b = 0.0; | |
| } else { | |
| color.b = saturate(0.543 * log(t / 100.0 - 10.0) - 1.185); | |
| } | |
| } else { | |
| color.r = saturate(1.29 * pow(t / 100.0 - 60.0, -0.1332)); | |
| color.g = saturate(1.13 * pow(t / 100.0 - 60.0, -0.0755)); | |
| color.b = 1.0; | |
| } | |
| return color; | |
| } | |
| fn diskColor(r: f32, phi: f32, time: f32) -> vec3f { | |
| let innerR = uniforms.innerRadius * uniforms.mass; | |
| let outerR = uniforms.outerRadius * uniforms.mass; | |
| let normalizedR = (r - innerR) / (outerR - innerR); | |
| let temp = uniforms.temperature * pow(1.0 - normalizedR, 0.75) * (0.8 + 0.4 * fbm(vec2f(phi * 3.0, r * 0.5 + time * 0.1))); | |
| var color = blackbodyColor(temp); | |
| let spiral = sin(phi * 4.0 - r * 0.5 + time * 2.0) * 0.5 + 0.5; | |
| let turbulence = fbm(vec2f(phi * 5.0 + time * 0.5, r * 2.0)) * 0.3; | |
| color *= (0.7 + 0.3 * spiral + turbulence); | |
| let falloff = smoothstep(innerR, innerR + 0.5, r) * smoothstep(outerR, outerR - 2.0, r); | |
| color *= falloff; | |
| let glow = exp(-normalizedR * 2.0) * 2.0; | |
| color += vec3f(1.0, 0.5, 0.2) * glow * 0.3; | |
| return color; | |
| } | |
| fn rayMarchBlackHole(ro: vec3f, rd: vec3f) -> vec3f { | |
| let rs = 2.0 * uniforms.mass; | |
| let photonSphere = 1.5 * rs; | |
| let innerR = uniforms.innerRadius * uniforms.mass; | |
| let outerR = uniforms.outerRadius * uniforms.mass; | |
| var pos = ro; | |
| var dir = rd; | |
| var color = vec3f(0.0); | |
| var accumulated = vec3f(0.0); | |
| var transmittance: f32 = 1.0; | |
| for (var i: i32 = 0; i < MAX_STEPS; i++) { | |
| let r = length(pos); | |
| if (r < rs * 1.01) { | |
| return accumulated; | |
| } | |
| if (r > 100.0) { | |
| let starField = pow(hash(dir.xy * 1000.0), 20.0) * 0.8; | |
| let nebula = fbm(dir.xy * 3.0 + dir.z) * 0.1; | |
| accumulated += transmittance * (vec3f(starField) + vec3f(0.1, 0.05, 0.15) * nebula); | |
| break; | |
| } | |
| let diskY = pos.y; | |
| let diskR = length(pos.xz); | |
| if (abs(diskY) < 0.3 && diskR > innerR && diskR < outerR) { | |
| let phi = atan2(pos.z, pos.x) + PI; | |
| let diskCol = diskColor(diskR, phi, uniforms.time); | |
| let density = (1.0 - abs(diskY) / 0.3) * 0.5; | |
| accumulated += transmittance * diskCol * density; | |
| transmittance *= (1.0 - density * 0.3); | |
| if (transmittance < 0.01) { | |
| break; | |
| } | |
| } | |
| let schwarzschildFactor = 1.0 - rs / r; | |
| let bendStrength = rs / (r * r) * 1.5; | |
| let toCenter = -normalize(pos); | |
| let perpComponent = dir - toCenter * dot(dir, toCenter); | |
| dir = normalize(dir + toCenter * bendStrength * STEP_SIZE); | |
| let adaptiveStep = STEP_SIZE * (1.0 + r * 0.05); | |
| pos += dir * adaptiveStep; | |
| } | |
| return accumulated; | |
| } | |
| @fragment | |
| fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { | |
| let uv = (input.uv * 2.0 - 1.0) * vec2f(uniforms.resolution.x / uniforms.resolution.y, 1.0); | |
| let viewAngleRad = uniforms.viewAngle * PI / 180.0; | |
| let camDist = uniforms.cameraDist * uniforms.mass; | |
| var camPos = vec3f(0.0, 0.0, camDist); | |
| camPos = rotateX(camPos, PI / 2.0 - viewAngleRad); | |
| let fov: f32 = 1.5; | |
| var rayDir = normalize(vec3f(uv.x * fov, uv.y * fov, -1.0)); | |
| rayDir = rotateX(rayDir, PI / 2.0 - viewAngleRad); | |
| var color = rayMarchBlackHole(camPos, rayDir); | |
| color = pow(color, vec3f(0.85)); | |
| color = color / (color + vec3f(1.0)); | |
| let vignette = 1.0 - length(input.uv - 0.5) * 0.5; | |
| color *= vignette; | |
| color = pow(color, vec3f(1.0 / 2.2)); | |
| return vec4f(color, 1.0); | |
| } | |
| `; | |
| async function initWebGPU() { | |
| const canvas = document.getElementById('webgpu-canvas'); | |
| if (!navigator.gpu) { | |
| showError(); | |
| return null; | |
| } | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (!adapter) { | |
| showError(); | |
| return null; | |
| } | |
| const device = await adapter.requestDevice(); | |
| const context = canvas.getContext('webgpu'); | |
| const format = navigator.gpu.getPreferredCanvasFormat(); | |
| context.configure({ | |
| device: device, | |
| format: format, | |
| alphaMode: 'premultiplied', | |
| }); | |
| return { device, context, format, canvas }; | |
| } | |
| function showError() { | |
| const container = document.querySelector('.canvas-container'); | |
| container.innerHTML = ` | |
| <div class="error-message"> | |
| <h2>WebGPU Not Supported</h2> | |
| <p>Your browser doesn't support WebGPU. Please try using Chrome 113+ or Edge 113+ with WebGPU enabled.</p> | |
| </div> | |
| `; | |
| } | |
| async function main() { | |
| const gpu = await initWebGPU(); | |
| if (!gpu) return; | |
| const { device, context, format, canvas } = gpu; | |
| const shaderModule = device.createShaderModule({ | |
| code: shaderCode | |
| }); | |
| const uniformBuffer = device.createBuffer({ | |
| size: 48, | |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
| }); | |
| const bindGroupLayout = device.createBindGroupLayout({ | |
| entries: [{ | |
| binding: 0, | |
| visibility: GPUShaderStage.FRAGMENT, | |
| buffer: { type: 'uniform' } | |
| }] | |
| }); | |
| const bindGroup = device.createBindGroup({ | |
| layout: bindGroupLayout, | |
| entries: [{ | |
| binding: 0, | |
| resource: { buffer: uniformBuffer } | |
| }] | |
| }); | |
| const pipelineLayout = device.createPipelineLayout({ | |
| bindGroupLayouts: [bindGroupLayout] | |
| }); | |
| const pipeline = device.createRenderPipeline({ | |
| layout: pipelineLayout, | |
| vertex: { | |
| module: shaderModule, | |
| entryPoint: 'vertexMain', | |
| }, | |
| fragment: { | |
| module: shaderModule, | |
| entryPoint: 'fragmentMain', | |
| targets: [{ format: format }], | |
| }, | |
| primitive: { | |
| topology: 'triangle-list', | |
| }, | |
| }); | |
| const params = { | |
| mass: 1.0, | |
| innerRadius: 3.0, | |
| outerRadius: 12.0, | |
| temperature: 1.0, | |
| cameraDist: 20.0, | |
| viewAngle: 75.0, | |
| }; | |
| function setupControls() { | |
| const controls = ['mass', 'inner-radius', 'outer-radius', 'temperature', 'camera-dist', 'view-angle']; | |
| const paramNames = ['mass', 'innerRadius', 'outerRadius', 'temperature', 'cameraDist', 'viewAngle']; | |
| controls.forEach((id, index) => { | |
| const input = document.getElementById(id); | |
| const valueSpan = document.getElementById(`${id}-value`); | |
| input.addEventListener('input', (e) => { | |
| const value = parseFloat(e.target.value); | |
| params[paramNames[index]] = value; | |
| valueSpan.textContent = value.toFixed(1); | |
| updateStats(); | |
| }); | |
| }); | |
| } | |
| function updateStats() { | |
| const rs = 2.0 * params.mass; | |
| document.getElementById('schwarzschild-radius').textContent = `${rs.toFixed(1)} Rs`; | |
| document.getElementById('photon-sphere').textContent = `${(1.5 * rs).toFixed(1)} Rs`; | |
| document.getElementById('isco').textContent = `${(3.0 * rs).toFixed(1)} Rs`; | |
| } | |
| setupControls(); | |
| updateStats(); | |
| let lastTime = performance.now(); | |
| let frameCount = 0; | |
| let fps = 0; | |
| function resizeCanvas() { | |
| const container = canvas.parentElement; | |
| const rect = container.getBoundingClientRect(); | |
| const dpr = Math.min(window.devicePixelRatio, 2); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| } | |
| window.addEventListener('resize', resizeCanvas); | |
| resizeCanvas(); | |
| function render(time) { | |
| frameCount++; | |
| const now = performance.now(); | |
| if (now - lastTime >= 1000) { | |
| fps = frameCount; | |
| frameCount = 0; | |
| lastTime = now; | |
| document.getElementById('fps').textContent = fps; | |
| } | |
| resizeCanvas(); | |
| const uniformData = new Float32Array([ | |
| canvas.width, canvas.height, | |
| time * 0.001, | |
| params.mass, | |
| params.innerRadius, | |
| params.outerRadius, | |
| params.temperature, | |
| params.cameraDist, | |
| params.viewAngle, | |
| 0.0, | |
| ]); | |
| device.queue.writeBuffer(uniformBuffer, 0, uniformData); | |
| const commandEncoder = device.createCommandEncoder(); | |
| const textureView = context.getCurrentTexture().createView(); | |
| const renderPass = commandEncoder.beginRenderPass({ | |
| colorAttachments: [{ | |
| view: textureView, | |
| clearValue: { r: 0, g: 0, b: 0, a: 1 }, | |
| loadOp: 'clear', | |
| storeOp: 'store', | |
| }] | |
| }); | |
| renderPass.setPipeline(pipeline); | |
| renderPass.setBindGroup(0, bindGroup); | |
| renderPass.draw(6, 1, 0, 0); | |
| renderPass.end(); | |
| device.queue.submit([commandEncoder.finish()]); | |
| requestAnimationFrame(render); | |
| } | |
| requestAnimationFrame(render); | |
| } | |
| main(); | |
| </script> | |
| </body> | |
| </html> |