scene: cone prepass for pass 1, opt-in steps/pixel readout #12

Merged
void merged 6 commits from nibu/scene-trace-prepass into ratatoskr/estate-navigator 2026-11-04 22:56:29 +00:00
Member

Scene pass 1 (terrain.frag.glsl) was a 96-step sphere trace per pixel with nothing to skip empty space. This adds a way to measure it and a cone prepass that cuts it.

What changed

  • createScene({ onTraceStats }): mean trace steps per pass-1 pixel, from an extra R8 step-count target, read back every 8th frame through a pixel-pack buffer and fence, outside the timed span. Unset, neither the output nor the target exists.
  • Pass 0, prepass.frag.glsl: one cone-march ray per 8x8 tile of pass 1. Each step shrinks by the widest any ray of the tile strays from the centre ray, so it is free for the whole tile. It stores the distance no ray of the tile can have hit before (W_FAR when the whole tile climbs above the highest geometry); pass 1 starts there. createScene({ prepass: false }) is the baseline. The distance bound moved to sdf.glsl so both passes use the same function.
  • Harness: drive.cjs trace (steps and ms at fixed poses, PREPASS=0,1), drive.cjs diff (frozen frame, prepass on/off against a 512-step render), bundle.cjs <out> demo (standalone page).

Measured, SwiftShader (software Vulkan, CPU time: before/after of one change only, not a phone frame time), level 0, 32 frames, same scene and poses, prepass off then on. Steps/pixel for "on" includes the prepass's steps spread over the tile's pixels.

viewport pull-back steps/px off → on pass 1 ms off → on prepass ms
960x540 0 (rest) 35.0 → 8.8 180 → 62 5.5
960x540 0.5 60.8 → 40.0 424 → 328 7.1
960x540 1 61.8 → 37.9 468 → 348 7.9
540x1200 0 28.1 → 9.7 182 → 88 8.9
540x1200 0.5 46.8 → 30.7 423 → 324 10.1
540x1200 1 47.1 → 23.2 383 → 253 9.2

Other passes did not move. Steps fall 25-75%; pass 1 time falls less (about 20-65%) because the rays that remain are the expensive ones (near terrain, full erosion field).

The picture changes, on purpose-ish. Pass 1 forces a hit on any ray that exhausts its steps. A ray that now starts later has its full budget left, so far-horizon rays resolve to real hills instead of the forced-hit banding. Measured on the frozen frame (640x360, mean over pixels of the max channel difference, 0-255): prepass vs baseline 0.44 / 2.45 / 1.66 at pull 0 / 0.5 / 1; against a 512-step render of the same frame the baseline is 0.62 / 3.87 / 2.56 away and the prepass 0.36 / 1.78 / 1.19. Two baseline renders of the frame are bit-identical, so these are real differences. Screenshots: diff-off-*.png, diff-on-*.png, diff-ref-*.png from drive.cjs diff.

Soul pass (separate reader, by reading) on the first commit: footprint of a prepass texel versus the pass-1 pixels that sample it, corner-maximum of the stray bound, sky early-out and W_MAX_HEIGHT, GL state of the readback. One defect confirmed and fixed here: the R8 readback used the default PACK_ALIGNMENT of 4, so widths not divisible by 4 read nothing (portrait showed 0 steps/pixel in my first run). One gap fixed: the hit tolerance grows with t and is now kept clear at the far end of a step. Left open by that pass: the cone assumes the distance bound is 1-Lipschitz across the envelope switch in terrainDistance; the login camera is on the plateau so it holds there, a camera inside the erosion band would break it.

Not verified

  • Real GPU. Nothing here ran on one. SwiftShader timings are CPU time; divergence and bandwidth behave differently on a phone, and the prepass's extra pass costs fixed overhead that SwiftShader underprices. The EXT_disjoint_timer_query_webgl2 path is exercised only through SwiftShader's implementation.
  • Look. The change is toward the long-step reference, but nobody has looked at it side by side on a real display. The PNGs are in the diff output, not committed.
  • Unit tests prove wiring (program count, draw order, pack alignment, shared bound), not the cone's conservativeness; that rests on the argument above, the Soul read and the pixel diff.
  • Ladder levels 1-3, still tier, and the low-tier start were not measured.

