160 lines
9.7 KiB
Python
160 lines
9.7 KiB
Python
"""WebGPU pixel parity, rare-cell selection, world replacement, resize and device loss."""
|
|
import pathlib, sys
|
|
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;
|
|
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});}}
|
|
}
|
|
results.push({zoom,fogOn,alpha,max,bad,examples,share:GPU.overrideCount/R.px.length});
|
|
}
|
|
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("""() => {
|
|
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.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(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"')
|
|
pg.evaluate('drawWorld()')
|
|
print('PASS device loss restores the legacy renderer')
|
|
assert not errors, errors
|
|
print('PASS no page or WebGPU validation errors')
|