Smooth look on WebGPU: WGSL port fed by GPU inputs and CPU overrides (t88 phase 2c)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
// Read the four actual smooth textures without adding COPY_SRC usage to game resources.
|
||||
async function testSmoothInputs() {
|
||||
const d=GPU.device, code=`
|
||||
@group(0) @binding(0) var col: texture_2d<f32>;
|
||||
@group(0) @binding(1) var back: texture_2d<f32>;
|
||||
@group(0) @binding(2) var tree: texture_2d<f32>;
|
||||
@group(0) @binding(3) var field: texture_2d<f32>;
|
||||
@group(0) @binding(4) var<storage,read_write> out: array<u32>;
|
||||
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3u) {
|
||||
let size=textureDimensions(col); let k=id.x; if(k>=size.x*size.y){return;}
|
||||
let p=vec2i(i32(k%size.x),i32(k/size.x));
|
||||
out[k*4]=pack4x8unorm(textureLoad(col,p,0));
|
||||
out[k*4+1]=pack4x8unorm(textureLoad(back,p,0));
|
||||
out[k*4+2]=pack4x8unorm(textureLoad(tree,p,0));
|
||||
out[k*4+3]=pack4x8unorm(textureLoad(field,p,0));
|
||||
}`;
|
||||
const pipe=d.createComputePipeline({layout:'auto',compute:{module:d.createShaderModule({code}),entryPoint:'main'}});
|
||||
const bytes=R.px.length*16;
|
||||
const storage=d.createBuffer({size:bytes,usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC});
|
||||
const read=d.createBuffer({size:bytes,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
|
||||
const entries=[GPU.colourTex,GPU.backTex,GPU.treeTex,GPU.fieldTex].map((t,binding)=>({binding,resource:t.createView()}));
|
||||
entries.push({binding:4,resource:{buffer:storage}});
|
||||
const bind=d.createBindGroup({layout:pipe.getBindGroupLayout(0),entries});
|
||||
const enc=d.createCommandEncoder(),pass=enc.beginComputePass();pass.setPipeline(pipe);pass.setBindGroup(0,bind);pass.dispatchWorkgroups(Math.ceil(R.px.length/64));pass.end();
|
||||
enc.copyBufferToBuffer(storage,0,read,0,bytes);d.queue.submit([enc.finish()]);
|
||||
await read.mapAsync(GPUMapMode.READ);
|
||||
const actual=new Uint32Array(read.getMappedRange()).slice();
|
||||
read.unmap();storage.destroy();read.destroy();
|
||||
return actual;
|
||||
}
|
||||
+8
-2
@@ -1328,8 +1328,14 @@ with launch(viewport={'width': 1280, 'height': 800}) as (b, pg, url):
|
||||
check('fog toggle shows the same rock', fp.evaluate(sample) != 0)
|
||||
fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000")
|
||||
fp.wait_for_timeout(500)
|
||||
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && SM.ok'))
|
||||
check('unseen smooth grains have no terrain shape', fp.evaluate("(() => {const k=(235-R.vy0)*R.bw+300-R.vx0;return R.px[k]===0x04000000 && R.backPx[k]===0 && R.treePx[k]===0 && R.fieldPx[k]===255})()"))
|
||||
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && (R.gpuOn ? GPU.ok : SM.ok)'))
|
||||
fp.add_script_tag(path=str(root / 'test/gpu-readback.js'))
|
||||
check('unseen smooth grains have no terrain shape', fp.evaluate("""async () => {
|
||||
const k=(235-R.vy0)*R.bw+300-R.vx0;
|
||||
const inputs=R.gpuOn ? Array.from((await testSmoothInputs()).slice(k*4,k*4+4)) :
|
||||
[R.px[k],R.backPx[k],R.treePx[k],R.fieldPx[k]];
|
||||
return inputs[0]===0x04000000 && inputs[1]===0 && inputs[2]===0 && inputs[3]===255;
|
||||
}"""))
|
||||
fp.screenshot(path=str(out / 'fog-smooth.png'))
|
||||
fog_context.close()
|
||||
|
||||
|
||||
+50
-4
@@ -65,10 +65,42 @@ with launch(viewport={'width': 960, 'height': 600}) as (browser, pg, url):
|
||||
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')
|
||||
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();')
|
||||
pg.wait_for_function('!GPU.timingBusy')
|
||||
# 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')
|
||||
@@ -103,9 +135,23 @@ with launch(viewport={'width': 960, 'height': 600}) as (browser, pg, url):
|
||||
}""")
|
||||
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')
|
||||
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')
|
||||
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
"""The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works."""
|
||||
"""The smooth look: the WebGPU pass compiles, draws, falls back cleanly, and the thermal lens still works."""
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
|
||||
from use_venv import ensure_playwright; ensure_playwright()
|
||||
@@ -15,11 +15,11 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
|
||||
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None)
|
||||
pg.add_init_script('localStorage.clear();')
|
||||
pg.goto(url); pg.evaluate('SET.fog = false'); pg.wait_for_timeout(300)
|
||||
pg.goto(url); pg.evaluate('CAPS_CHECK'); pg.evaluate('SET.fog = false'); pg.wait_for_timeout(300)
|
||||
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
|
||||
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
|
||||
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]'))
|
||||
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && GPU.ok && R.gpuOn && 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 = 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)
|
||||
@@ -32,8 +32,10 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
check('the pixel look can be chosen', ev('!R.smoothOn'))
|
||||
pg.screenshot(path=str(out / 'pixel.png'))
|
||||
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
|
||||
ev("SM.scale = 0.5"); pg.wait_for_timeout(1500)
|
||||
ev("SET.renderer = 'legacy'; SM.scale = 0.5"); pg.wait_for_timeout(1500)
|
||||
st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st)
|
||||
ev("SET.renderer = 'webgpu'"); pg.wait_for_timeout(300)
|
||||
check('smooth returns to WebGPU after legacy comparison', ev('R.gpuOn && R.smoothOn'))
|
||||
# Uniform colours isolate the solid/loose seam from material noise and lighting.
|
||||
ground = ev('''() => {
|
||||
GAME.paused = true; requestAnimationFrame = () => 0;
|
||||
@@ -290,7 +292,7 @@ with launch(viewport={'width': 1024, 'height': 640}) as (b, pg, url):
|
||||
# a browser without hardware drawing gets the pixel look and a note
|
||||
with launch(gpu='soft', viewport={'width': 800, 'height': 500}) as (_, pg2, soft_url):
|
||||
pg2.add_init_script('window.__allowSoftwareGL = false; localStorage.clear();')
|
||||
pg2.goto(soft_url); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300)
|
||||
pg2.goto(soft_url); pg2.evaluate("SET.fog = false; SET.renderer='legacy'"); pg2.wait_for_timeout(300)
|
||||
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
|
||||
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
|
||||
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
|
||||
|
||||
Reference in New Issue
Block a user