Skip to content

Instantly share code, notes, and snippets.

@sirisian
Created June 29, 2026 23:22
Show Gist options
  • Select an option

  • Save sirisian/3305d0930bedd6eabb2e795b6cf06fa8 to your computer and use it in GitHub Desktop.

Select an option

Save sirisian/3305d0930bedd6eabb2e795b6cf06fa8 to your computer and use it in GitHub Desktop.
Occupancy Tree Example (Claude Opus 4.8)
<!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 &nbsp; <span class="k">pos</span> ${cam.pos.map(c=>c.toFixed(0)).join(', ')} &nbsp; <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