Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>WebGPU Black Hole Simulation</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Noto+Sans+KR:wght@300;500;700&display=swap'); | |
| :root { | |
| --primary-color: #00d2ff; | |
| --secondary-color: #ff0055; | |
| --bg-color: #050505; | |
| --panel-bg: rgba(20, 20, 30, 0.75); | |
| --text-color: #ffffff; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| background-color: var(--bg-color); | |
| color: var(--text-color); | |
| font-family: 'Noto Sans KR', sans-serif; | |
| overflow: hidden; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| /* Header / Branding */ | |
| header { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| padding: 1.5rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| z-index: 100; | |
| pointer-events: none; /* click through to canvas */ | |
| } | |
| .brand { | |
| font-family: 'Orbitron', sans-serif; | |
| font-weight: 700; | |
| font-size: 1.5rem; | |
| letter-spacing: 2px; | |
| text-shadow: 0 0 10px rgba(0, 210, 255, 0.5); | |
| pointer-events: auto; | |
| } | |
| .anycoder-link { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 0.9rem; | |
| color: var(--text-color); | |
| text-decoration: none; | |
| border: 1px solid rgba(255, 255, 255, 0.3); | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| background: rgba(0, 0, 0, 0.5); | |
| backdrop-filter: blur(5px); | |
| transition: all 0.3s ease; | |
| pointer-events: auto; | |
| } | |
| .anycoder-link:hover { | |
| background: var(--primary-color); | |
| color: #000; | |
| box-shadow: 0 0 15px var(--primary-color); | |
| border-color: var(--primary-color); | |
| } | |
| /* Canvas */ | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| /* UI Overlay */ | |
| .controls { | |
| position: absolute; | |
| bottom: 2rem; | |
| left: 2rem; | |
| width: 300px; | |
| background: var(--panel-bg); | |
| backdrop-filter: blur(10px); | |
| -webkit-backdrop-filter: blur(10px); | |
| padding: 1.5rem; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); | |
| z-index: 100; | |
| transition: opacity 0.3s ease; | |
| } | |
| .controls h2 { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 1.1rem; | |
| margin-bottom: 1rem; | |
| color: var(--primary-color); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .control-group { | |
| margin-bottom: 1rem; | |
| } | |
| .control-group label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.85rem; | |
| margin-bottom: 0.5rem; | |
| color: #ccc; | |
| } | |
| .control-group input[type="range"] { | |
| width: 100%; | |
| -webkit-appearance: none; | |
| background: transparent; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: rgba(255, 255, 255, 0.1); | |
| outline: none; | |
| } | |
| .control-group input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--primary-color); | |
| cursor: pointer; | |
| box-shadow: 0 0 10px var(--primary-color); | |
| margin-top: -5px; | |
| transition: transform 0.1s; | |
| } | |
| .control-group input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| } | |
| .control-group input[type="range"]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 6px; | |
| cursor: pointer; | |
| } | |
| .status { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| text-align: center; | |
| z-index: 200; | |
| background: rgba(0,0,0,0.9); | |
| padding: 2rem; | |
| border-radius: 10px; | |
| border: 1px solid var(--secondary-color); | |
| display: none; /* Hidden by default */ | |
| } | |
| .status.error { | |
| display: block; | |
| color: var(--secondary-color); | |
| } | |
| .status h3 { | |
| margin-bottom: 1rem; | |
| font-family: 'Orbitron', sans-serif; | |
| } | |
| /* Loading Spinner */ | |
| .loader { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 50px; | |
| height: 50px; | |
| border: 3px solid rgba(255,255,255,0.1); | |
| border-radius: 50%; | |
| border-top-color: var(--primary-color); | |
| animation: spin 1s ease-in-out infinite; | |
| z-index: 150; | |
| } | |
| @keyframes spin { | |
| to { transform: translate(-50%, -50%) rotate(360deg); } | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| .instructions { | |
| position: absolute; | |
| bottom: 2rem; | |
| right: 2rem; | |
| text-align: right; | |
| font-size: 0.8rem; | |
| color: rgba(255,255,255,0.5); | |
| pointer-events: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="brand">GARGANTUA <span style="color:var(--primary-color); font-size: 0.8em;">WebGPU</span></div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link">Built with anycoder</a> | |
| </header> | |
| <div id="loader" class="loader"></div> | |
| <div id="error-modal" class="status"> | |
| <h3>WebGPU ์ง์ ๋ถ๊ฐ</h3> | |
| <p>์ด ๋ธ๋ผ์ฐ์ ๋ WebGPU๋ฅผ ์ง์ํ์ง ์์ต๋๋ค.<br>Chrome ์ต์ ๋ฒ์ ์ด๋ Edge๋ฅผ ์ฌ์ฉํด ์ฃผ์ธ์.</p> | |
| </div> | |
| <canvas id="gpuCanvas"></canvas> | |
| <div class="controls"> | |
| <h2> | |
| <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg> | |
| ์ ์ด ํจ๋ | |
| </h2> | |
| <div class="control-group"> | |
| <label>๋ธ๋ํ ์ง๋ (Mass) <span id="val-mass">1.0</span></label> | |
| <input type="range" id="mass" min="0.1" max="3.0" step="0.1" value="1.0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>์๋ฐ ๋ฐ๊ธฐ (Intensity) <span id="val-intensity">1.5</span></label> | |
| <input type="range" id="intensity" min="0.1" max="5.0" step="0.1" value="1.5"> | |
| </div> | |
| <div class="control-group"> | |
| <label>๊ฐ์ค ์จ๋ (Temperature) <span id="val-temp">0.5</span></label> | |
| <input type="range" id="temp" min="0.0" max="1.0" step="0.01" value="0.5"> | |
| </div> | |
| <div class="control-group"> | |
| <label>์๋ฎฌ๋ ์ด์ ์๋ (Speed) <span id="val-speed">1.0</span></label> | |
| <input type="range" id="speed" min="0.0" max="5.0" step="0.1" value="1.0"> | |
| </div> | |
| </div> | |
| <div class="instructions"> | |
| ๋๋๊ทธํ์ฌ ํ์ / ํ ๋ก ์ค | |
| </div> | |
| <script type="module"> | |
| // WebGPU Setup and Logic | |
| const canvas = document.getElementById('gpuCanvas'); | |
| const loader = document.getElementById('loader'); | |
| const errorModal = document.getElementById('error-modal'); | |
| // UI Elements | |
| const ui = { | |
| mass: document.getElementById('mass'), | |
| intensity: document.getElementById('intensity'), | |
| temp: document.getElementById('temp'), | |
| speed: document.getElementById('speed'), | |
| valMass: document.getElementById('val-mass'), | |
| valIntensity: document.getElementById('val-intensity'), | |
| valTemp: document.getElementById('val-temp'), | |
| valSpeed: document.getElementById('val-speed'), | |
| }; | |
| // State | |
| const state = { | |
| mass: 1.0, | |
| intensity: 1.5, | |
| temperature: 0.5, | |
| speed: 1.0, | |
| time: 0, | |
| camRadius: 8.0, | |
| camTheta: 0.0, // Horizontal | |
| camPhi: 0.3, // Vertical | |
| mouseDown: false, | |
| lastMouseX: 0, | |
| lastMouseY: 0 | |
| }; | |
| // Update UI Labels | |
| const updateLabels = () => { | |
| ui.valMass.innerText = state.mass.toFixed(1); | |
| ui.valIntensity.innerText = state.intensity.toFixed(1); | |
| ui.valTemp.innerText = state.temperature.toFixed(2); | |
| ui.valSpeed.innerText = state.speed.toFixed(1); | |
| }; | |
| // Event Listeners for UI | |
| ui.mass.addEventListener('input', (e) => { state.mass = parseFloat(e.target.value); updateLabels(); }); | |
| ui.intensity.addEventListener('input', (e) => { state.intensity = parseFloat(e.target.value); updateLabels(); }); | |
| ui.temp.addEventListener('input', (e) => { state.temperature = parseFloat(e.target.value); updateLabels(); }); | |
| ui.speed.addEventListener('input', (e) => { state.speed = parseFloat(e.target.value); updateLabels(); }); | |
| // Mouse Controls | |
| canvas.addEventListener('mousedown', (e) => { | |
| state.mouseDown = true; | |
| state.lastMouseX = e.clientX; | |
| state.lastMouseY = e.clientY; | |
| }); | |
| window.addEventListener('mouseup', () => state.mouseDown = false); | |
| window.addEventListener('mousemove', (e) => { | |
| if (!state.mouseDown) return; | |
| const dx = e.clientX - state.lastMouseX; | |
| const dy = e.clientY - state.lastMouseY; | |
| state.lastMouseX = e.clientX; | |
| state.lastMouseY = e.clientY; | |
| state.camTheta -= dx * 0.005; | |
| state.camPhi += dy * 0.005; | |
| // Clamp vertical angle to avoid flipping | |
| state.camPhi = Math.max(-1.5, Math.min(1.5, state.camPhi)); | |
| }); | |
| canvas.addEventListener('wheel', (e) => { | |
| state.camRadius += e.deltaY * 0.01; | |
| state.camRadius = Math.max(3.0, Math.min(20.0, state.camRadius)); | |
| }); | |
| // WGSL Shader Code | |
| const shaderCode = ` | |
| struct Uniforms { | |
| resolution: vec2f, | |
| time: f32, | |
| mass: f32, | |
| intensity: f32, | |
| temperature: f32, | |
| camPos: vec3f, | |
| camTarget: vec3f, | |
| }; | |
| @group(0) @binding(0) var<uniform> u: Uniforms; | |
| struct VertexOutput { | |
| @builtin(position) position: vec4f, | |
| @location(0) uv: vec2f, | |
| }; | |
| @vertex | |
| fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { | |
| var pos = 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(pos[vertexIndex], 0.0, 1.0); | |
| output.uv = pos[vertexIndex]; // -1 to 1 | |
| return output; | |
| } | |
| // --- Noise Functions for Accretion Disk --- | |
| fn hash(p: vec2f) -> f32 { | |
| var p3 = fract(vec3f(p.xyx) * .1031); | |
| p3 += dot(p3, p3.yzx + 33.33); | |
| return fract((p3.x + p3.y) * p3.z); | |
| } | |
| 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 st = p; | |
| for (var i = 0; i < 5; i++) { | |
| value += amplitude * noise(st); | |
| st *= 2.0; | |
| amplitude *= 0.5; | |
| } | |
| return value; | |
| } | |
| // --- Physics & Rendering --- | |
| // Rotate vector | |
| fn rotateY(v: vec3f, angle: f32) -> vec3f { | |
| let c = cos(angle); | |
| let s = sin(angle); | |
| return vec3f(c * v.x + s * v.z, v.y, -s * v.x + c * v.z); | |
| } | |
| // Blackbody-ish color ramp | |
| fn blackBodyColor(t: f32) -> vec3f { | |
| // Map t (0 to 1) to colors: Red -> Orange -> White -> Blue | |
| let r = smoothstep(0.0, 0.5, t) + smoothstep(0.3, 1.0, t); | |
| let g = smoothstep(0.2, 0.7, t) + smoothstep(0.8, 1.0, t) * 0.5; | |
| let b = smoothstep(0.5, 0.9, t) + smoothstep(0.9, 1.0, t) * 2.0; | |
| return vec3f(r, g, b) * u.intensity; | |
| } | |
| @fragment | |
| fn fs_main(in: VertexOutput) -> @location(0) vec4f { | |
| // 1. Setup Camera | |
| let uv = in.uv * vec2f(u.resolution.x / u.resolution.y, 1.0); | |
| let ro = u.camPos; | |
| let ta = u.camTarget; | |
| let ww = normalize(ta - ro); | |
| let uu = normalize(cross(ww, vec3f(0.0, 1.0, 0.0))); | |
| let vv = normalize(cross(uu, ww)); | |
| var rd = normalize(uv.x * uu + uv.y * vv + 1.5 * ww); | |
| // 2. Ray Marching Simulation (Curved Space-Time approximation) | |
| var pos = ro; | |
| var color = vec3f(0.0); | |
| var glow = 0.0; | |
| let dt = 0.05; // Step size | |
| let maxSteps = 300; | |
| // Accretion disk parameters | |
| let innerRadius = 2.0 * u.mass; // Schwarzschild radius * scaling | |
| let diskInner = 2.6 * u.mass; | |
| let diskOuter = 8.0 * u.mass; | |
| var hitHorizon = false; | |
| for(var i = 0; i < maxSteps; i++) { | |
| let distSq = dot(pos, pos); | |
| let dist = sqrt(distSq); | |
| // Event Horizon Check | |
| if (dist < innerRadius) { | |
| hitHorizon = true; | |
| break; | |
| } | |
| // Escape Check | |
| if (dist > 25.0) { | |
| break; | |
| } | |
| // Gravity Bending (Newtonian approx for visual flair) | |
| // Acceleration towards center | |
| let force = (1.5 * u.mass) / (distSq * 1.5); // Tweaked for visuals | |
| let acc = -normalize(pos) * force; | |
| // Update Velocity (Ray direction) | |
| rd += acc * dt; | |
| rd = normalize(rd); | |
| // Update Position | |
| let prevPos = pos; | |
| pos += rd * dt; | |
| // --- Accretion Disk Rendering --- | |
| // Check if we crossed the Y=0 plane | |
| if (prevPos.y * pos.y < 0.0 || abs(pos.y) < 0.1) { | |
| let midPoint = (prevPos + pos) * 0.5; | |
| let r = length(midPoint.xz); | |
| if (r > diskInner && r < diskOuter) { | |
| // Calculate polar coordinates for noise | |
| let angle = atan2(midPoint.z, midPoint.x); | |
| // Rotating texture | |
| let uvDisk = vec2f(r - diskInner, angle + u.time * 0.5 + 10.0/r); | |
| // Noise generation | |
| var density = fbm(uvDisk * vec2f(1.0, 3.0)); | |
| // Radial fade out | |
| let fade = smoothstep(diskOuter, diskOuter - 1.0, r) * smoothstep(diskInner, diskInner + 0.5, r); | |
| density *= fade; | |
| // Doppler Beaming Approximation | |
| // Matter on left moves towards us (brighter), right moves away (dimmer) | |
| // Assuming camera is somewhat aligned with z-axis logic | |
| let velDir = normalize(vec3f(-midPoint.z, 0.0, midPoint.x)); // Tangential velocity | |
| let viewDir = normalize(ro - midPoint); | |
| let doppler = 1.0 + dot(velDir, viewDir) * 0.5; | |
| // Color mapping | |
| let temp = density * doppler; | |
| let diskCol = blackBodyColor(temp * (0.5 + u.temperature)); | |
| // Accumulate (Volumetric-ish addition) | |
| color += diskCol * density * 0.4 * u.intensity; | |
| } | |
| } | |
| // Accumulate Glow around the black hole | |
| glow += 0.01 / (dist * dist * 0.1 + 0.01); | |
| } | |
| // Stars / Background | |
| if (!hitHorizon) { | |
| let starDir = rd; | |
| let starVal = pow(hash(starDir.xy * 50.0 + starDir.zz * 50.0), 50.0) * 2.0; | |
| color += vec3f(starVal); | |
| } | |
| // Add Glow | |
| color += vec3f(0.1, 0.3, 0.5) * glow * 0.5; | |
| // Tone mapping | |
| color = color / (color + vec3f(1.0)); | |
| color = pow(color, vec3f(0.4545)); // Gamma correction | |
| return vec4f(color, 1.0); | |
| } | |
| `; | |
| async function init() { | |
| if (!navigator.gpu) { | |
| loader.classList.add('hidden'); | |
| errorModal.classList.add('error'); | |
| return; | |
| } | |
| try { | |
| const adapter = await navigator.gpu.requestAdapter(); | |
| if (!adapter) throw new Error("No adapter found"); | |
| const device = await adapter.requestDevice(); | |
| const context = canvas.getContext('webgpu'); | |
| const format = navigator.gpu.getPreferredCanvasFormat(); | |
| context.configure({ | |
| device: device, | |
| format: format, | |
| alphaMode: 'opaque', | |
| }); | |
| // Create Shader Module | |
| const shaderModule = device.createShaderModule({ | |
| label: 'Black Hole Shader', | |
| code: shaderCode | |
| }); | |
| // Create Pipeline | |
| const pipeline = device.createRenderPipeline({ | |
| label: 'Render Pipeline', | |
| layout: 'auto', | |
| vertex: { | |
| module: shaderModule, | |
| entryPoint: 'vs_main', | |
| }, | |
| fragment: { | |
| module: shaderModule, | |
| entryPoint: 'fs_main', | |
| targets: [{ format: format }], | |
| }, | |
| primitive: { | |
| topology: 'triangle-list', | |
| } | |
| }); | |
| // Uniform Buffer Setup | |
| // Struct: resolution(vec2), time(f32), mass(f32), intensity(f32), temperature(f32), camPos(vec3), camTarget(vec3) | |
| // Padding rules: vec3 takes 16 bytes (4 floats) alignment. | |
| // Layout: | |
| // 0: res.x, res.y, time, mass | |
| // 16: intensity, temperature, padding, padding | |
| // 32: camPos.x, camPos.y, camPos.z, padding | |
| // 48: camTarget.x, camTarget.y, camTarget.z, padding | |
| // Total: 64 bytes | |
| const uniformBufferSize = 64; | |
| const uniformBuffer = device.createBuffer({ | |
| size: uniformBufferSize, | |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, | |
| }); | |
| const bindGroup = device.createBindGroup({ | |
| layout: pipeline.getBindGroupLayout(0), | |
| entries: [{ | |
| binding: 0, | |
| resource: { buffer: uniformBuffer } | |
| }] | |
| }); | |
| const uniformValues = new Float32Array(uniformBufferSize / 4); | |
| loader.classList.add('hidden'); | |
| function render(timestamp) { | |
| state.time += 0.01 * state.speed; | |
| // Update Canvas Size | |
| const width = canvas.clientWidth; | |
| const height = canvas.clientHeight; | |
| canvas.width = width; | |
| canvas.height = height; | |
| // Update Camera Position (Spherical coords) | |
| const camX = state.camRadius * Math.sin(state.camTheta) * Math.cos(state.camPhi); | |
| const camY = state.camRadius * Math.sin(state.camPhi); | |
| const camZ = state.camRadius * Math.cos(state.camTheta) * Math.cos(state.camPhi); | |
| // Update Uniforms | |
| // 0: res.x, res.y, time, mass | |
| uniformValues[0] = width; | |
| uniformValues[1] = height; | |
| uniformValues[2] = state.time; | |
| uniformValues[3] = state.mass; | |
| // 4: intensity, temp, padding, padding | |
| uniformValues[4] = state.intensity; | |
| uniformValues[5] = state.temperature; | |
| uniformValues[6] = 0; // padding | |
| uniformValues[7] = 0; // padding | |
| // 8: camPos (vec3 + padding) | |
| uniformValues[8] = camX; | |
| uniformValues[9] = camY; | |
| uniformValues[10] = camZ; | |
| uniformValues[11] = 0; | |
| // 12: camTarget (vec3 + padding) | |
| uniformValues[12] = 0; | |
| uniformValues[13] = 0; | |
| uniformValues[14] = 0; | |
| uniformValues[15] = 0; | |
| device.queue.writeBuffer(uniformBuffer, 0, uniformValues); | |
| const commandEncoder = device.createCommandEncoder(); | |
| const textureView = context.getCurrentTexture().createView(); | |
| const renderPassDescriptor = { | |
| colorAttachments: [{ | |
| view: textureView, | |
| clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, | |
| loadOp: 'clear', | |
| storeOp: 'store', | |
| }] | |
| }; | |
| const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); | |
| passEncoder.setPipeline(pipeline); | |
| passEncoder.setBindGroup(0, bindGroup); | |
| passEncoder.draw(6); // Draw 6 vertices (2 triangles = 1 quad) | |
| passEncoder.end(); | |
| device.queue.submit([commandEncoder.finish()]); | |
| requestAnimationFrame(render); | |
| } | |
| requestAnimationFrame(render); | |
| } catch (e) { | |
| console.error(e); | |
| loader.classList.add('hidden'); | |
| errorModal.innerHTML = `<h3>์ค๋ฅ ๋ฐ์</h3><p>${e.message}</p>`; | |
| errorModal.classList.add('error'); | |
| } | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> |