Fix blocky WebGPU sky: view inputs refresh when the camera moves (t91)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 17:46:52 -04:00
co-authored by Claude Opus 5.5
parent fbc3b07be2
commit f7ec8af791
12 changed files with 108 additions and 9 deletions
+11 -7
View File
@@ -79,17 +79,21 @@ checks waking within eight ticks separately from the probabilistic consumption o
`shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth `shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth
look. look.
- `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens. - `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens.
- `test/t_gpu_golden.py` compares 28 cropped legacy reference PNGs (384x288, under - `test/t_gpu_golden.py` compares 28 cropped frozen reference PNGs (384x288, under
`test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes, `test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes,
both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8, both looks at zoom 12 and 2. ImageMagick decodes RGB; the per-channel tolerance is 8,
mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8 mean RGB error must stay below 0.5/255 and fewer than 0.3% of pixels may exceed 8
(smooth AO/edge rounding). These are frozen pre-removal legacy images, not generated (smooth AO/edge rounding). Seven smooth crops were refreshed for t91 (renderer 12/2,
from the new renderer. The compressed `golden-world-640x*.json.gz` fixtures retain the joins 2, sky 12/2, lamp 12, magma 12): the old noise had seams in the references too.
Phase 2 world bytes that produced them, isolating renderer checks from worldgen RNG changes. Inspect the matching `shots/golden-*.png` after visible changes. The remaining crops are frozen pre-removal legacy images. The compressed
`golden-world-640x*.json.gz` fixtures retain the Phase 2 world bytes that produced them,
isolating renderer checks from worldgen RNG changes. Inspect the matching
`shots/golden-*.png` after visible changes.
- `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload - `test/t_gpu.py` checks default limits (four storage textures per stage), dirty-upload
readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality readback after 240 busy ticks with scrolling and both looks, exact dirty/full image equality,
with a frozen clock, world replacement, transparent overlays, drill contours, shader compilation timing, successful device recreation and failed recovery with noise continuity and pan/zoom equality against fresh view textures/full uploads with a
an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and frozen clock, world replacement, transparent overlays, drill contours, shader compilation
timing, successful device recreation and failed recovery with an autosave and Reload. `gpuCheckUploads()` reads actual resident GPU texture bytes and
compares them to the full CPU arrays, including Float32 temperatures, machine and block data. compares them to the full CPU arrays, including Float32 temperatures, machine and block data.
- `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees. - `test/t_render_dirty.js` checks invalidation of sleeping chunks, heat edges, swaps, fog and trees.
- The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the - The old CPU light checks now run as `test/t_gpu_light.py` / `test/light-cases.js`, reading the
+39
View File
@@ -4,6 +4,45 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-28: t91 sky renders as mismatched blocks (Codex)
- Reproduced the owner's cloud rectangles and stepped distant hills on NVIDIA Blackwell:
generated seed 424242 at 2048x1024, smooth look, 1320x916 viewport, 30 submitted frames
panning (400,20) to (565.3,32.47) while zooming 4 to 1.68. Inspected
`shots/t91-before.png`, `t91-after.png`, `t91-pixel.png` and the seven-pair
`t91-golden-review.png`. Clouds and hills are continuous after the fix; pixel sky intact.
- Cause: `vn` in `gpu/smooth-shader.js` hashed shared lattice corners differently depending
on whether they came from the current cell or its offset neighbour. Isolated GPU readback
measured a 255/255 jump across an infinitesimal cell boundary. Rounding the offset corner
sums before `h21` reduces the maximum to 1/255. This implicates fused/reassociated hash
arithmetic in the shader port, not stale camera inputs. The previously frozen sky/joins
references also contained these seams, so their presence does not establish a 2d regression.
Floating-point fusion/reassociation is permitted by
[WGSL](https://www.w3.org/TR/WGSL/#floating-point-evaluation) and
[Vulkan](https://docs.vulkan.org/spec/latest/appendices/spirvenv.html#spirvenv-precision-operation).
No backend disassembly was captured; the precise optimizer transformation is inferred.
- Kept dirty world uploads unchanged: a forced full upload of the broken frame was exactly
identical. Audited view-dependent inputs: main camera/view/zoom uniform, smooth time/surface
uniform, overrides and row/column tables upload each draw; colour dispatch covers the full
view. Pixel colour shares this path. Lighting refreshes its own uniform/grid on its selected
cadence and retains the matching origin between updates. Existing cadence check still passes.
- Added two checks to `test/t_gpu.py`: actual smooth noise helpers rendered to an offscreen
texture and read across horizontal/vertical lattice boundaries (failed before, passes after);
12-frame pan/zoom paths compared to recreated view textures/light history plus full world
uploads, pixel/smooth with full/fast/off lighting (all six comparisons exactly equal).
Seven smooth golden crops intentionally refreshed after inspection: renderer 12/2, joins 2,
sky 12/2, lamp 12 and magma 12. Shared noise also shades surfaces, so those textures change
slightly. All pixel and remaining smooth references retained; tolerances unchanged.
- Files: `src/render/gpu/smooth-shader.js`, `test/t_gpu.py`, `test/t_gpu_golden.py`, seven
`test/fixtures/golden/*-smooth-*.png`, `docs/TESTING.md`, this log and t91 tracking.
No simulation changes, DESIGN/other task edits or commit.
- Verification: focused GPU suite passes (14 checks, no page/WebGPU validation errors).
Full `python3 test/run_all.py --browser` exited 0, ALL PASSED: all 32 headless suites;
gameplay 361, smooth 28, console 18, capabilities 7, GPU 14, goldens 28, light 71 and
Firefox 2 checks. Evidence: `/tmp/t91-all-tests.log`. Diff check and kandown doctor passed;
t91 moved to Review. Sandbox had no WebGPU adapter; host-GPU execution used for reproduction
and verification. Other GPUs and owner playtesting remain unverified.
## 2026-10-28: t88 phase 4 hardware verification and handoff (Codex) ## 2026-10-28: t88 phase 4 hardware verification and handoff (Codex)
- Reviewed the existing uncommitted Phase 4 changes rather than duplicating the implementation. - Reviewed the existing uncommitted Phase 4 changes rather than duplicating the implementation.
+2 -1
View File
@@ -15,7 +15,8 @@ fn modulo(x: f32,y: f32) -> f32 { return x-y*floor(x/y); }
fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } fn h21(inputP: vec2f) -> f32 { var p=inputP; p = fract(p * vec2f(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
fn vn(p: vec2f) -> f32 { fn vn(p: vec2f) -> f32 {
var i: vec2f = floor(p); var f: vec2f = fract(p); f = f * f * (3.0 - 2.0 * f); var i: vec2f = floor(p); var f: vec2f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(h21(i), h21(i + vec2f(1, 0)), f.x), mix(h21(i + vec2f(0, 1)), h21(i + vec2f(1, 1)), f.x), f.y); // Round corner sums before hashing so fused arithmetic cannot give shared corners different hashes.
return mix(mix(h21(i), h21(floor(i + vec2f(1, 0))), f.x), mix(h21(floor(i + vec2f(0, 1))), h21(floor(i + vec2f(1, 1))), f.x), f.y);
} }
fn fbm(inputP: vec2f) -> f32 { var p=inputP; var a: f32 = 0.5; var s: f32 = 0.0; for (var k: i32 = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; } fn fbm(inputP: vec2f) -> f32 { var p=inputP; var a: f32 = 0.5; var s: f32 = 0.0; for (var k: i32 = 0; k < 4; k++) { s += a * vn(p); p = p * 2.03 + 17.1; a *= 0.5; } return s / 0.9375; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 115 KiB

+55
View File
@@ -57,6 +57,61 @@ with launch(viewport={'width':960,'height':600}) as (_,pg,url):
}""") }""")
assert all(r['max']==0 for r in parity),parity 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') print('PASS dirty and forced full uploads render identically in',len(parity),'look/light/lens/fog configurations')
# Adjacent noise cells must use the same hash for their shared lattice corners.
noise=pg.evaluate("""async()=>{
const code=GPU_SMOOTH.slice(GPU_SMOOTH.indexOf('fn h21'),GPU_SMOOTH.indexOf('fn lin'))+`
@vertex fn vs(@builtin(vertex_index) i:u32)->@builtin(position) vec4f {
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));return vec4f(p[i],0,1);
}
@fragment fn fs(@builtin(position) p:vec4f)->@location(0) vec4f {
let edge=floor(p.x/2.);let along=floor(p.y)*0.1;
let epsilon=select(-0.0001,0.0001,u32(p.x)%2u==1u);
return vec4f(vn(vec2f(edge+epsilon,along)),vn(vec2f(along,edge+epsilon)),0,1);
}`;
const module=GPU.device.createShaderModule({code});
const pipeline=await GPU.device.createRenderPipelineAsync({layout:'auto',
vertex:{module,entryPoint:'vs'},fragment:{module,entryPoint:'fs',targets:[{format:'rgba8unorm'}]}});
const texture=GPU.device.createTexture({size:[960,100],format:'rgba8unorm',
usage:GPUTextureUsage.RENDER_ATTACHMENT|GPUTextureUsage.COPY_SRC});
const buffer=GPU.device.createBuffer({size:960*100*4,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
const encoder=GPU.device.createCommandEncoder();
const pass=encoder.beginRenderPass({colorAttachments:[{
view:texture.createView(),loadOp:'clear',storeOp:'store'}]});
pass.setPipeline(pipeline);pass.draw(3);pass.end();
encoder.copyTextureToBuffer({texture},{buffer,bytesPerRow:960*4},[960,100]);
GPU.device.queue.submit([encoder.finish()]);await buffer.mapAsync(GPUMapMode.READ);
const pixels=new Uint8Array(buffer.getMappedRange());let max=0;
for(let y=0;y<100;y++)for(let x=0;x<960;x+=2)for(let c=0;c<2;c++) {
const i=(y*960+x)*4+c;max=Math.max(max,Math.abs(pixels[i]-pixels[i+4]));
}
buffer.unmap();buffer.destroy();texture.destroy();return max;
}""")
assert noise<=1,noise
print('PASS smooth noise is continuous across horizontal and vertical lattice boundaries',noise)
motion=pg.evaluate("""async()=>{
const results=[],now=performance.now.bind(performance);performance.now=()=>5000;
try {
SET.fog=false;SET.glow='low';SET.lightEvery=1;R.lens=false;
for(const look of ['pixel','smooth'])for(const light of ['full','fast','off']) {
SET.look=look;SET.lighting=light;
for(let n=0;n<12;n++) {
SET.zoom=6-n*0.37;resizeRender();R.camX=80.25+n*7.3;R.camY=5.5+n*0.43;
R.rain=0;R.frame++;drawWorld();await GPU.device.queue.onSubmittedWorkDone();
}
const a=worldPixelContext().getImageData(0,0,960,600).data;
const cleanCells=GPU.uploadCells;
// Discard all view textures/light history as well as the resident-world cache.
GPU.resident.fill(0);GPU.viewKey=null;GPU.lightFrame=undefined;
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,max,cleanCells});
}
SET.lighting='full';return results;
} finally {performance.now=now;}
}""")
assert all(r['max']==0 for r in motion),motion
print('PASS pan/zoom matches fresh full redraw in both looks and all light modes',motion)
cadence=pg.evaluate("""()=>{ cadence=pg.evaluate("""()=>{
SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0; SET.lightEvery=3;R.frame=1002;drawWorld();const old=GPU.lightFrame,origin=R.lx0;
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;
+1 -1
View File
@@ -1,4 +1,4 @@
"""Frozen legacy references, captured before retirement; compare the WebGPU world only.""" """Frozen world references; seven smooth crops corrected for continuous noise in t91."""
import base64, gzip, json, pathlib, sys, subprocess import base64, gzip, json, pathlib, sys, subprocess
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 use_venv import ensure_playwright; ensure_playwright()