WebGPU pixel look: GPU colours the world, CPU only rare cells (t88 phase 2a)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:49:36 -04:00
co-authored by Claude Opus 5.5
parent b2c9ad9533
commit fc05146887
29 changed files with 949 additions and 386 deletions
+14 -5
View File
@@ -29,7 +29,7 @@ function perfSim(names, scene, w, h) {
return { total_ms: total, ticks: n, start, end: perfState(), steps,
rest_ms: total - Object.values(data).reduce((s, d) => s + d.ms / n, 0) };
}
function perfRender(lighting, overlays) {
async function perfRender(lighting, overlays) {
SET.lighting = lighting;
PERF_MARKS = true;
try {
@@ -48,13 +48,22 @@ function perfRender(lighting, overlays) {
// Exercise the actual overlay calls and their arguments/conditions in frame().
PERF_MARKS = false;
for (let k = 0; k < 5; k++) frame(lastT + 1000 / 60);
const data = {}, restore = perfWrap(overlays, data);
const data = {}, restore = perfWrap(overlays, data), frameStart = performance.now();
try { for (let k = 0; k < n; k++) frame(lastT + 1000 / 60); }
finally { restore(); }
return { drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks,
const frame_ms = (performance.now() - frameStart) / n;
// One completed timestamp sample for this configuration, outside the CPU measurements.
let gpu_ms = null;
if (R.gpuOn && GPU.queries) {
await GPU.timingPromise;
PERF_MARKS = true; drawWorld();
await GPU.timingPromise;
PERF_MARKS = false; gpu_ms = GPU.gpuMs; marks.gpu = gpu_ms;
}
return { frame_ms, drawWorld_ms: draw, computeLight_ms: light, marks_ms: marks,
overlays: Object.fromEntries(Object.entries(data).map(([key, d]) => [key, { ms_per_frame: d.ms / n, calls: d.calls }])),
overlay_ms: Object.values(data).reduce((sum, d) => sum + d.ms / n, 0),
scale: SM.scale, actual_look: SET.look };
scale: SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms };
} finally { PERF_MARKS = false; }
}
function perfRun(seconds) {
@@ -72,7 +81,7 @@ function perfRun(seconds) {
requestAnimationFrame(frame);
setTimeout(() => {
window.__perfHalt(); PERF_FRAME = null; GAME.paused = true;
resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look });
resolve({ samples, end: perfState(), scale: SM.scale, actual_look: SET.look, renderer: R.gpuOn ? 'webgpu' : 'legacy', override_cells: GPU.overrideCount || 0, gpu_ms: R.gpuOn ? GPU.gpuMs : null });
}, seconds * 1000);
});
}
+14 -4
View File
@@ -54,14 +54,14 @@ def report(result, baseline=None):
m = result['machine']
print(f"GPU: {m['renderer']}\nCores: {m['cores']} | isolated: {m['crossOriginIsolated']} | navigator.gpu: {m['webgpu']}")
print(f"Viewport {result['options']['viewport']} | world {result['options']['world']} | seed 424242 | ms (CPU wall time)")
print('\nScene Look Zoom Light Grains World Light Prep Colour Upload Blit Overlays Scale')
print('\nScene Look Zoom Light Grains World Frame Light Prep Colour Upload Blit Overlays Scale')
for scene, data in result['scenes'].items():
for key, view in data['views'].items():
look, zoom = key.split('/')
for light, r in view['lighting'].items():
p = r['marks_ms']
print(f"{scene:8} {look:6} {zoom:>4} {light:>5} {view['grains']:6d} "
f"{r['drawWorld_ms']:7.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} "
f"{r['drawWorld_ms']:7.2f} {r.get('frame_ms', 0):6.2f} {p['light']:6.2f} {p['prepare']:5.2f} {p['color']:6.2f} "
f"{p['upload']:6.2f} {p['blit']:6.2f} {r['overlay_ms']:8.3f} {r['scale']:5.2f}")
print('\nSimulation: average ms/tick (periodic work amortized over 240 ticks)')
names = list(next(iter(result['scenes'].values()))['sim']['steps'])
@@ -125,8 +125,10 @@ def main():
options = dict(viewport=list(args.viewport), world=list(args.world), zooms=zooms,
looks=looks, scenes=scenes, seconds=args.seconds)
baseline = json.loads(args.compare.read_text()) if args.compare else None
if baseline and (baseline.get('schema') != 1 or baseline['options'] != options):
ap.error('baseline schema/options differ; use the same matrix, dimensions and seconds')
if baseline and (baseline.get('schema') != 1
or any(baseline['options'].get(k) != v for k, v in options.items() if k not in ('looks', 'scenes', 'zooms'))
or any(not set(options[k]).issubset(baseline['options'][k]) for k in ('looks', 'scenes', 'zooms'))):
ap.error('baseline schema/options differ; use a subset of the baseline matrix with the same dimensions and seconds')
ensure_playwright()
started = time.monotonic()
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True, stdout=sys.stderr)
@@ -154,6 +156,9 @@ def main():
const id = raf(t => { pending.delete(id); f(t); }); pending.add(id); return id;
};''')
pg.goto(url)
pg.evaluate('CAPS_CHECK')
if not pg.evaluate('GPU.ok'):
raise RuntimeError('WebGPU unavailable: ' + pg.evaluate('GPU.error'))
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
pg.add_script_tag(content=(ROOT / 'tools/perf-measure.js').read_text())
result['machine'] = pg.evaluate('''() => {
@@ -162,6 +167,7 @@ def main():
return { renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
cores: navigator.hardwareConcurrency, crossOriginIsolated, webgpu: !!navigator.gpu,
adapter: CAPS.adapterInfo, timestamp_query: !!GPU.queries,
user_agent: navigator.userAgent, dpr: devicePixelRatio };
}''')
if not result['machine']['crossOriginIsolated']:
@@ -185,6 +191,8 @@ def main():
view['lighting'][light] = pg.evaluate('([l,o]) => perfRender(l,o)', [light, overlays])
if view['lighting']['full']['actual_look'] != look or view['lighting']['off']['actual_look'] != look:
raise RuntimeError('Look fell back while measuring')
if look == 'pixel' and any(r['renderer'] != 'webgpu' for r in view['lighting'].values()):
raise RuntimeError('WebGPU fell back while measuring: ' + pg.evaluate('GPU.error'))
data['views'][f'{look}/{zoom:g}'] = view
for look in looks:
for zoom in [2, 6]:
@@ -193,6 +201,8 @@ def main():
r = pg.evaluate('seconds => perfRun(seconds)', args.seconds)
if r['actual_look'] != look:
raise RuntimeError('Look fell back during real run')
if look == 'pixel' and r['renderer'] != 'webgpu':
raise RuntimeError('WebGPU fell back during real run: ' + pg.evaluate('GPU.error'))
for key in ['interval', 'work', 'sim']:
r[key + '_ms'] = quantiles([s[key] for s in r['samples']])
data['runs'][f'{look}/{zoom}'] = r
+29
View File
@@ -0,0 +1,29 @@
// Paused comparison scene: ordinary grains, fog, heat, trees and exceptional geometry.
const cx = (W / 3) & ~3, cy = 180;
window.CAM = [cx, cy];
SET.fog = true; SET.shake = false; SET.cameraLead = false;
weather.next = 1e15; R.rain = 0;
for (let y = cy - 52; y < cy + 52; y++) for (let x = cx - 90; x < cx + 90; x++) {
const i = y * W + x;
setCell(i, y >= cy + 24 ? STONE : EMPTY, 20, y >= cy + 24 ? F_NAT : 0);
bgm[i] = part[i] = mach[i] = occ[i] = 0; blockP[(y >> 2) * BW + (x >> 2)] = 0;
}
for (let x = cx - 90; x < cx + 90; x++) { surfY[x] = cy + 16; dirtY[x] = cy + 30; graniteY[x] = cy + 38; basaltY[x] = cy + 46; }
const fill = (x0,y0,w,h,m,T=20) => { for(let y=y0;y<y0+h;y++)for(let x=x0;x<x0+w;x++)setCell(y*W+x,m,T,F_NAT); };
fill(cx-62,cy+6,22,16,WATER); fill(cx-34,cy+11,16,11,MAGMA,1450);
fill(cx-33,cy+3,8,8,FIRE,900); fill(cx-12,cy+17,15,6,SAND);
fill(cx-10,cy+24,12,5,STONE,1100);
for(let y=cy-20;y<cy+16;y++)for(let x=cx-58;x<cx-54;x++)bgm[y*W+x]=WOOD;
for(let y=cy-28;y<cy-12;y++)for(let x=cx-68;x<cx-44;x++)if((x-cx+56)**2+(y-cy+20)**2<130)bgm[y*W+x]=LEAVES;
for(let x=cx-40;x<cx-34;x++)digp[(cy+24)*W+x]=160;
const place=(code,v,dx,dy)=>{const e=placeBlock(code,v,(cx+dx)>>2,(cy+dy)>>2,true);if(e)throw Error(e);};
for(let dx=-36;dx<-12;dx+=4)place(BP_BELT,1,dx,-12);
place(BP_SCREW,1,-4,-12); place(BP_SCREW,4,0,-12);
place(BP_BELT_UP,1,16,-12);place(BP_BELT_UP,1,20,-16);
place(BP_PIPE,0,32,-12);place(BP_PIPE,0,36,-12);
addMold('ingot',(cx+8)>>2,(cy+8)>>2,RSTONE,true);
spawnBar(BARCU,cx-25,cy-16,0,0,700);
flyers.push({x:cx-8,y:cy-18,m:SAND,rain:false});
ghostBlocks.set(((cy-4)>>2)*BW+((cx-4)>>2),[BP_WALL,RSTONE]);
for(let j=0;j<FH;j++)for(let i=0;i<FW;i++)fog[j*FW+i]=Math.max(0,Math.min(255,(cx+72-i*FOG)*5));
updateRoof(); rebuildPower();
+7 -1
View File
@@ -13,6 +13,7 @@ code and the bot is moved there before the shot. SX, SY are the bot's starting p
Options:
--look pixel|smooth which look to draw (default: pixel; use smooth only to check graphics)
--renderer webgpu|legacy pixel renderer (default: webgpu)
--seed N world seed (default 424242)
--size WxH world size in grains (default 2048x1024)
--setup FILE.js code to run once the world is ready (see tools/scenes/ for examples)
@@ -51,6 +52,7 @@ def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('out')
ap.add_argument('--look', choices=['pixel', 'smooth'], default='pixel')
ap.add_argument('--renderer', choices=['webgpu', 'legacy'], default='webgpu')
ap.add_argument('--seed', type=int, default=424242)
ap.add_argument('--size', default='2048x1024')
ap.add_argument('--setup')
@@ -75,6 +77,8 @@ def main():
pg.add_init_script(FREEZE if a.freeze else '')
pg.goto(url)
pg.evaluate("SET.fog = false")
pg.evaluate('CAPS_CHECK')
pg.evaluate('(r) => { SET.renderer = r; if (r === \'webgpu\' && !GPU.ok) throw Error(GPU.error || \'WebGPU unavailable\'); }', a.renderer)
pg.wait_for_timeout(300)
# pause the moment the world exists, so the shot depends only on the seed, the setup
# code and --ticks (simTick is deterministic), not on how fast the browser ran
@@ -99,6 +103,8 @@ def main():
pg.evaluate("() => { window.__frozenNow = 5000; window.requestAnimationFrame = () => 0; }")
pg.wait_for_timeout(100)
pg.evaluate("() => { R.frame = 999; if (typeof SM !== 'undefined') SM.t0 = 0; frame(lastRender + 1000); }")
if a.look == 'pixel' and a.renderer == 'webgpu' and not pg.evaluate('R.gpuOn'):
raise RuntimeError('WebGPU fell back: ' + pg.evaluate('GPU.error'))
kw = {}
if a.clip:
x, y, w, h = (float(v) for v in a.clip.split(','))
@@ -107,7 +113,7 @@ def main():
pg.screenshot(path=a.out, **kw)
note = pg.evaluate("SET.look === 'smooth' ? (SM.ok ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'")
b.close()
print(f'saved {a.out} ({note})')
print(f"saved {a.out} ({note}, {a.renderer if a.look == 'pixel' else 'legacy'})")
for e in errors:
print('ERROR', e)
sys.exit(1 if errors else 0)
+3 -2
View File
@@ -38,6 +38,7 @@ def main():
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.add_init_script('window.requestAnimationFrame = () => 0;')
pg.goto(url)
pg.evaluate('CAPS_CHECK')
pg.add_script_tag(content=(ROOT / 'tools/perf-scenes.js').read_text())
assert pg.evaluate('crossOriginIsolated')
# Compare to the unwrapped functions, and hash the byte representation of all grid state.
@@ -66,8 +67,8 @@ def main():
performance.now = () => 5000;
R.rain = rainAt(Math.max(0, Math.min(W - 1, Math.floor(R.camX + R.bw / 2))));
try {
PERF_MARKS = false; drawWorld(); const a = R.canvas.toDataURL();
PERF_MARKS = true; drawWorld(); const b = R.canvas.toDataURL();
PERF_MARKS = false; drawWorld(); const a = worldPixelContext().canvas.toDataURL();
PERF_MARKS = true; drawWorld(); const b = worldPixelContext().canvas.toDataURL();
return a === b;
} finally { PERF_MARKS = false; performance.now = now; }
}''', look)