Created
June 29, 2026 23:22
-
-
Save sirisian/3305d0930bedd6eabb2e795b6cf06fa8 to your computer and use it in GitHub Desktop.
Occupancy Tree Example (Claude Opus 4.8)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>WebGPU · per-pixel nested 4³ 3D-DDA volume</title> | |
| <style> | |
| :root{ | |
| --bg:#0a0c10;--panel:#12151c;--panel2:#161a23;--line:#1e2430;--line2:#2a3242; | |
| --ink:#e9eef6;--mut:#8b95a7;--faint:#5b6473;--acc:#38bdf8;--acc2:#f0a830;--ray:#ff3d71; | |
| --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans);overflow:hidden} | |
| #app{display:grid;grid-template-columns:1fr 312px;height:100vh} | |
| #stage{position:relative;min-width:0;background:radial-gradient(120% 120% at 50% 0%,#0e1320,#070a0e)} | |
| canvas{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:grab} | |
| canvas.drag{cursor:grabbing} | |
| #hud{position:absolute;left:14px;top:12px;font-family:var(--mono);font-size:11px;color:var(--mut);pointer-events:none;line-height:1.7;text-shadow:0 1px 3px #000a} | |
| #hud b{color:var(--ink)} #hud .k{color:var(--acc)} | |
| #err{position:absolute;left:14px;bottom:14px;right:14px;font-family:var(--mono);font-size:12px;color:#ffb4b4;background:#2a1414ee;border:1px solid #5a2a2a;border-radius:9px;padding:11px 13px;display:none;white-space:pre-wrap;max-height:46%;overflow:auto} | |
| aside{background:linear-gradient(180deg,var(--panel2),var(--panel));border-left:1px solid var(--line);overflow-y:auto;padding:16px 16px 30px} | |
| .brand{font-family:var(--mono);font-size:10.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--acc);margin:2px 0 2px} | |
| h1{font-size:17px;line-height:1.18;letter-spacing:-.01em;margin:0 0 4px;font-weight:700} | |
| .blurb{font-size:11.5px;color:var(--mut);line-height:1.5;margin:0 0 14px} | |
| .sec{border-top:1px solid var(--line);padding:13px 0 4px} .sec:first-of-type{border-top:0} | |
| .sec h2{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin:0 0 11px;font-weight:600} | |
| .row{display:grid;grid-template-columns:62px 1fr 46px;align-items:center;gap:9px;margin:9px 0} | |
| .row label{font-family:var(--mono);font-size:11px;color:var(--mut)} | |
| .row .v{font-family:var(--mono);font-size:11px;color:var(--ink);text-align:right} | |
| input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:3px;border-radius:3px;background:#222a36;cursor:pointer} | |
| input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--ink);border:2px solid var(--acc2);cursor:pointer} | |
| input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--ink);border:2px solid var(--acc2);cursor:pointer} | |
| .btn{width:100%;font-family:var(--mono);font-size:12px;color:var(--ink);background:#1b2330;border:1px solid var(--line2);border-radius:8px;padding:9px;cursor:pointer;margin-top:6px} | |
| .btn:hover{background:#222c3c;border-color:#33405a} | |
| .two{display:grid;grid-template-columns:1fr 1fr;gap:8px} | |
| .swatch{height:18px;border-radius:5px;border:1px solid #ffffff22;margin-top:6px} | |
| .note{font-family:var(--mono);font-size:10px;color:var(--faint);line-height:1.5;margin-top:9px} | |
| :focus-visible{outline:2px solid var(--acc);outline-offset:2px} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <div id="stage"> | |
| <canvas id="gpu"></canvas> | |
| <div id="hud"></div> | |
| <div id="err"></div> | |
| </div> | |
| <aside> | |
| <div class="brand">WebGPU · 4³ 3D-DDA</div> | |
| <h1>Per-pixel volume raycast</h1> | |
| <p class="blurb">A 256³ sparse volume built entirely on the GPU (random-walk compute + OR-reduced occupancy mips — nothing large is uploaded). Each pixel walks a nested 4³ 3D-DDA to the first voxel; the crossed face gives the normal, shaded Cook-Torrance.</p> | |
| <div class="sec"><h2>Material</h2> | |
| <div class="row"><label>roughness</label><input id="rough" type="range" min="0.03" max="1" step="0.01" value="0.35"><span class="v" id="roughV">0.35</span></div> | |
| <div class="row"><label>metallic</label><input id="metal" type="range" min="0" max="1" step="0.01" value="0.10"><span class="v" id="metalV">0.10</span></div> | |
| <div class="row"><label>ambient</label><input id="amb" type="range" min="0" max="0.5" step="0.005" value="0.06"><span class="v" id="ambV">0.06</span></div> | |
| <div class="row"><label>exposure</label><input id="exp" type="range" min="0.2" max="4" step="0.05" value="1.30"><span class="v" id="expV">1.30</span></div> | |
| </div> | |
| <div class="sec"><h2>Light</h2> | |
| <div class="row"><label>pos X</label><input id="lx" type="range" min="-400" max="656" step="2" value="430"><span class="v" id="lxV">430</span></div> | |
| <div class="row"><label>pos Y</label><input id="ly" type="range" min="-400" max="656" step="2" value="470"><span class="v" id="lyV">470</span></div> | |
| <div class="row"><label>pos Z</label><input id="lz" type="range" min="-400" max="656" step="2" value="120"><span class="v" id="lzV">120</span></div> | |
| <div class="row"><label>intensity</label><input id="li" type="range" min="0" max="6" step="0.05" value="2.20"><span class="v" id="liV">2.20</span></div> | |
| <div class="row"><label>hue</label><input id="lh" type="range" min="0" max="1" step="0.01" value="0.09"><span class="v" id="lhV">0.09</span></div> | |
| <div class="swatch" id="lsw"></div> | |
| </div> | |
| <div class="sec"><h2>Volume</h2> | |
| <div class="row"><label>walks</label><input id="walks" type="range" min="1000" max="40000" step="1000" value="14000"><span class="v" id="walksV">14k</span></div> | |
| <div class="row"><label>steps</label><input id="steps" type="range" min="20" max="220" step="10" value="80"><span class="v" id="stepsV">80</span></div> | |
| <div class="row"><label>seed</label><input id="seed" type="range" min="1" max="999" step="1" value="7"><span class="v" id="seedV">7</span></div> | |
| <button class="btn" id="regen">Regenerate volume</button> | |
| </div> | |
| <div class="sec"><h2>Camera</h2> | |
| <div class="row"><label>speed</label><input id="spd" type="range" min="20" max="600" step="10" value="160"><span class="v" id="spdV">160</span></div> | |
| <div class="two"><button class="btn" id="reset">Reset view</button><button class="btn" id="center">Look at cube</button></div> | |
| <p class="note">drag = look · W A S D = move · E / Q = up / down · scroll = speed · fly inside the cube.</p> | |
| </div> | |
| </aside> | |
| </div> | |
| <script type="module"> | |
| const GRID = 256, LEVELS = 4; | |
| const errEl = document.getElementById('err'); | |
| function fail(m){ errEl.style.display='block'; errEl.textContent += (errEl.textContent?'\n':'') + m; console.error(m); } | |
| const COMMON = ` | |
| const GRID : i32 = ${GRID}; | |
| const GRIDF : f32 = ${GRID}.0; | |
| fn getbit(m: vec2<u32>, b: i32) -> bool { | |
| if (b < 32) { return ((m.x >> u32(b)) & 1u) != 0u; } | |
| return ((m.y >> u32(b - 32)) & 1u) != 0u; | |
| }`; | |
| const POPULATE_WGSL = COMMON + ` | |
| struct GP { seed:u32, walks:u32, steps:u32, pad:u32 }; | |
| @group(0) @binding(0) var<uniform> gp : GP; | |
| @group(0) @binding(1) var<storage, read_write> mip0 : array<atomic<u32>>; | |
| @group(0) @binding(2) var<storage, read_write> vals : array<u32>; | |
| fn hash(x0:u32)->u32 { var x=x0; x^=x>>16u; x*=0x7feb352du; x^=x>>15u; x*=0x846ca68bu; x^=x>>16u; return x; } | |
| fn rnd(s: ptr<function,u32>) -> f32 { let r = hash(*s); *s = *s + 0x9e3779b9u; return f32(r) * (1.0/4294967296.0); } | |
| fn hsv(h:f32)->vec3<f32>{ | |
| let hh=fract(h)*6.0; let c=1.0; let x=c*(1.0-abs(hh - 2.0*floor(hh*0.5) - 1.0)); | |
| if(hh<1.0){return vec3<f32>(c,x,0.0);} if(hh<2.0){return vec3<f32>(x,c,0.0);} if(hh<3.0){return vec3<f32>(0.0,c,x);} | |
| if(hh<4.0){return vec3<f32>(0.0,x,c);} if(hh<5.0){return vec3<f32>(x,0.0,c);} return vec3<f32>(c,0.0,x); | |
| } | |
| fn pack(c:vec3<f32>)->u32{ let r=u32(clamp(c.x,0.0,1.0)*255.0); let g=u32(clamp(c.y,0.0,1.0)*255.0); let b=u32(clamp(c.z,0.0,1.0)*255.0); return r | (g<<8u) | (b<<16u) | (255u<<24u); } | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid: vec3<u32>) { | |
| let w = gid.x; | |
| if (w >= gp.walks) { return; } | |
| var st = hash(gp.seed * 2654435761u + w * 2246822519u + 1u); | |
| var x = i32(rnd(&st) * GRIDF); | |
| var y = i32(rnd(&st) * GRIDF); | |
| var z = i32(rnd(&st) * GRIDF); | |
| var d = i32(rnd(&st) * 6.0); | |
| let col = pack(hsv(fract(f32(w) * 0.6180339 + 0.07)) * 0.82 + vec3<f32>(0.14)); | |
| var dirs = array<vec3<i32>,6>(vec3<i32>(1,0,0),vec3<i32>(-1,0,0),vec3<i32>(0,1,0),vec3<i32>(0,-1,0),vec3<i32>(0,0,1),vec3<i32>(0,0,-1)); | |
| for (var s:u32=0u; s<gp.steps; s=s+1u) { | |
| let bx=x>>2; let by=y>>2; let bz=z>>2; | |
| let bidx = (bz*64 + by)*64 + bx; | |
| let bit = (z&3)*16 + (y&3)*4 + (x&3); | |
| if (bit < 32) { atomicOr(&mip0[bidx*2], 1u << u32(bit)); } else { atomicOr(&mip0[bidx*2+1], 1u << u32(bit-32)); } | |
| vals[(z*256 + y)*256 + x] = col; | |
| let rv = i32(rnd(&st)*13.0) - 6; | |
| if (rv <= -4) { d = (d+1) % 6; } else if (rv >= 4) { d = (d+5) % 6; } | |
| let dir = dirs[d]; | |
| x = (x + 256 + dir.x) % 256; y = (y + 256 + dir.y) % 256; z = (z + 256 + dir.z) % 256; | |
| } | |
| }`; | |
| const BUILD_WGSL = COMMON + ` | |
| struct BP { childDim:u32, parentDim:u32, p0:u32, p1:u32 }; | |
| @group(0) @binding(0) var<uniform> bp : BP; | |
| @group(0) @binding(1) var<storage, read> childBuf : array<u32>; | |
| @group(0) @binding(2) var<storage, read_write> parentBuf : array<u32>; | |
| @compute @workgroup_size(64) | |
| fn main(@builtin(global_invocation_id) gid: vec3<u32>) { | |
| let pd = bp.parentDim; | |
| let total = pd*pd*pd; | |
| let pi = gid.x; | |
| if (pi >= total) { return; } | |
| let X = pi % pd; let Y = (pi/pd) % pd; let Z = pi/(pd*pd); | |
| let cd = bp.childDim; | |
| var plo:u32 = 0u; var phi:u32 = 0u; | |
| for (var cz:u32=0u; cz<4u; cz=cz+1u) { | |
| for (var cy:u32=0u; cy<4u; cy=cy+1u) { | |
| for (var cx:u32=0u; cx<4u; cx=cx+1u) { | |
| let ci = ((Z*4u+cz)*cd + (Y*4u+cy))*cd + (X*4u+cx); | |
| if (childBuf[ci*2u] != 0u || childBuf[ci*2u+1u] != 0u) { | |
| let b = (cz*4u + cy)*4u + cx; | |
| if (b < 32u) { plo = plo | (1u << b); } else { phi = phi | (1u << (b-32u)); } | |
| } | |
| } | |
| } | |
| } | |
| parentBuf[pi*2u] = plo; parentBuf[pi*2u+1u] = phi; | |
| }`; | |
| const RENDER_WGSL = COMMON + ` | |
| struct U { | |
| v0:vec4<f32>, v1:vec4<f32>, v2:vec4<f32>, v3:vec4<f32>, | |
| v4:vec4<f32>, v5:vec4<f32>, v6:vec4<f32>, | |
| }; | |
| @group(0) @binding(0) var<uniform> u : U; | |
| @group(0) @binding(1) var<storage, read> mip0 : array<u32>; | |
| @group(0) @binding(2) var<storage, read> mip1 : array<u32>; | |
| @group(0) @binding(3) var<storage, read> mip2 : array<u32>; | |
| @group(0) @binding(4) var<storage, read> mip3 : array<u32>; | |
| @group(0) @binding(5) var<storage, read> vals : array<u32>; | |
| @vertex fn vs(@builtin(vertex_index) vi:u32) -> @builtin(position) vec4<f32> { | |
| var p = array<vec2<f32>,3>(vec2<f32>(-1.0,-1.0), vec2<f32>(3.0,-1.0), vec2<f32>(-1.0,3.0)); | |
| return vec4<f32>(p[vi], 0.0, 1.0); | |
| } | |
| fn getMask(level:i32, c:vec3<i32>) -> vec2<u32> { | |
| if (level == 0) { | |
| if (any(c < vec3<i32>(0)) || any(c >= vec3<i32>(64))) { return vec2<u32>(0u,0u); } | |
| let i = (c.z*64 + c.y)*64 + c.x; return vec2<u32>(mip0[i*2], mip0[i*2+1]); | |
| } else if (level == 1) { | |
| if (any(c < vec3<i32>(0)) || any(c >= vec3<i32>(16))) { return vec2<u32>(0u,0u); } | |
| let i = (c.z*16 + c.y)*16 + c.x; return vec2<u32>(mip1[i*2], mip1[i*2+1]); | |
| } else if (level == 2) { | |
| if (any(c < vec3<i32>(0)) || any(c >= vec3<i32>(4))) { return vec2<u32>(0u,0u); } | |
| let i = (c.z*4 + c.y)*4 + c.x; return vec2<u32>(mip2[i*2], mip2[i*2+1]); | |
| } | |
| return vec2<u32>(mip3[0], mip3[1]); | |
| } | |
| struct Hit { hit:bool, vox:vec3<i32>, nrm:vec3<f32> }; | |
| fn traverse(o:vec3<f32>, dir0:vec3<f32>) -> Hit { | |
| var res : Hit; res.hit=false; res.vox=vec3<i32>(0); res.nrm=vec3<f32>(0.0,1.0,0.0); | |
| var d = dir0; | |
| if (abs(d.x)<1e-7) { d.x = select(1e-7,-1e-7,d.x<0.0); } | |
| if (abs(d.y)<1e-7) { d.y = select(1e-7,-1e-7,d.y<0.0); } | |
| if (abs(d.z)<1e-7) { d.z = select(1e-7,-1e-7,d.z<0.0); } | |
| d = normalize(d); | |
| let inside = all(o > vec3<f32>(0.0)) && all(o < vec3<f32>(GRIDF)); | |
| var e = o; var faceAxis : i32 = -1; let EPS = 1e-3; | |
| if (!inside) { | |
| let t1 = (vec3<f32>(0.0)-o)/d; let t2 = (vec3<f32>(GRIDF)-o)/d; | |
| let tlo = min(t1,t2); let thi = max(t1,t2); | |
| let tmin = max(max(tlo.x,tlo.y),tlo.z); let tmax = min(min(thi.x,thi.y),thi.z); | |
| if (tmax < tmin || tmax < 0.0) { return res; } | |
| if (tlo.x>=tlo.y && tlo.x>=tlo.z) { faceAxis=0; } else if (tlo.y>=tlo.z) { faceAxis=1; } else { faceAxis=2; } | |
| let tE = max(tmin,0.0)+EPS; e = clamp(o + d*tE, vec3<f32>(EPS), vec3<f32>(GRIDF-EPS)); | |
| } | |
| let sgn = vec3<i32>(select(1,-1,d.x<0.0), select(1,-1,d.y<0.0), select(1,-1,d.z<0.0)); | |
| let ia = vec3<f32>(1.0)/abs(d); | |
| let sgnPos = vec3<bool>(sgn.x>0, sgn.y>0, sgn.z>0); | |
| var sizesA = array<i32,4>(1,4,16,64); | |
| var scA:array<vec3<i32>,4>; var tDA:array<vec3<f32>,4>; var sTA:array<vec3<f32>,4>; | |
| var nearA:array<vec3<i32>,4>; var boundA:array<vec3<i32>,4>; | |
| for (var L:i32=0; L<4; L=L+1) { | |
| let s=f32(sizesA[L]); let invS=1.0/s; | |
| let sc=vec3<i32>(floor(e*invS)); scA[L]=sc; | |
| let tl=e - vec3<f32>(sc)*s; | |
| sTA[L]=select(tl, vec3<f32>(s)-tl, sgnPos)*ia; | |
| tDA[L]=vec3<f32>(s)*ia; | |
| nearA[L]=select(vec3<i32>(3), vec3<i32>(0), sgnPos); | |
| boundA[L]=select(vec3<i32>(-1), vec3<i32>(4), sgnPos); | |
| } | |
| var nrm:vec3<f32> = -d; | |
| if (!inside) { | |
| let sf = select(-1.0, 1.0, d[faceAxis] < 0.0); | |
| if (faceAxis==0) { nrm=vec3<f32>(sf,0.0,0.0); } else if (faceAxis==1) { nrm=vec3<f32>(0.0,sf,0.0); } else { nrm=vec3<f32>(0.0,0.0,sf); } | |
| } | |
| var last:i32 = select(faceAxis, -1, inside); | |
| var Flv:array<i32,4>; var Fb:array<vec3<i32>,4>; var Fc:array<vec3<i32>,4>; | |
| var Ft:array<vec3<f32>,4>; var Fm:array<vec2<u32>,4>; var Fph:array<i32,4>; | |
| var sp:i32 = 0; | |
| Flv[0]=3; Fb[0]=vec3<i32>(0); Fm[0]=getMask(3, vec3<i32>(0)); Fph[0]=0; | |
| { let cc = clamp(scA[3], vec3<i32>(0), vec3<i32>(3)); Fc[0]=cc; | |
| Ft[0]=sTA[3] + vec3<f32>(sgn)*vec3<f32>(cc - scA[3])*tDA[3]; } | |
| var guard:i32 = 0; | |
| loop { | |
| guard = guard + 1; if (guard > 1200) { break; } | |
| let L = Flv[sp]; | |
| if (Fph[sp] == 0) { | |
| let c = Fc[sp]; | |
| let b = (c.z*4 + c.y)*4 + c.x; | |
| if (getbit(Fm[sp], b)) { | |
| let o4 = Fb[sp]*4; | |
| if (L == 0) { res.hit=true; res.vox=o4+c; res.nrm=nrm; break; } | |
| let childB = o4 + c; | |
| let cm = getMask(L-1, childB); | |
| let parentT = Ft[sp]; | |
| sp = sp + 1; let cL = L - 1; | |
| Flv[sp]=cL; Fb[sp]=childB; Fm[sp]=cm; Fph[sp]=0; | |
| let s=f32(sizesA[cL]); let invS=1.0/s; let oo=childB*4; | |
| var cc:vec3<i32>; | |
| if (last==0) { let tC=parentT.x - tDA[L].x; cc=vec3<i32>(nearA[cL].x, i32(floor((e.y+tC*d.y)*invS))-oo.y, i32(floor((e.z+tC*d.z)*invS))-oo.z); } | |
| else if (last==1) { let tC=parentT.y - tDA[L].y; cc=vec3<i32>(i32(floor((e.x+tC*d.x)*invS))-oo.x, nearA[cL].y, i32(floor((e.z+tC*d.z)*invS))-oo.z); } | |
| else if (last==2) { let tC=parentT.z - tDA[L].z; cc=vec3<i32>(i32(floor((e.x+tC*d.x)*invS))-oo.x, i32(floor((e.y+tC*d.y)*invS))-oo.y, nearA[cL].z); } | |
| else { cc = scA[cL] - oo; } | |
| cc = clamp(cc, vec3<i32>(0), vec3<i32>(3)); | |
| Fc[sp]=cc; | |
| Ft[sp]=sTA[cL] + vec3<f32>(sgn)*vec3<f32>((oo+cc) - scA[cL])*tDA[cL]; | |
| continue; | |
| } | |
| Fph[sp] = 1; | |
| } | |
| let t = Ft[sp]; | |
| var axis:i32; | |
| if (t.x < t.y) { axis = select(2,0, t.x < t.z); } else { axis = select(2,1, t.y < t.z); } | |
| last = axis; | |
| if (axis == 0) { | |
| nrm = vec3<f32>(-f32(sgn.x),0.0,0.0); | |
| if (Fc[sp].x + sgn.x == boundA[L].x) { sp=sp-1; if (sp<0) { break; } Fph[sp]=1; continue; } | |
| Fc[sp].x = Fc[sp].x + sgn.x; Ft[sp].x = Ft[sp].x + tDA[L].x; | |
| } else if (axis == 1) { | |
| nrm = vec3<f32>(0.0,-f32(sgn.y),0.0); | |
| if (Fc[sp].y + sgn.y == boundA[L].y) { sp=sp-1; if (sp<0) { break; } Fph[sp]=1; continue; } | |
| Fc[sp].y = Fc[sp].y + sgn.y; Ft[sp].y = Ft[sp].y + tDA[L].y; | |
| } else { | |
| nrm = vec3<f32>(0.0,0.0,-f32(sgn.z)); | |
| if (Fc[sp].z + sgn.z == boundA[L].z) { sp=sp-1; if (sp<0) { break; } Fph[sp]=1; continue; } | |
| Fc[sp].z = Fc[sp].z + sgn.z; Ft[sp].z = Ft[sp].z + tDA[L].z; | |
| } | |
| Fph[sp] = 0; | |
| } | |
| return res; | |
| } | |
| fn DistGGX(NdotH:f32, a:f32)->f32 { let a2=a*a; let dd=NdotH*NdotH*(a2-1.0)+1.0; return a2/(3.14159265*dd*dd); } | |
| fn GSchlick(NdotV:f32, k:f32)->f32 { return NdotV/(NdotV*(1.0-k)+k); } | |
| fn fres(c:f32, F0:vec3<f32>)->vec3<f32>{ return F0 + (vec3<f32>(1.0)-F0)*pow(clamp(1.0-c,0.0,1.0),5.0); } | |
| @fragment fn fs(@builtin(position) fc: vec4<f32>) -> @location(0) vec4<f32> { | |
| let res = vec2<f32>(u.v6.y, u.v6.z); | |
| let ndc = vec2<f32>((fc.x/res.x)*2.0 - 1.0, 1.0 - (fc.y/res.y)*2.0); | |
| let origin = u.v0.xyz; let tanHalf = u.v0.w; | |
| let fwd = u.v1.xyz; let aspect = u.v1.w; | |
| let right = u.v2.xyz; let exposure = u.v2.w; | |
| let up = u.v3.xyz; let ambient = u.v3.w; | |
| let dir = normalize(fwd + right*(ndc.x*tanHalf*aspect) + up*(ndc.y*tanHalf)); | |
| let h = traverse(origin, dir); | |
| if (!h.hit) { | |
| let tt = clamp(dir.y*0.5 + 0.5, 0.0, 1.0); | |
| let bg = mix(vec3<f32>(0.035,0.045,0.065), vec3<f32>(0.08,0.11,0.16), tt); | |
| return vec4<f32>(bg, 1.0); | |
| } | |
| let p = vec3<f32>(h.vox) + vec3<f32>(0.5); | |
| let N = normalize(h.nrm); | |
| let V = normalize(origin - p); | |
| let lightPos = u.v4.xyz; let intensity = u.v4.w; | |
| let lightCol = u.v5.xyz; let metallic = u.v5.w; let rough = u.v6.x; | |
| let Lv = lightPos - p; let dist = length(Lv); let Ldir = Lv / max(dist,1e-3); | |
| let H = normalize(V + Ldir); | |
| let cidx = (h.vox.z*256 + h.vox.y)*256 + h.vox.x; | |
| let packed = vals[cidx]; | |
| let albedo = vec3<f32>(f32(packed & 255u), f32((packed>>8u)&255u), f32((packed>>16u)&255u)) * (1.0/255.0); | |
| let NdotL = max(dot(N,Ldir), 0.0); | |
| let NdotV = max(dot(N,V), 1e-4); | |
| let NdotH = max(dot(N,H), 0.0); | |
| let F0 = mix(vec3<f32>(0.04), albedo, metallic); | |
| let a = max(rough*rough, 1e-3); | |
| let r1 = rough + 1.0; let k = (r1*r1)/8.0; | |
| let D = DistGGX(NdotH, a); | |
| let G = GSchlick(NdotV,k) * GSchlick(NdotL,k); | |
| let F = fres(max(dot(H,V),0.0), F0); | |
| let spec = (D*G)*F / (4.0*NdotV*NdotL + 1e-4); | |
| let kd = (vec3<f32>(1.0)-F) * (1.0-metallic); | |
| let atten = intensity * (1.0 / (1.0 + (dist*dist)/(GRIDF*GRIDF))); | |
| let radiance = lightCol * atten; | |
| let Lo = (kd*albedo*(1.0/3.14159265) + spec) * radiance * NdotL; | |
| var color = ambient*albedo + Lo; | |
| color = vec3<f32>(1.0) - exp(-color*exposure); | |
| color = pow(color, vec3<f32>(1.0/2.2)); | |
| return vec4<f32>(color, 1.0); | |
| }`; | |
| // ----------------------------- host ----------------------------- | |
| const V = { | |
| sub:(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]], | |
| cross:(a,b)=>[a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]], | |
| norm:(a)=>{const l=Math.hypot(a[0],a[1],a[2])||1;return [a[0]/l,a[1]/l,a[2]/l];}, | |
| }; | |
| const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); | |
| function hsv2rgb(h){h=((h%1)+1)%1; const i=Math.floor(h*6), f=h*6-i, q=1-f; | |
| const t=[[1,f,0],[q,1,0],[0,1,f],[0,q,1],[f,0,1],[1,0,q]][i%6]; return t;} | |
| const canvas = document.getElementById('gpu'); | |
| const hud = document.getElementById('hud'); | |
| const state = { | |
| rough:0.35, metal:0.10, amb:0.06, exp:1.30, | |
| lx:430, ly:470, lz:120, li:2.20, lh:0.09, | |
| walks:14000, steps:80, seed:7, spd:160, | |
| }; | |
| const cam = { pos:[330,300,470], yaw:0, pitch:0, fov:60*Math.PI/180 }; | |
| function fwdVec(){ const cp=Math.cos(cam.pitch); return [Math.sin(cam.yaw)*cp, Math.sin(cam.pitch), Math.cos(cam.yaw)*cp]; } | |
| function lookAt(t){ const f=V.norm(V.sub(t,cam.pos)); cam.pitch=clamp(Math.asin(clamp(f[1],-1,1)),-1.5,1.5); cam.yaw=Math.atan2(f[0],f[2]); } | |
| function resetView(){ cam.pos=[330,300,470]; lookAt([128,128,128]); } | |
| let device, context, format, ub, uniformBuf, gpBuf, bpBufs=[], pop, build, render, popBG, buildBGs=[], renderBG; | |
| let mip0,mip1,mip2,mip3,valBuf; | |
| async function main(){ | |
| if (!navigator.gpu) { fail('WebGPU is not available in this browser.\nTry Chrome/Edge 113+ or Safari 18+ (or enable the WebGPU flag), then reload.'); return; } | |
| const adapter = await navigator.gpu.requestAdapter({powerPreference:'high-performance'}); | |
| if (!adapter) { fail('No suitable GPU adapter found.'); return; } | |
| device = await adapter.requestDevice(); | |
| device.lost.then(info=>{ if(info.reason!=='destroyed') fail('GPU device lost: '+info.message); }); | |
| context = canvas.getContext('webgpu'); | |
| format = navigator.gpu.getPreferredCanvasFormat(); | |
| context.configure({ device, format, alphaMode:'opaque' }); | |
| // buffers (all generated on-GPU; nothing large is uploaded) | |
| const ST = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST; | |
| mip0 = device.createBuffer({ size: 64*64*64*2*4, usage: ST }); // 2 MB | |
| mip1 = device.createBuffer({ size: 16*16*16*2*4, usage: ST }); | |
| mip2 = device.createBuffer({ size: 4*4*4*2*4, usage: ST }); | |
| mip3 = device.createBuffer({ size: 16, usage: ST }); | |
| valBuf = device.createBuffer({ size: GRID*GRID*GRID*4, usage: ST }); // 64 MB | |
| uniformBuf = device.createBuffer({ size: 7*16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); | |
| gpBuf = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); | |
| ub = new Float32Array(7*4); | |
| async function module(code, label){ | |
| const m = device.createShaderModule({ code, label }); | |
| try { const info = await m.getCompilationInfo(); for (const msg of info.messages){ if (msg.type==='error') fail(`[${label}] line ${msg.lineNum}: ${msg.message}`); } } catch(e){} | |
| return m; | |
| } | |
| const popMod = await module(POPULATE_WGSL, 'populate'); | |
| const bldMod = await module(BUILD_WGSL, 'build'); | |
| const rndMod = await module(RENDER_WGSL, 'render'); | |
| if (errEl.style.display==='block') return; // shader errors — stop | |
| pop = device.createComputePipeline({ layout:'auto', compute:{ module:popMod, entryPoint:'main' } }); | |
| build = device.createComputePipeline({ layout:'auto', compute:{ module:bldMod, entryPoint:'main' } }); | |
| render = device.createRenderPipeline({ | |
| layout:'auto', | |
| vertex:{ module:rndMod, entryPoint:'vs' }, | |
| fragment:{ module:rndMod, entryPoint:'fs', targets:[{ format }] }, | |
| primitive:{ topology:'triangle-list' }, | |
| }); | |
| popBG = device.createBindGroup({ layout: pop.getBindGroupLayout(0), entries:[ | |
| { binding:0, resource:{ buffer:gpBuf } }, | |
| { binding:1, resource:{ buffer:mip0 } }, | |
| { binding:2, resource:{ buffer:valBuf } }, | |
| ]}); | |
| // three mip-build bind groups (same pipeline, child->parent per level) | |
| const levels = [ {c:mip0,p:mip1,cd:64,pd:16}, {c:mip1,p:mip2,cd:16,pd:4}, {c:mip2,p:mip3,cd:4,pd:1} ]; | |
| for (const lv of levels){ | |
| const bp = device.createBuffer({ size:16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); | |
| device.queue.writeBuffer(bp, 0, new Uint32Array([lv.cd, lv.pd, 0, 0])); | |
| bpBufs.push(bp); | |
| buildBGs.push(device.createBindGroup({ layout: build.getBindGroupLayout(0), entries:[ | |
| { binding:0, resource:{ buffer:bp } }, | |
| { binding:1, resource:{ buffer:lv.c } }, | |
| { binding:2, resource:{ buffer:lv.p } }, | |
| ]})); | |
| } | |
| renderBG = device.createBindGroup({ layout: render.getBindGroupLayout(0), entries:[ | |
| { binding:0, resource:{ buffer:uniformBuf } }, | |
| { binding:1, resource:{ buffer:mip0 } }, | |
| { binding:2, resource:{ buffer:mip1 } }, | |
| { binding:3, resource:{ buffer:mip2 } }, | |
| { binding:4, resource:{ buffer:mip3 } }, | |
| { binding:5, resource:{ buffer:valBuf } }, | |
| ]}); | |
| buildVolume(); | |
| resetView(); | |
| resize(); | |
| syncPanel(); | |
| requestAnimationFrame(frame); | |
| } | |
| function buildVolume(){ | |
| device.queue.writeBuffer(gpBuf, 0, new Uint32Array([state.seed>>>0, state.walks>>>0, state.steps>>>0, 0])); | |
| const enc = device.createCommandEncoder(); | |
| // GPU-side zero (no CPU->GPU transfer of the big buffers) | |
| enc.clearBuffer(mip0); enc.clearBuffer(mip1); enc.clearBuffer(mip2); enc.clearBuffer(mip3); enc.clearBuffer(valBuf); | |
| { const p = enc.beginComputePass(); p.setPipeline(pop); p.setBindGroup(0, popBG); | |
| p.dispatchWorkgroups(Math.ceil(state.walks/64)); p.end(); } | |
| const disp = [ Math.ceil(16*16*16/64), Math.ceil(4*4*4/64), 1 ]; | |
| for (let i=0;i<3;i++){ const p = enc.beginComputePass(); p.setPipeline(build); p.setBindGroup(0, buildBGs[i]); | |
| p.dispatchWorkgroups(disp[i]); p.end(); } | |
| device.queue.submit([enc.finish()]); | |
| } | |
| // ---- input ---- | |
| const keys = new Set(); | |
| addEventListener('keydown', e=>{ const k=e.key.toLowerCase(); if(['w','a','s','d','q','e'].includes(k)){ keys.add(k); e.preventDefault(); } }); | |
| addEventListener('keyup', e=>{ keys.delete(e.key.toLowerCase()); }); | |
| let dragging=false, lastX=0, lastY=0; | |
| canvas.addEventListener('pointerdown', e=>{ dragging=true; lastX=e.clientX; lastY=e.clientY; canvas.classList.add('drag'); }); | |
| addEventListener('pointermove', e=>{ if(!dragging) return; const dx=e.clientX-lastX, dy=e.clientY-lastY; lastX=e.clientX; lastY=e.clientY; | |
| cam.yaw += dx*0.005; cam.pitch = clamp(cam.pitch - dy*0.005, -1.5, 1.5); }); | |
| addEventListener('pointerup', ()=>{ dragging=false; canvas.classList.remove('drag'); }); | |
| canvas.addEventListener('wheel', e=>{ state.spd = clamp(state.spd*Math.exp(-e.deltaY*0.0012), 20, 1200); document.getElementById('spd').value=state.spd; document.getElementById('spdV').textContent=Math.round(state.spd); e.preventDefault(); }, {passive:false}); | |
| function resize(){ | |
| const dpr = Math.min(2, window.devicePixelRatio||1); | |
| const w = Math.max(1, Math.floor(canvas.clientWidth*dpr)), h = Math.max(1, Math.floor(canvas.clientHeight*dpr)); | |
| if (canvas.width!==w || canvas.height!==h){ canvas.width=w; canvas.height=h; } | |
| } | |
| addEventListener('resize', resize); | |
| let lastT = performance.now(), fps=0, acc=0, frames=0; | |
| function frame(now){ | |
| const dt = Math.min(0.05, (now-lastT)/1000); lastT=now; | |
| // movement | |
| const f = fwdVec(); const r = V.norm(V.cross([0,1,0], f)); const up = V.cross(f, r); | |
| const v = state.spd*dt; | |
| const add=(s,k)=>{ cam.pos[0]+=s[0]*k; cam.pos[1]+=s[1]*k; cam.pos[2]+=s[2]*k; }; | |
| if (keys.has('w')) add(f,v); if (keys.has('s')) add(f,-v); | |
| if (keys.has('d')) add(r,v); if (keys.has('a')) add(r,-v); | |
| if (keys.has('e')) add(up,v); if (keys.has('q')) add(up,-v); | |
| resize(); | |
| const aspect = canvas.width/canvas.height, tanHalf = Math.tan(cam.fov/2); | |
| const lc = hsv2rgb(state.lh); | |
| ub.set([ | |
| cam.pos[0],cam.pos[1],cam.pos[2], tanHalf, | |
| f[0],f[1],f[2], aspect, | |
| r[0],r[1],r[2], state.exp, | |
| up[0],up[1],up[2], state.amb, | |
| state.lx,state.ly,state.lz, state.li, | |
| lc[0],lc[1],lc[2], state.metal, | |
| state.rough, canvas.width, canvas.height, 0, | |
| ], 0); | |
| device.queue.writeBuffer(uniformBuf, 0, ub); | |
| const enc = device.createCommandEncoder(); | |
| const pass = enc.beginRenderPass({ colorAttachments:[{ view: context.getCurrentTexture().createView(), clearValue:{r:0,g:0,b:0,a:1}, loadOp:'clear', storeOp:'store' }] }); | |
| pass.setPipeline(render); pass.setBindGroup(0, renderBG); pass.draw(3); pass.end(); | |
| device.queue.submit([enc.finish()]); | |
| // hud | |
| acc+=dt; frames++; if(acc>0.4){ fps=frames/acc; acc=0; frames=0; } | |
| const inside = cam.pos.every(c=>c>0&&c<GRID); | |
| hud.innerHTML = `<b>${fps.toFixed(0)}</b> fps <span class="k">pos</span> ${cam.pos.map(c=>c.toFixed(0)).join(', ')} <span class="k">${inside?'inside':'outside'}</span> the cube`; | |
| requestAnimationFrame(frame); | |
| } | |
| // ---- panel wiring ---- | |
| function bind(id, key, fmt){ const el=document.getElementById(id), out=document.getElementById(id+'V'); | |
| el.addEventListener('input', ()=>{ state[key]=+el.value; out.textContent=fmt(state[key]); if(key==='lh') swatch(); }); } | |
| function swatch(){ const c=hsv2rgb(state.lh).map(x=>Math.round(x*255)); document.getElementById('lsw').style.background=`rgb(${c[0]},${c[1]},${c[2]})`; } | |
| function syncPanel(){ | |
| for (const [id,k] of [['rough','rough'],['metal','metal'],['amb','amb'],['exp','exp'],['lx','lx'],['ly','ly'],['lz','lz'],['li','li'],['lh','lh'],['spd','spd']]){ | |
| document.getElementById(id).value = state[k]; | |
| } | |
| document.getElementById('walks').value=state.walks; document.getElementById('steps').value=state.steps; document.getElementById('seed').value=state.seed; | |
| document.getElementById('walksV').textContent=(state.walks/1000)+'k'; | |
| swatch(); | |
| } | |
| bind('rough','rough',v=>v.toFixed(2)); bind('metal','metal',v=>v.toFixed(2)); | |
| bind('amb','amb',v=>v.toFixed(3)); bind('exp','exp',v=>v.toFixed(2)); | |
| bind('lx','lx',v=>v|0); bind('ly','ly',v=>v|0); bind('lz','lz',v=>v|0); | |
| bind('li','li',v=>v.toFixed(2)); bind('lh','lh',v=>v.toFixed(2)); bind('spd','spd',v=>v|0); | |
| document.getElementById('walks').addEventListener('input',e=>{ state.walks=+e.target.value; document.getElementById('walksV').textContent=(state.walks/1000)+'k'; }); | |
| document.getElementById('steps').addEventListener('input',e=>{ state.steps=+e.target.value; document.getElementById('stepsV').textContent=state.steps; }); | |
| document.getElementById('seed').addEventListener('input',e=>{ state.seed=+e.target.value; document.getElementById('seedV').textContent=state.seed; }); | |
| document.getElementById('regen').addEventListener('click', ()=>{ if(device) buildVolume(); }); | |
| document.getElementById('reset').addEventListener('click', resetView); | |
| document.getElementById('center').addEventListener('click', ()=>lookAt([128,128,128])); | |
| main().catch(err=>fail('Init error: '+(err && err.message ? err.message : err))); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment