WebGPU renderer complete: dirty uploads, default limits, legacy removed, WebGPU required, warm-up (t88 phase 2d)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+111
-145
@@ -1,159 +1,125 @@
|
||||
"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss."""
|
||||
"""Dirty uploads, default limits, warm pipelines, world replacement and device recovery."""
|
||||
import pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||
sys.path.insert(0,str(pathlib.Path(__file__).resolve().parent.parent/'tools'))
|
||||
from use_venv import ensure_playwright; ensure_playwright()
|
||||
from browser import launch, consume_gpu_option
|
||||
gpu_mode = consume_gpu_option()
|
||||
root = pathlib.Path(__file__).resolve().parent.parent
|
||||
with launch(viewport={'width': 960, 'height': 600}) as (browser, pg, url):
|
||||
errors = []
|
||||
pg.on('pageerror', lambda e: errors.append(str(e)))
|
||||
pg.on('console', lambda m: errors.append(m.text) if m.type == 'error' else None)
|
||||
pg.add_init_script('requestAnimationFrame = () => 0;')
|
||||
pg.goto(url); pg.evaluate('CAPS_CHECK')
|
||||
if gpu_mode == 'soft' and not pg.evaluate('CAPS.webgpu'):
|
||||
print('SKIP WebGPU parity: software browser has no WebGPU adapter')
|
||||
sys.exit(0)
|
||||
assert pg.evaluate('GPU.ok'), pg.evaluate('GPU.error')
|
||||
pg.evaluate('generateWorld(424242, 640, 384, null); enterGame(); GAME.paused=true;')
|
||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
|
||||
result = pg.evaluate("""() => {
|
||||
SET.look='pixel'; SET.zoom=6; SET.glow='off'; SET.lighting='off';
|
||||
resizeRender(); bot.x=CAM[0]-bot.w/2; bot.y=CAM[1]-bot.h/2; snapCamera(); R.frame=1000;
|
||||
consume_gpu_option()
|
||||
root=pathlib.Path(__file__).resolve().parent.parent
|
||||
with launch(viewport={'width':960,'height':600}) as (_,pg,url):
|
||||
errors=[]
|
||||
pg.on('pageerror',lambda e:errors.append(str(e)))
|
||||
pg.on('console',lambda m:errors.append(m.text) if m.type=='error' else None)
|
||||
pg.add_init_script('requestAnimationFrame=()=>0;')
|
||||
pg.goto(url);pg.evaluate('CAPS_CHECK')
|
||||
assert pg.evaluate('GPU.ok'),pg.evaluate('GPU.error')
|
||||
assert pg.evaluate('GPU.device.limits.maxStorageTexturesPerShaderStage===4')
|
||||
print('PASS all pipelines compile with default limits',pg.evaluate('GPU_LIMITS'))
|
||||
startup=pg.evaluate("""async()=>{
|
||||
await startNewWorld(424242,640,384); GAME.paused=true;
|
||||
const samples=[];
|
||||
for(let n=0;n<10;n++) {const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();samples.push(performance.now()-t);}
|
||||
return {compile:GPU.initMs,warm:GPU.warmMs,first:samples[0],normal:samples.slice(1)};
|
||||
}""")
|
||||
assert startup['warm']>=0
|
||||
print('PASS both looks submit behind loading screen; first visible frame ms',startup)
|
||||
pg.add_script_tag(path=str(root/'tools/perf-scenes.js'))
|
||||
pg.evaluate("perfScene('factory',1024,640); SET.look='pixel'; SET.zoom=2;resizeRender();snapCamera();drawWorld();")
|
||||
result=pg.evaluate('''async () => {
|
||||
const results=[];
|
||||
for(const rain of [0,0.7])for(const lit of [false,true])for(const lens of [false,true])for(const palette of ['inferno','ironbow','gray'])for(const fogOn of [false,true]) {
|
||||
SET.lighting=lit?'full':'off'; SET.glow=lit?'low':'off';
|
||||
R.lens=lens; SET.thermalPalette=palette; SET.fog=fogOn;
|
||||
R.rain=rain; SET.renderer='legacy';drawWorld(); const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
R.rain=rain; SET.renderer='webgpu';drawWorld(); const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0, sum=0, bad=0;
|
||||
for(let i=0;i<a.length;i+=4) {let d=0;for(let j=0;j<3;j++){const e=Math.abs(a[i+j]-b[i+j]);sum+=e;d=Math.max(d,e);}max=Math.max(max,d);bad+=d>8;}
|
||||
results.push({rain,lit,lens,palette,fogOn,max,mean:sum/(960*600*3),bad});
|
||||
}
|
||||
R.lens=false;
|
||||
return results;
|
||||
}""")
|
||||
for r in result:
|
||||
assert r['bad'] == 0, r
|
||||
print('PASS plain cells, built geometry, ghosts, rain and all thermal palettes match legacy within 8', len(result), 'configurations; max', max(r['max'] for r in result))
|
||||
assert pg.evaluate('GPU.overrideCount > 0 && GPU.overrideCount < R.bw*R.bh*0.25')
|
||||
alpha = pg.evaluate('[R.gpuOn,GPU.ok,GPU.error,R.ctx.getImageData(10,10,1,1).data[3],worldPixelContext().getImageData(10,10,1,1).data[3],R.canvas.width,R.canvas.height]')
|
||||
assert alpha[3:5] == [0,255], alpha
|
||||
print('PASS most visible grains use GPU colouring; pixel reads include the opaque GPU world')
|
||||
glow = pg.evaluate("""() => {
|
||||
SET.fog=false; SET.lighting='off'; SET.glow='high'; R.lens=false;
|
||||
spawnBar(BARFE,CAM[0]-30,CAM[1]+15,0,0,20);
|
||||
const x=CAM[0]-64,y=CAM[1]-36;
|
||||
setCell(y*W+x,MAGMA,1450,F_NAT);
|
||||
flyers.push({x,y,m:COAL,rain:false});
|
||||
SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0;for(let i=0;i<a.length;i++)if((i&3)!==3)max=Math.max(max,Math.abs(a[i]-b[i]));return max;
|
||||
}""")
|
||||
assert glow <= 8, f'Objects must retain the hot grain glow beneath them: max error {glow}'
|
||||
print('PASS bars and flying grains preserve the glow behind them', glow)
|
||||
# Fresh arrays, same dimensions: catches stale world/static textures after a load.
|
||||
pg.evaluate('generateWorld(777, 640, 384, null); buildSky(); SET.renderer="webgpu"; SET.zoom=2; resizeRender(); snapCamera(); drawWorld();')
|
||||
assert pg.evaluate('GPU.world===mat && GPU.colourTex.width===R.bw && GPU.ok')
|
||||
parity = pg.evaluate("""() => {
|
||||
const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
SET.renderer='legacy';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
SET.renderer='webgpu'; let max=0,nonblack=0;
|
||||
for(let i=0;i<a.length;i+=4){nonblack+=a[i]+a[i+1]+a[i+2]>0;for(let j=0;j<3;j++)max=Math.max(max,Math.abs(a[i+j]-b[i+j]));}
|
||||
return {max,nonblack};
|
||||
}""")
|
||||
assert parity['max'] <= 8 and parity['nonblack'] > 1000, parity
|
||||
print('PASS world replacement and zoom reallocate textures and draw the new world', parity)
|
||||
pg.evaluate('SET.look="smooth"; drawWorld();')
|
||||
assert pg.evaluate('R.gpuOn && R.smoothOn && !GPU.canvas.hidden')
|
||||
print('PASS smooth uses WebGPU')
|
||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-renderer.js').read_text() + '}')
|
||||
pg.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
||||
# Compare all four smooth inputs, including valid zero tags and class-zero overrides.
|
||||
smooth_inputs = pg.evaluate("""async () => {
|
||||
// Every material also gets a plain, unowned cell (including part materials).
|
||||
for(let m=0;m<NMAT;m++) {
|
||||
const x=CAM[0]-16+(m%11)*3,y=CAM[1]-40+Math.floor(m/11)*2,i=y*W+x;
|
||||
setCell(i,m,20,0);part[i]=mach[i]=occ[i]=bgm[i]=0;blockP[(y>>2)*BW+(x>>2)]=0;
|
||||
}
|
||||
const results=[];
|
||||
const now=performance.now.bind(performance); performance.now=()=>5000; SM.t0=0;
|
||||
try {
|
||||
for(const zoom of [12,6,2])for(const fogOn of [false,true]) {
|
||||
SET.zoom=zoom;SET.fog=fogOn;SET.look='smooth';R.lens=false;SET.lighting='off';SET.glow='off';
|
||||
resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();R.frame=999;
|
||||
SET.renderer='legacy';SM.scale=1;drawWorld();
|
||||
const expected=[R.px.slice(),R.backPx.slice(),R.treePx.slice(),R.fieldPx.slice()];
|
||||
SET.renderer='webgpu';drawWorld();
|
||||
const actual=await testSmoothInputs(); let alpha=0,max=0,bad=0,examples=[];
|
||||
for(let k=0;k<R.px.length;k++)for(let t=0;t<4;t++) {
|
||||
const a=expected[t][k],b=actual[k*4+t];if((a>>>24)!==(b>>>24))alpha++;
|
||||
for(let c=0;c<3;c++){const diff=Math.abs((a>>>(c*8)&255)-(b>>>(c*8)&255));max=Math.max(max,diff);if(diff>1){bad++;if(examples.length<3)examples.push({k,t,a,b});}}
|
||||
for(let n=0;n<240;n++) {
|
||||
simTick();
|
||||
if(n%20===0) {
|
||||
fogLook(bot.x,bot.y); R.camX=(n*7)%400; R.camY=(n*3)%280;
|
||||
SET.look=n%40?'smooth':'pixel';R.frame++;drawWorld();
|
||||
results.push(await gpuCheckUploads());
|
||||
}
|
||||
results.push({zoom,fogOn,alpha,max,bad,examples,share:GPU.overrideCount/R.px.length});
|
||||
}
|
||||
return results;
|
||||
} finally { performance.now=now; }
|
||||
drawWorld();await gpuCheckUploads();
|
||||
drawWorld();return {checked:results,cleanCells:GPU.uploadCells};
|
||||
}''')
|
||||
assert min(result['checked'])>1000 and result['cleanCells']==0,result
|
||||
print('PASS busy ticks, heat, fog, scrolling, both looks: GPU bytes equal full CPU data; clean frame uploads no world cells',result)
|
||||
parity=pg.evaluate("""async()=>{
|
||||
const results=[];
|
||||
const now=performance.now.bind(performance),frozen=now();performance.now=()=>frozen;
|
||||
try {
|
||||
for(const look of ['pixel','smooth'])for(const light of ['full','fast','off'])for(const lens of [false,true])for(const fogOn of [false,true]) {
|
||||
SET.look=look;SET.lighting=light;R.lens=lens;SET.fog=fogOn;R.frame++;
|
||||
R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||
const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
GPU.resident.fill(0);R.rain=0;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||
const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0;for(let i=0;i<a.length;i++)max=Math.max(max,Math.abs(a[i]-b[i]));
|
||||
results.push({look,light,lens,fogOn,max});
|
||||
}
|
||||
R.lens=false;SET.lighting='full';return results;
|
||||
} finally {performance.now=now;}
|
||||
}""")
|
||||
for r in smooth_inputs:
|
||||
assert r['alpha'] == 0 and r['bad'] == 0, r
|
||||
print('PASS all four smooth inputs match legacy: classes and flags exact, RGB within 1', smooth_inputs)
|
||||
pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();')
|
||||
# This fixture stops requestAnimationFrame; poll asynchronous GPU events by timer.
|
||||
pg.wait_for_function('!GPU.timingBusy', polling=10)
|
||||
assert pg.evaluate('!GPU.queries || GPU.gpuMs>=0')
|
||||
assert pg.evaluate('perfMarks.override>=0 && perfMarks.upload>=0')
|
||||
print('PASS separate override/upload clocks and optional GPU timestamps')
|
||||
pg.evaluate('() => {' + (root / 'tools/scenes/t88-light.js').read_text() + '}')
|
||||
# Draw GPU first with no CPU grid and forbid accidental CPU lighting calls.
|
||||
pg.evaluate("""() => {
|
||||
window.savedComputeLight=computeLight;
|
||||
computeLight=()=>{throw Error('CPU light called in WebGPU');};
|
||||
R.lr=R.lg=R.lb=R.latt=R.lightPx=null; R.lw=R.lh=0;
|
||||
SET.renderer='webgpu';SET.look='pixel';SET.zoom=6;SET.lighting='full';SET.lightEvery=1;
|
||||
resizeRender();bot.x=CAM[0]-bot.w/2;bot.y=CAM[1]-bot.h/2;snapCamera();drawWorld();
|
||||
computeLight=savedComputeLight;
|
||||
}""")
|
||||
for mode in ['full', 'fast']:
|
||||
for case in ['lamp', 'opening', 'magma', 'fire']:
|
||||
parity = pg.evaluate("""([mode,kind]) => {
|
||||
lightScene(kind);SET.lighting=mode;SET.lightEvery=1;R.frame=1000;R.rain=0;
|
||||
SET.renderer='legacy';drawWorld();const a=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
R.rain=0;SET.renderer='webgpu';drawWorld();const b=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let max=0,bad=0,sum=0;
|
||||
for(let i=0;i<a.length;i+=4){let d=0;for(let j=0;j<3;j++){const e=Math.abs(a[i+j]-b[i+j]);sum+=e;d=Math.max(d,e);}max=Math.max(max,d);bad+=d>8;}
|
||||
return {mode,kind,max,bad,mean:sum/(960*600*3)};
|
||||
}""", [mode,case])
|
||||
assert parity['bad'] == 0, parity
|
||||
print('PASS light scene', parity)
|
||||
cadence = pg.evaluate("""() => {
|
||||
assert all(r['max']==0 for r in parity),parity
|
||||
print('PASS dirty and forced full uploads render identically in',len(parity),'look/light/lens/fog configurations')
|
||||
cadence=pg.evaluate("""()=>{
|
||||
SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0;
|
||||
bot.lamp=!bot.lamp;R.camX+=4;R.frame=1003;drawWorld();
|
||||
const held=GPU.lightFrame===old && R.lx0===origin;
|
||||
R.camX+=4;R.frame=1003;drawWorld();const held=GPU.lightFrame===old && R.lx0===origin;
|
||||
R.frame=1005;drawWorld();const updated=GPU.lightFrame===1005 && R.lx0!==origin;
|
||||
SET.lightEvery=1;return {held,updated};
|
||||
}""")
|
||||
assert all(cadence.values()), cadence
|
||||
print('PASS GPU-only lighting, both resolutions and lightEvery cadence')
|
||||
switches = pg.evaluate("""() => {
|
||||
const original=computeLight,frames=[];
|
||||
computeLight=()=>{throw Error('CPU light called in WebGPU smooth');};
|
||||
try {
|
||||
SET.lightEvery=100;
|
||||
for(const look of ['smooth','pixel','smooth']) {
|
||||
SET.look=look;R.frame++;drawWorld();
|
||||
frames.push(R.gpuOn && GPU.lightFrame===R.frame && R.smoothOn===(look==='smooth'));
|
||||
}
|
||||
return frames;
|
||||
} finally {computeLight=original;SET.lightEvery=1;}
|
||||
assert all(cadence.values()),cadence
|
||||
print('PASS lighting cadence holds and then updates its camera origin')
|
||||
# Force paths that can mutate sleeping chunks, away from the busy factory.
|
||||
pg.evaluate('''() => {
|
||||
const i=500*W+800; setCell(i,STONE,20,F_NAT);digp[i]=120;renderDirtyIdx(i);
|
||||
setTreeCell(801,450,WOOD);fogRevealAll();
|
||||
R.camX=650;R.camY=400;drawWorld();
|
||||
}''')
|
||||
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||
pg.evaluate('generateWorld(777,640,384,null);buildSky();resizeRender();snapCamera();drawWorld();')
|
||||
assert pg.evaluate('GPU.world===mat && GPU.ok')
|
||||
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||
print('PASS world replacement refreshes resident chunks and static data')
|
||||
assert pg.evaluate('R.ctx.getImageData(10,10,1,1).data[3]===0 && worldPixelContext().getImageData(10,10,1,1).data[3]===255')
|
||||
print('PASS GPU world stays opaque under transparent overlays')
|
||||
cracks=pg.evaluate("""async()=>{
|
||||
SET.zoom=12;SET.look='pixel';SET.fog=false;SET.lighting='off';SET.glow='off';SET.grain=0;
|
||||
resizeRender();R.camX=200;R.camY=160;
|
||||
for(let y=170;y<192;y++)for(let x=215;x<260;x++)setCell(y*W+x,STONE,20,F_NAT);
|
||||
drawWorld();const clean=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
for(let y=170;y<192;y++)for(let x=215;x<260;x++){digp[y*W+x]=240;renderDirtyXY(x,y);}
|
||||
R.frame=1000;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
|
||||
const damaged=worldPixelContext().getImageData(0,0,960,600).data;
|
||||
let changed=0;for(let i=0;i<clean.length;i+=4)changed+=clean[i]!==damaged[i];
|
||||
return changed;
|
||||
}""")
|
||||
assert all(switches), switches
|
||||
print('PASS switching either look refreshes new light textures without CPU lighting')
|
||||
assert pg.evaluate('!GPU.queries || GPU.lightMs>=0')
|
||||
pg.evaluate('GPU.device.destroy()')
|
||||
pg.wait_for_function('!GPU.ok', polling=10)
|
||||
assert pg.evaluate('SET.renderer==="legacy"')
|
||||
assert cracks>1000,cracks
|
||||
pg.screenshot(path=str(root/'shots/t88-drill-pixel.png'))
|
||||
pg.evaluate("SET.look='smooth';drawWorld()")
|
||||
pg.screenshot(path=str(root/'shots/t88-drill-smooth.png'))
|
||||
print('PASS stationary drill damage changes GPU colour in both looks',cracks)
|
||||
timing=pg.evaluate('''async () => {
|
||||
const originalC=GPU.device.createComputePipeline,originalR=GPU.device.createRenderPipeline;
|
||||
GPU.device.createComputePipeline=GPU.device.createRenderPipeline=()=>{throw Error('synchronous pipeline compilation');};
|
||||
const samples=[];
|
||||
try {
|
||||
for(const [look,zoom] of [['pixel',12],['smooth',12],['smooth',2],['pixel',2],['smooth',6]]) {
|
||||
SET.look=look;SET.zoom=zoom;resizeRender();snapCamera();const start=performance.now();drawWorld();
|
||||
await GPU.device.queue.onSubmittedWorkDone();samples.push({look,zoom,first:performance.now()-start});
|
||||
const times=[];for(let n=0;n<8;n++){const t=performance.now();drawWorld();await GPU.device.queue.onSubmittedWorkDone();times.push(performance.now()-t);}
|
||||
samples.at(-1).normal=Math.max(...times);
|
||||
}
|
||||
} finally {GPU.device.createComputePipeline=originalC;GPU.device.createRenderPipeline=originalR;}
|
||||
return {init:GPU.initMs,samples};
|
||||
}''')
|
||||
print('PASS look and zoom switches create no pipelines; timings ms',timing)
|
||||
# Successful recovery must recreate resources and preserve simulation arrays.
|
||||
pg.evaluate('window.savedMat=mat; GPU.device.destroy();')
|
||||
pg.wait_for_function('GPU.ok && !GPU.recovering',polling=20,timeout=60000)
|
||||
assert pg.evaluate('mat===savedMat')
|
||||
pg.evaluate('drawWorld()')
|
||||
print('PASS device loss restores the legacy renderer')
|
||||
assert not errors, errors
|
||||
assert pg.evaluate('gpuCheckUploads()')>1000
|
||||
print('PASS device loss recreates the device once without changing the world')
|
||||
pg.evaluate("Object.defineProperty(navigator,'gpu',{value:{requestAdapter:async()=>null}});GPU.device.destroy();")
|
||||
pg.wait_for_function('GPU.stopped',polling=20,timeout=60000)
|
||||
assert pg.evaluate('GAME.paused') and 'Reload' in pg.inner_text('#m-dialog')
|
||||
assert pg.evaluate("DB.get(GAME.worldId+':auto').then(s=>!!s)")
|
||||
print('PASS failed recovery stops frames, autosaves and offers Reload')
|
||||
assert not errors,errors
|
||||
print('PASS no page or WebGPU validation errors')
|
||||
|
||||
Reference in New Issue
Block a user