Standalone page with the prepass switch and live steps/pixel + ms readout: https://minds.gamecult.org/nibu/ratatoskr-scene-prepass-50ffd905/

No merge requested.

🤖 Generated with Claude Code

Scene pass 1 (`terrain.frag.glsl`) was a 96-step sphere trace per pixel with nothing to skip empty space. This adds a way to measure it and a cone prepass that cuts it. **What changed** - `createScene({ onTraceStats })`: mean trace steps per pass-1 pixel, from an extra `R8` step-count target, read back every 8th frame through a pixel-pack buffer and fence, outside the timed span. Unset, neither the output nor the target exists. - Pass 0, `prepass.frag.glsl`: one cone-march ray per 8x8 tile of pass 1. Each step shrinks by the widest any ray of the tile strays from the centre ray, so it is free for the whole tile. It stores the distance no ray of the tile can have hit before (`W_FAR` when the whole tile climbs above the highest geometry); pass 1 starts there. `createScene({ prepass: false })` is the baseline. The distance bound moved to `sdf.glsl` so both passes use the same function. - Harness: `drive.cjs trace` (steps and ms at fixed poses, `PREPASS=0,1`), `drive.cjs diff` (frozen frame, prepass on/off against a 512-step render), `bundle.cjs <out> demo` (standalone page). **Measured, SwiftShader (software Vulkan, CPU time: before/after of one change only, not a phone frame time)**, level 0, 32 frames, same scene and poses, prepass off then on. Steps/pixel for "on" includes the prepass's steps spread over the tile's pixels. | viewport | pull-back | steps/px off → on | pass 1 ms off → on | prepass ms | |---|---|---|---|---| | 960x540 | 0 (rest) | 35.0 → 8.8 | 180 → 62 | 5.5 | | 960x540 | 0.5 | 60.8 → 40.0 | 424 → 328 | 7.1 | | 960x540 | 1 | 61.8 → 37.9 | 468 → 348 | 7.9 | | 540x1200 | 0 | 28.1 → 9.7 | 182 → 88 | 8.9 | | 540x1200 | 0.5 | 46.8 → 30.7 | 423 → 324 | 10.1 | | 540x1200 | 1 | 47.1 → 23.2 | 383 → 253 | 9.2 | Other passes did not move. Steps fall 25-75%; pass 1 time falls less (about 20-65%) because the rays that remain are the expensive ones (near terrain, full erosion field). **The picture changes, on purpose-ish.** Pass 1 forces a hit on any ray that exhausts its steps. A ray that now starts later has its full budget left, so far-horizon rays resolve to real hills instead of the forced-hit banding. Measured on the frozen frame (640x360, mean over pixels of the max channel difference, 0-255): prepass vs baseline 0.44 / 2.45 / 1.66 at pull 0 / 0.5 / 1; against a 512-step render of the same frame the baseline is 0.62 / 3.87 / 2.56 away and the prepass 0.36 / 1.78 / 1.19. Two baseline renders of the frame are bit-identical, so these are real differences. Screenshots: `diff-off-*.png`, `diff-on-*.png`, `diff-ref-*.png` from `drive.cjs diff`. **Soul pass** (separate reader, by reading) on the first commit: footprint of a prepass texel versus the pass-1 pixels that sample it, corner-maximum of the stray bound, sky early-out and `W_MAX_HEIGHT`, GL state of the readback. One defect confirmed and fixed here: the `R8` readback used the default `PACK_ALIGNMENT` of 4, so widths not divisible by 4 read nothing (portrait showed 0 steps/pixel in my first run). One gap fixed: the hit tolerance grows with t and is now kept clear at the far end of a step. Left open by that pass: the cone assumes the distance bound is 1-Lipschitz across the envelope switch in `terrainDistance`; the login camera is on the plateau so it holds there, a camera inside the erosion band would break it. **Not verified** - Real GPU. Nothing here ran on one. SwiftShader timings are CPU time; divergence and bandwidth behave differently on a phone, and the prepass's extra pass costs fixed overhead that SwiftShader underprices. The `EXT_disjoint_timer_query_webgl2` path is exercised only through SwiftShader's implementation. - Look. The change is toward the long-step reference, but nobody has looked at it side by side on a real display. The PNGs are in the diff output, not committed. - Unit tests prove wiring (program count, draw order, pack alignment, shared bound), not the cone's conservativeness; that rests on the argument above, the Soul read and the pixel diff. - Ladder levels 1-3, `still` tier, and the `low`-tier start were not measured. Standalone page with the prepass switch and live steps/pixel + ms readout: https://minds.gamecult.org/nibu/ratatoskr-scene-prepass-50ffd905/ No merge requested. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest
any ray of the tile strays from it, and stores the distance no ray of the tile
can have hit before (W_FAR when the whole tile climbs out above the highest
geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both
passes step by the same function.

onTraceStats adds a step-count render target to pass 1 and reads it back every
8th frame through a pixel-pack buffer and fence, outside the timed span. Unset,
neither the output nor the target exists. prepass:false is the baseline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
trace: steps per pixel and pass ms at three fixed camera poses, PREPASS=0,1 arms.
diff: one frozen frame with and without the prepass, against a 512-step render.
bundle.cjs demo: full-window page with a prepass switch and the readout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Soul pass on 87c9ca60: the R8 steps read used the default PACK_ALIGNMENT of 4, so
at widths not divisible by 4 the buffer was too small (readPixels fails) and padding
would be summed. Portrait runs read 0 steps/pixel. The prepass step now divides by
1.0006 so pass 1's tolerance, which grows with t, stays clear at the end of the step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
50ffd90560
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Member

Prepass vs baseline, per pixel, against the 512-step reference (head 50ffd905, read-only pass, no code changed)

Method: harness/scene/drive.cjs diff, frozen frame (reduced motion, time 0, 32 frames), 640x360, level 0, baseline and prepass both at 96 steps, reference = baseline at 512 steps. Pulls 0, 0.25, 0.5, 0.75, 1. Per pixel worse = max-channel |prepass-ref| - max-channel |baseline-ref|. Baseline rendered twice is bit-identical (0 differing pixels), so the frame is deterministic and the numbers are not noise.

Pixels where the prepass is worse than baseline by more than T/255 (of 230,400):

pull >2 >4 >8 >16 >32 >64 max worse (at x,y) 8-connected blobs >8 / largest
0 148 104 59 17 3 0 47 (352,255) 21 / 15 px
0.25 360 215 27 8 0 0 31 (207,172) 18 / 3 px
0.5 781 469 94 4 0 0 23 (146,68) 32 / 22 px
0.75 1027 597 168 30 0 0 29 (188,119) 39 / 80 px
1 638 343 104 20 1 0 35 (203,128) 31 / 35 px

Pixels where the prepass is better by more than 8: 2009 / 25550 / 18870 / 13563 / 10870 (pulls 0 to 1); by more than 64: 276 / 276 / 311 / 139 / 73. Mean max-channel error vs reference, baseline then prepass: 0.62 -> 0.36, 4.96 -> 2.49, 3.87 -> 1.78, 3.14 -> 1.56, 2.56 -> 1.19.

Bounding boxes of the >8 worse pixels are wide (e.g. pull 0.75 x 175-625, y 19-344) because they are scattered, not one region. Looking at the heatmaps: the residue is thin slivers along ridge/silhouette edges, largest connected blob 80 px, worst single pixel 47/255. No pixel is worse by 64 or more at any pull, and I found no hole or missing hill; where the two disagree with the reference, the prepass is closer to it far more often than not.

Heatmaps (red = prepass worse than baseline, blue = better, grey = reference): /srv/minds-web/nibu/ratatoskr-scene-prepass-50ffd905/shots/regress-heatmap-pull{0,0.25,0.5,0.75,1}.png; source frames diff-{off,on,ref}-<pull>.png in the same folder.

Limits, honestly:

  • SwiftShader (software Vulkan), no GPU. Float behaviour on a phone GPU can differ, so slivers could land elsewhere; this shows the algorithm, not a device.
  • One frozen frame (time 0, azimuth 0.9) per pull, one viewport (640x360), level 0 only. Not a sweep over azimuth or time.
  • The reference is the same renderer at 512 steps, not an analytic ground truth; a ray that gives up at 512 also counts as a hit.
  • The harness only exposes camera pose through fixedPull (a path from near to far). I could not place a camera inside the erosion band without editing code, which I was told not to do, so there is no pose aimed at the band beyond what that path crosses. If Void wants one, it needs a harness option for a fixed camera pose.
  • Pixel metric on a stylised render; it does not measure what a viewer notices.
**Prepass vs baseline, per pixel, against the 512-step reference** (head `50ffd905`, read-only pass, no code changed) Method: `harness/scene/drive.cjs diff`, frozen frame (reduced motion, time 0, 32 frames), 640x360, level 0, baseline and prepass both at 96 steps, reference = baseline at 512 steps. Pulls 0, 0.25, 0.5, 0.75, 1. Per pixel `worse = max-channel |prepass-ref| - max-channel |baseline-ref|`. Baseline rendered twice is bit-identical (0 differing pixels), so the frame is deterministic and the numbers are not noise. Pixels where the prepass is **worse** than baseline by more than T/255 (of 230,400): | pull | >2 | >4 | >8 | >16 | >32 | >64 | max worse (at x,y) | 8-connected blobs >8 / largest | |---|---|---|---|---|---|---|---|---| | 0 | 148 | 104 | 59 | 17 | 3 | 0 | 47 (352,255) | 21 / 15 px | | 0.25 | 360 | 215 | 27 | 8 | 0 | 0 | 31 (207,172) | 18 / 3 px | | 0.5 | 781 | 469 | 94 | 4 | 0 | 0 | 23 (146,68) | 32 / 22 px | | 0.75 | 1027 | 597 | 168 | 30 | 0 | 0 | 29 (188,119) | 39 / 80 px | | 1 | 638 | 343 | 104 | 20 | 1 | 0 | 35 (203,128) | 31 / 35 px | Pixels where the prepass is **better** by more than 8: 2009 / 25550 / 18870 / 13563 / 10870 (pulls 0 to 1); by more than 64: 276 / 276 / 311 / 139 / 73. Mean max-channel error vs reference, baseline then prepass: 0.62 -> 0.36, 4.96 -> 2.49, 3.87 -> 1.78, 3.14 -> 1.56, 2.56 -> 1.19. Bounding boxes of the >8 worse pixels are wide (e.g. pull 0.75 x 175-625, y 19-344) because they are scattered, not one region. Looking at the heatmaps: the residue is thin slivers along ridge/silhouette edges, largest connected blob 80 px, worst single pixel 47/255. No pixel is worse by 64 or more at any pull, and I found no hole or missing hill; where the two disagree with the reference, the prepass is closer to it far more often than not. Heatmaps (red = prepass worse than baseline, blue = better, grey = reference): `/srv/minds-web/nibu/ratatoskr-scene-prepass-50ffd905/shots/regress-heatmap-pull{0,0.25,0.5,0.75,1}.png`; source frames `diff-{off,on,ref}-<pull>.png` in the same folder. Limits, honestly: - SwiftShader (software Vulkan), no GPU. Float behaviour on a phone GPU can differ, so slivers could land elsewhere; this shows the algorithm, not a device. - One frozen frame (time 0, azimuth 0.9) per pull, one viewport (640x360), level 0 only. Not a sweep over azimuth or time. - The reference is the same renderer at 512 steps, not an analytic ground truth; a ray that gives up at 512 also counts as a hit. - The harness only exposes camera pose through `fixedPull` (a path from near to far). I could not place a camera inside the erosion band without editing code, which I was told not to do, so there is no pose aimed at the band beyond what that path crosses. If Void wants one, it needs a harness option for a fixed camera pose. - Pixel metric on a stylised render; it does not measure what a viewer notices.
scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
7e05866437
Phone Chrome without EXT_color_buffer_float returned null with one message. Rendering to
RGBA16F only needs EXT_color_buffer_half_float, and every other target is already a half, so
only the prepass start (RGBA32F) needed a fallback. In half mode the prepass writes
t*(1-2^-10) - 2^-15, which stays below t after the half rounds to nearest, so pass 1 still
starts at or before the true distance. W_FAR stays exact. onUnavailable names no-webgl2 vs
neither float extension; Scene.startFormat and options.halfStart (force, for tests and the
harness). The standalone page shows the reason, renderer, vendor, UA and extension list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Member

7e058664: half-float fallback for the prepass start, and a reason for every null (pushed to this branch, not merged)

Meta's phone Chrome showed createScene returned null: no WebGL2 or float colour buffers. Cause: createScene demanded EXT_color_buffer_float, and only the prepass start target (RGBA32F) needs 32-bit; every other target is already a half, and rendering to those needs only EXT_color_buffer_half_float.

What changed

  • createScene({ onUnavailable }) says why it returned null: no-webgl2, or no-float-or-half-float-colour-buffer (neither extension). With only EXT_color_buffer_half_float the scene now runs, with an RGBA16F start target (scene.startFormat; halfStart: true forces it for tests/measurement).
  • Rounding down: the half-mode prepass writes max(0, t*(1-2^-10) - 2^-15). A half rounds to nearest with relative error at most 2^-11, so the stored value is below the true distance t, and pass 1 starts at or before it (conservative; the cost is starting at most ~0.1% earlier). W_FAR (1400) is exact in a half, so a tile proven empty is still skipped. The constants live once in Scene.ts and reach the shader as defines.
  • Standalone page: shows the reason (webgl2 context missing / which extension missing / compile error), a half-float start checkbox, the start-target format in the readout, and a device panel (open automatically when the scene is null) with renderer, vendor, GL version, user agent, the four extensions that matter yes/NO, and the full extension list.

Tests (Scene.test.ts, run with vitest 1.6 in a scratch tree: 63 pass): reasons are distinct; half-only device allocates no RGBA32F target and compiles the prepass with the rounding-down write, a wide device keeps RGBA32F and the plain write; a sweep of ~20k distances plus a dense grid to 1400 through an emulation of round-to-nearest-even half storage shows the stored value never exceeds t and never falls short by more than 0.21% of it; W_FAR survives exactly. Mutation check: setting the scale to 1 fails the sweep.

Rendered check (SwiftShader, forced halfStart, 640x360, pulls 0/0.5/1, vs the 512-step reference): half-start prepass vs 32F prepass, mean error 0.362 vs 0.361, 1.784 vs 1.785, 1.190 vs 1.193; pixels more than 8/255 worse than the 32F prepass: 1, 2, 12 (largest blob 8 px). So the half path is indistinguishable from the 32F one in the software renderer.

New page (the 50ffd905 folder is untouched): https://minds.gamecult.org/nibu/ratatoskr-scene-halfstart-7e058664/index.html (index.html and bundle.js return 200).

What a phone must still confirm; I could not:

  • That the phone really has EXT_color_buffer_half_float without EXT_color_buffer_float. If it has neither, the page now says so in words, and this change does not help: the scene stays null and the CSS ground stays.
  • That a RGBA16F/RGBA16F/R16F framebuffer is complete there. Advertising the extension is not a guarantee; createScene does not check checkFramebufferStatus, so an incomplete framebuffer would show as a black or empty scene, not a reason string.
  • The GPU's rounding of a half write. The test emulates round-to-nearest; truncating or unusual hardware also lands at or below t, but nothing here ran on a phone GPU.
  • The trace-stats readback (readPixels RGBA/FLOAT from an RGBA16F target) is spec-legal but untested on mobile; it only runs when the page asks for step stats, and a phone without timer queries still shows no ms.
  • Frame rate and the adaptive ladder on a real device. SwiftShader timings are CPU time.
**7e058664: half-float fallback for the prepass start, and a reason for every null** (pushed to this branch, not merged) Meta's phone Chrome showed `createScene returned null: no WebGL2 or float colour buffers`. Cause: `createScene` demanded `EXT_color_buffer_float`, and only the prepass start target (RGBA32F) needs 32-bit; every other target is already a half, and rendering to those needs only `EXT_color_buffer_half_float`. What changed - `createScene({ onUnavailable })` says why it returned null: `no-webgl2`, or `no-float-or-half-float-colour-buffer` (neither extension). With only `EXT_color_buffer_half_float` the scene now runs, with an RGBA16F start target (`scene.startFormat`; `halfStart: true` forces it for tests/measurement). - Rounding down: the half-mode prepass writes `max(0, t*(1-2^-10) - 2^-15)`. A half rounds to nearest with relative error at most 2^-11, so the stored value is below the true distance t, and pass 1 starts at or before it (conservative; the cost is starting at most ~0.1% earlier). `W_FAR` (1400) is exact in a half, so a tile proven empty is still skipped. The constants live once in `Scene.ts` and reach the shader as defines. - Standalone page: shows the reason (webgl2 context missing / which extension missing / compile error), a `half-float start` checkbox, the start-target format in the readout, and a `device` panel (open automatically when the scene is null) with renderer, vendor, GL version, user agent, the four extensions that matter yes/NO, and the full extension list. Tests (Scene.test.ts, run with vitest 1.6 in a scratch tree: 63 pass): reasons are distinct; half-only device allocates no RGBA32F target and compiles the prepass with the rounding-down write, a wide device keeps RGBA32F and the plain write; a sweep of ~20k distances plus a dense grid to 1400 through an emulation of round-to-nearest-even half storage shows the stored value never exceeds t and never falls short by more than 0.21% of it; `W_FAR` survives exactly. Mutation check: setting the scale to 1 fails the sweep. Rendered check (SwiftShader, forced `halfStart`, 640x360, pulls 0/0.5/1, vs the 512-step reference): half-start prepass vs 32F prepass, mean error 0.362 vs 0.361, 1.784 vs 1.785, 1.190 vs 1.193; pixels more than 8/255 worse than the 32F prepass: 1, 2, 12 (largest blob 8 px). So the half path is indistinguishable from the 32F one in the software renderer. New page (the 50ffd905 folder is untouched): https://minds.gamecult.org/nibu/ratatoskr-scene-halfstart-7e058664/index.html (index.html and bundle.js return 200). What a phone must still confirm; I could not: - That the phone really has `EXT_color_buffer_half_float` without `EXT_color_buffer_float`. If it has neither, the page now says so in words, and this change does not help: the scene stays null and the CSS ground stays. - That a RGBA16F/RGBA16F/R16F framebuffer is *complete* there. Advertising the extension is not a guarantee; `createScene` does not check `checkFramebufferStatus`, so an incomplete framebuffer would show as a black or empty scene, not a reason string. - The GPU's rounding of a half write. The test emulates round-to-nearest; truncating or unusual hardware also lands at or below t, but nothing here ran on a phone GPU. - The trace-stats readback (`readPixels` RGBA/FLOAT from an RGBA16F target) is spec-legal but untested on mobile; it only runs when the page asks for step stats, and a phone without timer queries still shows no ms. - Frame rate and the adaptive ladder on a real device. SwiftShader timings are CPU time.
scene README: createScene needs either float colour-buffer extension
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
293885089a
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Member

29388508 vs 50ffd905 on the full-float prepass path, and the per-pixel diff re-run with half-float forced

Method: both commits checked out as worktrees, the harness entry bundle built from each, same headless Chromium on SwiftShader, same frozen frame (reduced motion, time 0, 640x360, level 0, 96 steps), pulls 0, 0.25, 0.5, 0.75, 1. PNG bytes hashed (sha256).

Byte-identity. Full-float prepass frames, baseline (no prepass) frames and 512-step reference frames are byte-identical between the two commits at all five pulls (15 of 15 PNGs have equal hashes). The only shader change on that path is float start = t; ahead of the write, and the half branch is behind #ifdef START_HALF, which the 32-bit program never defines. Rendered output proves the source change is a no-op here; the GLSL text itself is not identical (the diff is prepass.frag.glsl plus Scene.ts option plumbing, harness and tests).

Half-float forced (halfStart: true, RGBA16F start, rounded-down write), prepass worse than baseline by more than T/255, of 230,400 pixels, against the 512-step reference:

pull >2 >4 >8 >16 >32 >64 max largest blob better by >8
0 153 108 59 17 3 0 48 15 px 2013
0.25 362 213 26 8 0 0 31 3 px 25529
0.5 781 469 91 4 0 0 23 22 px 18850
0.75 1026 603 167 31 0 0 29 79 px 13555
1 643 352 110 20 1 0 34 35 px 10833

Against the earlier 32-bit table these differ by a few pixels (for example pull 0, more than 8: 59 both; pull 1: 104 vs 110). Half vs 32-bit prepass directly, pixels more than 8/255 worse: 1 / 2 / 2 / 3 / 12 (max 9 / 17 / 19 / 15 / 44, pull 1 worst, in a few small blobs). Mean error vs reference, baseline then half prepass: 0.62 -> 0.36, 4.96 -> 2.49, 3.87 -> 1.79, 3.14 -> 1.56, 2.56 -> 1.19. No pixel is worse by 64 or more; no hole or missing hill seen.

Heatmaps: /srv/minds-web/nibu/ratatoskr-scene-halfstart-7e058664/shots/half-vs-baseline-heatmap-pull*.png.

Limits: SwiftShader's half rounding and float behaviour, not a phone GPU; one frozen frame per pull; level 0; reference is the same renderer at 512 steps; a byte-identical frame here says nothing about other GPUs.

**29388508 vs 50ffd905 on the full-float prepass path, and the per-pixel diff re-run with half-float forced** Method: both commits checked out as worktrees, the harness entry bundle built from each, same headless Chromium on SwiftShader, same frozen frame (reduced motion, time 0, 640x360, level 0, 96 steps), pulls 0, 0.25, 0.5, 0.75, 1. PNG bytes hashed (sha256). Byte-identity. Full-float prepass frames, baseline (no prepass) frames and 512-step reference frames are **byte-identical between the two commits at all five pulls** (15 of 15 PNGs have equal hashes). The only shader change on that path is `float start = t;` ahead of the write, and the half branch is behind `#ifdef START_HALF`, which the 32-bit program never defines. Rendered output proves the source change is a no-op here; the GLSL text itself is not identical (the diff is `prepass.frag.glsl` plus Scene.ts option plumbing, harness and tests). Half-float forced (`halfStart: true`, RGBA16F start, rounded-down write), prepass worse than baseline by more than T/255, of 230,400 pixels, against the 512-step reference: | pull | >2 | >4 | >8 | >16 | >32 | >64 | max | largest blob | better by >8 | |---|---|---|---|---|---|---|---|---|---| | 0 | 153 | 108 | 59 | 17 | 3 | 0 | 48 | 15 px | 2013 | | 0.25 | 362 | 213 | 26 | 8 | 0 | 0 | 31 | 3 px | 25529 | | 0.5 | 781 | 469 | 91 | 4 | 0 | 0 | 23 | 22 px | 18850 | | 0.75 | 1026 | 603 | 167 | 31 | 0 | 0 | 29 | 79 px | 13555 | | 1 | 643 | 352 | 110 | 20 | 1 | 0 | 34 | 35 px | 10833 | Against the earlier 32-bit table these differ by a few pixels (for example pull 0, more than 8: 59 both; pull 1: 104 vs 110). Half vs 32-bit prepass directly, pixels more than 8/255 worse: 1 / 2 / 2 / 3 / 12 (max 9 / 17 / 19 / 15 / 44, pull 1 worst, in a few small blobs). Mean error vs reference, baseline then half prepass: 0.62 -> 0.36, 4.96 -> 2.49, 3.87 -> 1.79, 3.14 -> 1.56, 2.56 -> 1.19. No pixel is worse by 64 or more; no hole or missing hill seen. Heatmaps: `/srv/minds-web/nibu/ratatoskr-scene-halfstart-7e058664/shots/half-vs-baseline-heatmap-pull*.png`. Limits: SwiftShader's half rounding and float behaviour, not a phone GPU; one frozen frame per pull; level 0; reference is the same renderer at 512 steps; a byte-identical frame here says nothing about other GPUs.
void merged commit 7e632e8605 into ratatoskr/estate-navigator 2026-11-04 22:56:29 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Ratatoskr!12
No description provided.