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
+3 -3
View File
@@ -1308,10 +1308,10 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
snapCamera(); GAME.hidePreviews = true; $('cards').replaceChildren();
}""")
fp.wait_for_timeout(300)
sample = "(() => { drawWorld(); const k = (235 - R.vy0) * R.bw + 300 - R.vx0; return R.px[k] & 0xffffff; })()"
sample = "(() => { drawWorld(); const p=worldToScreenPx(300.5,235.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; })()"
check('unexplored pixel is black', fp.evaluate(sample) == 0)
check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const c=R.px[20*R.bw+20]&0xffffff; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c!==0x0a0a0c; })()"))
check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(R.ctx.getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()"))
check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const d=worldPixelContext().getImageData(20*R.zp,20*R.zp,1,1).data,c=d[0]|d[1]<<8|d[2]<<16; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c>0 && c!==0x0a0a0c; })()"))
check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(worldPixelContext().getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()"))
check('hover and cursor share only Unexplored', fp.evaluate("JSON.stringify(hoverInfo(300,235)) === JSON.stringify({t1:'Unexplored',t2:[],t3:[]})"))
fp.evaluate("GAME.aim = {x:300,y:235}; renderInfo()")
check('info box says only Unexplored', fp.locator('#info').inner_text() == 'Unexplored')
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 'firefox_smoke.py']:
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 'firefox_smoke.py']:
results.append(run_py(name))
bad = 0
for name, code, out, secs in results:
+81
View File
@@ -0,0 +1,81 @@
"""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')
+10 -9
View File
@@ -21,7 +21,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
ev = pg.evaluate
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && SM.ok && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]'))
# the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black
px = ev("""(() => { const c = R.ctx.getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = R.ctx.getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
px = ev("""(() => { const c = worldPixelContext().getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = worldPixelContext().getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px)
pg.screenshot(path=str(out / 'smooth.png'))
ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500)
@@ -43,8 +43,9 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const i = y * W + x;
setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128;
}
GPU.world = null; // The fixture changed static noise in place.
const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); };
const at = (x, y) => { const c = R.px[(y - R.vy0) * R.bw + x - R.vx0]; return (c & 255) + (c >>> 8 & 255) + (c >>> 16 & 255); };
const at = (x, y) => { const p=worldToScreenPx(x+0.5,y+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]+c[1]+c[2]; };
const out = {};
for (const lens of [false, true]) {
R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld();
@@ -59,7 +60,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld();
const sample = (x, y) => {
const p = worldToScreenPx(x, y);
const c = R.ctx.getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data;
const c = worldPixelContext().getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data;
return c[0] + c[1] + c[2];
};
out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)];
@@ -140,8 +141,8 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const x=cell%W,y=(cell/W)|0,control=80*W+180;
bot.x=118;bot.y=78;snapCamera();
setCell(cell,SAND,22,0);setCell(control,SAND,22,0);
noiseB[cell]=noiseB[control]=128;
const at=i=>R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0];
noiseB[cell]=noiseB[control]=128; GPU.world=null;
const at=i=>{ if(R.smoothOn) return R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; const p=worldToScreenPx(i%W+0.5,((i/W)|0)+0.5), c=worldPixelContext().getImageData(p.x,p.y,1,1).data; return c[0]|c[1]<<8|c[2]<<16; };
SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff];
SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24;
return {colors,grainClass};
@@ -162,7 +163,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
for(const look of ["pixel","smooth"]) {
SET.look=look;SM.scale=1;drawWorld();
const bad=[], copper=c=>c[0]>c[1]*1.15 && c[1]>c[2]*1.1;
const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
const sample=(x,y)=>{const p=worldToScreenPx(x,y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const j of JOIN_CASES.filter(j=>j.code===BP_SCREW)) {
const slope=j.v===4 || j.v===6?-1:1, side=j.end?1:-1;
const joins=diagonalScrewJoins(j.bx,j.by,j.v);
@@ -211,7 +212,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const b=j.by*BW+j.bx,nb=j.ny*BW+j.nx;
const plan=new Map([[b,[BP_SCREW,j.v]],[nb,[BP_SCREW,j.straight]]]);
removeBlock(j.bx,j.by);removeBlock(j.nx,j.ny);
const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');};
const sample=(x,y)=>{const p=worldToScreenPx(j.nx*4+x,j.ny*4+y);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data).join(',');};
SM.scale=1;drawWorld();const bg=sample(ux,uy),empty=sample(2,2);
for(const [n,piece] of plan)ghostBlocks.set(n,piece);
drawWorld();
@@ -236,7 +237,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
try {
const j=JOIN_CASES.find(j=>j.v===5 && j.end===1 && !j.vertical && j.spot===2 && j.code===BP_SCREW);
const bx=j.nx+1,by=j.ny;
const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');};
const sample=()=>{const p=worldToScreenPx(bx*4,by*4);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),4*R.zp,4*R.zp).data).join(',');};
drawWorld();const plain=sample(),bad=[];
for(const m of [WOOD,LEAVES]) {
for(let y=by*4;y<by*4+4;y++)for(let x=bx*4;x<bx*4+4;x++)bgm[y*W+x]=m;
@@ -252,7 +253,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
const j=JOIN_CASES.find(j=>j.v===4&&j.end===0&&j.code===BP_BELT&&j.spot===0);
try {
slopeDetailAt=()=>0;performance.now=()=>frozen;
const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(R.ctx.getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
const sample=()=>{const p=worldToScreenPx(j.bx*BS+0.4,j.by*BS+2.5);return Array.from(worldPixelContext().getImageData(Math.floor(p.x),Math.floor(p.y),1,1).data);};
for(const look of ['pixel','smooth']) {
SET.look=look;SM.scale=1;drawWorld();const joined=sample();
removeBlock(j.nx,j.ny);drawWorld();const free=sample();