scene: cone prepass for pass 1, opt-in steps/pixel readout #12
Loading…
Reference in a new issue
No description provided.
Delete branch "nibu/scene-trace-prepass"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 extraR8step-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.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_FARwhen the whole tile climbs above the highest geometry); pass 1 starts there.createScene({ prepass: false })is the baseline. The distance bound moved tosdf.glslso both passes use the same function.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.
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-*.pngfromdrive.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: theR8readback used the defaultPACK_ALIGNMENTof 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 interrainDistance; the login camera is on the plateau so it holds there, a camera inside the erosion band would break it.Not verified
EXT_disjoint_timer_query_webgl2path is exercised only through SwiftShader's implementation.stilltier, and thelow-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
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 pixelworse = 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):
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 framesdiff-{off,on,ref}-<pull>.pngin the same folder.Limits, honestly:
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.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:createScenedemandedEXT_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 onlyEXT_color_buffer_half_float.What changed
createScene({ onUnavailable })says why it returned null:no-webgl2, orno-float-or-half-float-colour-buffer(neither extension). With onlyEXT_color_buffer_half_floatthe scene now runs, with an RGBA16F start target (scene.startFormat;halfStart: trueforces it for tests/measurement).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 inScene.tsand reach the shader as defines.half-float startcheckbox, the start-target format in the readout, and adevicepanel (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_FARsurvives 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
50ffd905folder 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:
EXT_color_buffer_half_floatwithoutEXT_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.createScenedoes not checkcheckFramebufferStatus, so an incomplete framebuffer would show as a black or empty scene, not a reason string.readPixelsRGBA/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.29388508vs50ffd905on the full-float prepass path, and the per-pixel diff re-run with half-float forcedMethod: 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 isprepass.frag.glslplus 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: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.
nibu referenced this pull request2026-11-04 21:54:52 +00:00