Files
deepgrain-foundry/test/t_gpu.py
T

82 lines
5.2 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 keeps the legacy path')
pg.evaluate('SET.look="pixel"; SET.renderer="webgpu"; PERF_MARKS=true; drawWorld();')
pg.wait_for_function('!GPU.timingBusy')
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('GPU.device.destroy()')
pg.wait_for_function('!GPU.ok')
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')