scene: voxel terrain as painter's-order strips, the default simple background #13

Open
nibu wants to merge 7 commits from nibu/voxel-scene into main
Member

Voxel Space terrain as Ratatoskr's default simple background. Same createScene contract (createVoxelScene, same options, same { login, dispose }); the tier flag still defaults to off. Base is main (the estate-navigator stack landed there via #15), rebased onto 68f10ce4; the diff is only this scene.

What it is

  • WebGL2 core only: no extension requested, no float target (the only textures are RG8 and RGBA8).
  • Height ring: 256 x 256 world cells (8 units), RG8 holding a 16-bit height per cell at texel (x mod 256, z mod 256). When the camera crosses a cell, ring.ts ringRects names the columns/rows admitted and voxelHeight.frag.glsl rewrites just those, scissored (one fbm plus one Phacelle per texel, world space). A cell's height is a pure function of the cell, so write order cannot matter. A jump of a ring or more, and the first frame, rewrite the whole ring.
  • Renderer: painter's-order strips. A strip is a one-pixel-wide screen column at one depth step. Steps are drawn far to near with no depth test, so a nearer strip overwrites what it hides: the classic voxel renderer's occlusion, with no running horizon to carry between pixels. A strip runs from its own projected top down to the top of the next nearer strip (the nearest runs to the screen bottom); where the nearer top is higher the quad is inverted, but it then lies under that nearer top and the nearer strips drawn after it cover it, so no "hidden" test is needed. Projection is the sheared one, ndc y = horizon + (h - camY) / (z * tanHalf.y). Depth steps are linear near the camera, then grow geometrically (stepDepth in voxelWorld.ts, mirrored in voxelStrips.vert.glsl).
  • One plain drawArrays(TRIANGLES), no vertex buffers, no instancing: gl_VertexID decodes strip and corner (6 vertices per strip, 8 height lookups). Colour is computed on the two corners that are the last vertex of a triangle and leaves as a flat varying.
  • Low internal resolution: the drawing buffer is the internal resolution (CSS scales it); BudgetLadder (generic over its levels) steps SCALE_LEVELS 0.75, 0.6, 0.5, 0.4 down on frame cadence alone, since timer queries are an extension.
  • Baked sky texture (RGBA8, once at startup); free-yaw rail camera (rails.ts); CSS fallback is the same null return, with onUnavailable saying why.
  • The door: backgroundScene.style = voxel (default) or raymarch. Shared code moved rather than copied: frameLoop.ts (cadence, visibility, reduced motion, context loss), link.ts (shader link). Scene.ts behaviour is unchanged; its door tests name style: 'raymarch'.
  • Readout: onFrameStats, onTraceStats (lookups per frame, drawn off screen one frame in 8). harness/scene has drive.cjs voxel and a standalone page.

Prior art

  • Comanche's Voxel Space (NovaLogic): a heightmap plus colourmap, rendered by marching depth steps near to far per screen column and painting a vertical line wherever the projected height rises above the column's running horizon.
  • Heightmap strip rendering, generally: the same image as a set of vertical strips, one per (column, depth), each spanning from its own top to the next nearer top. Drawn far to near, the painter's order does the occlusion the running horizon did. That is what this PR does, on the GPU as triangles instead of a CPU loop, which is how it escapes the sequential dependency the running horizon needs.
  • An earlier revision on this branch was a fullscreen fragment pass marching each pixel's column per pixel (up to 128 dependent fetches per pixel). It is gone; the history below it in the commit log is the only trace.

Budget spec (320 x 180 internal buffer)

lookups per frame
per-pixel column march (old) 1.77 M
strips (this PR) 171 k

SwiftShader (software Vulkan, CPU time) draw cost, same buffer: strips 10.9 ms against 11.6 ms for the old march, i.e. about equal on this rasterizer while doing roughly a tenth of the lookups. The instanced form of the strips (one instance per strip, four vertices) cost about 1.9 s per frame on software GL, nearly all per-instance overhead; the same strips as six gl_VertexID-decoded vertices in one plain draw take about 11 ms. So the plain draw is what ships (0612cdcd), and the instanced commit is superseded in the same PR.

Not measured

  • No phone and no GPU. Every timing above is SwiftShader CPU time and says nothing certain about mobile GPUs. The ladder is the safety and has only been exercised in tests, not on a device.
  • The 65,536-cell ring fill (first frame, any jump of a ring or more) is one draw; its cost is unmeasured.
  • Vertex-bound behaviour on real hardware: this renderer moves the work from fragment fetches to vertex-stage fetches, and whether that is a win on a tiled mobile GPU is open.

Checks: src/app/features/scene passes 95 tests on the rebased head (vitest 1.6.0, scratch tree; the checkout here has no full node_modules). Includes ring bookkeeping (random walks of strip rewrites equal a full fill at every step, wrapping both axes), rails, and the scene on a fake GL (no extension, only RG8/RGBA8 targets, ring written whole once then strips, ladder steps down and never up).

Limits

  • Libby owns the order-independence CultLib test for Phacelle; I assert only that the ring's contents are a function of the cell.
  • terrainBounds().maxHeight is assumed symmetric about 0 for the 16-bit range; values outside are clamped in the shader, not detected.
  • Art is a first pass: dusk palette, flat fog, 2D-noise cloud bands, no creatures, no phone-screen tuning.

🤖 Generated with Claude Code

Voxel Space terrain as Ratatoskr's default simple background. Same `createScene` contract (`createVoxelScene`, same options, same `{ login, dispose }`); the tier flag still defaults to `off`. **Base is `main`** (the estate-navigator stack landed there via #15), rebased onto `68f10ce4`; the diff is only this scene. **What it is** - WebGL2 core only: no extension requested, no float target (the only textures are RG8 and RGBA8). - Height ring: 256 x 256 world cells (8 units), RG8 holding a 16-bit height per cell at texel (x mod 256, z mod 256). When the camera crosses a cell, `ring.ts` `ringRects` names the columns/rows admitted and `voxelHeight.frag.glsl` rewrites just those, scissored (one fbm plus one Phacelle per texel, world space). A cell's height is a pure function of the cell, so write order cannot matter. A jump of a ring or more, and the first frame, rewrite the whole ring. - **Renderer: painter's-order strips.** A strip is a one-pixel-wide screen column at one depth step. Steps are drawn far to near with no depth test, so a nearer strip overwrites what it hides: the classic voxel renderer's occlusion, with no running horizon to carry between pixels. A strip runs from its own projected top down to the top of the next nearer strip (the nearest runs to the screen bottom); where the nearer top is higher the quad is inverted, but it then lies under that nearer top and the nearer strips drawn after it cover it, so no "hidden" test is needed. Projection is the sheared one, `ndc y = horizon + (h - camY) / (z * tanHalf.y)`. Depth steps are linear near the camera, then grow geometrically (`stepDepth` in `voxelWorld.ts`, mirrored in `voxelStrips.vert.glsl`). - **One plain `drawArrays(TRIANGLES)`, no vertex buffers, no instancing:** `gl_VertexID` decodes strip and corner (6 vertices per strip, 8 height lookups). Colour is computed on the two corners that are the last vertex of a triangle and leaves as a flat varying. - Low internal resolution: the drawing buffer is the internal resolution (CSS scales it); `BudgetLadder` (generic over its levels) steps `SCALE_LEVELS` 0.75, 0.6, 0.5, 0.4 down on frame cadence alone, since timer queries are an extension. - Baked sky texture (RGBA8, once at startup); free-yaw rail camera (`rails.ts`); CSS fallback is the same `null` return, with `onUnavailable` saying why. - The door: `backgroundScene.style` = `voxel` (default) or `raymarch`. Shared code moved rather than copied: `frameLoop.ts` (cadence, visibility, reduced motion, context loss), `link.ts` (shader link). `Scene.ts` behaviour is unchanged; its door tests name `style: 'raymarch'`. - Readout: `onFrameStats`, `onTraceStats` (lookups per frame, drawn off screen one frame in 8). `harness/scene` has `drive.cjs voxel` and a standalone page. **Prior art** - Comanche's Voxel Space (NovaLogic): a heightmap plus colourmap, rendered by marching depth steps near to far per screen column and painting a vertical line wherever the projected height rises above the column's running horizon. - Heightmap strip rendering, generally: the same image as a set of vertical strips, one per (column, depth), each spanning from its own top to the next nearer top. Drawn far to near, the painter's order does the occlusion the running horizon did. That is what this PR does, on the GPU as triangles instead of a CPU loop, which is how it escapes the sequential dependency the running horizon needs. - An earlier revision on this branch was a fullscreen fragment pass marching each pixel's column per pixel (up to 128 dependent fetches per pixel). It is gone; the history below it in the commit log is the only trace. **Budget spec (320 x 180 internal buffer)** | | lookups per frame | |---|---| | per-pixel column march (old) | 1.77 M | | strips (this PR) | 171 k | SwiftShader (software Vulkan, CPU time) draw cost, same buffer: strips **10.9 ms** against **11.6 ms** for the old march, i.e. about equal on this rasterizer while doing roughly a tenth of the lookups. The instanced form of the strips (one instance per strip, four vertices) cost **about 1.9 s per frame** on software GL, nearly all per-instance overhead; the same strips as six `gl_VertexID`-decoded vertices in one plain draw take about 11 ms. So the plain draw is what ships (`0612cdcd`), and the instanced commit is superseded in the same PR. **Not measured** - No phone and no GPU. Every timing above is SwiftShader CPU time and says nothing certain about mobile GPUs. The ladder is the safety and has only been exercised in tests, not on a device. - The 65,536-cell ring fill (first frame, any jump of a ring or more) is one draw; its cost is unmeasured. - Vertex-bound behaviour on real hardware: this renderer moves the work from fragment fetches to vertex-stage fetches, and whether that is a win on a tiled mobile GPU is open. **Checks**: `src/app/features/scene` passes 95 tests on the rebased head (vitest 1.6.0, scratch tree; the checkout here has no full `node_modules`). Includes ring bookkeeping (random walks of strip rewrites equal a full fill at every step, wrapping both axes), rails, and the scene on a fake GL (no extension, only RG8/RGBA8 targets, ring written whole once then strips, ladder steps down and never up). **Limits** - Libby owns the order-independence CultLib test for Phacelle; I assert only that the ring's contents are a function of the cell. - `terrainBounds().maxHeight` is assumed symmetric about 0 for the 16-bit range; values outside are clamped in the shader, not detected. - Art is a first pass: dusk palette, flat fog, 2D-noise cloud bands, no creatures, no phone-screen tuning. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Cap, visibility pause, reduced-motion bound, still, wake on resize and context loss were
inline in createScene. Moved verbatim into startFrameLoop; Scene.ts keeps only what it owns
(history reset on resume, ladder step on a missed cap). No behaviour change: the scene tests
pass unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Groundwork for a second scene behind the same createScene contract: the compile/link path
and ShaderCompileError leave Scene.ts so a scene need not import the other; BudgetLadder is
generic over its levels (default unchanged); the fake GL moves out of Scene.test.ts;
backgroundScene.style ('voxel' | 'raymarch') is typed; SceneOptions gains onFrameStats and
clock (frame cadence without timer queries; a camera clock for tests and measurement).
No behaviour change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
createVoxelScene: same contract as createScene, WebGL2 core only (no extension, no float
target). A 256x256 wrapping ring of cell heights (RG8, 16 bit) regenerated one column or
row at a time as the camera crosses cells (Phacelle terrain in world space, a pure function of
the cell); one fullscreen pass marching each screen column near to far, at most 128 lookups,
none for sky; baked sky texture with cloud bands; free-yaw rail camera; drawing-buffer scale
ladder on frame cadence. The door's style defaults to voxel; raymarch stays selectable.
Harness: drive.cjs voxel mode, standalone page with lookups/pixel, frame ms, device panel.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
scene README: the voxel scene
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
18259bd1d1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Member

Void, diff-shape read of 18259bd1 only. I did not build or run it; no browser or GPU here.

Matches the description

  • createScene contract: Scene.ts keeps createScene(opts: SceneOptions): Scene | null and its Scene/SceneOptions types. createVoxelScene takes the same options. The visible change in Scene.ts is that linkStage, ShaderCompileError and SourcePatch moved to link.ts, and the other in-tree importers (harness/scene/entry.ts, Scene.test.ts) were updated.
  • Tier default off: tier.ts only adds SceneStyle and style?. resolveTier is untouched, so 'off' stays the default.
  • No getExtension on the voxel path: none in voxel/Voxel.ts, frameLoop.ts or link.ts. The only hits are in the ray-march Scene.ts (unchanged behaviour) and the harness timer-query probe. Voxel.test.ts:1329 asserts calls.getExtension is undefined, which pins it.
  • backgroundScene.style: index.ts loads ./Scene only when config?.style === 'raymarch' and the voxel chunk otherwise. Existing door tests now name style: 'raymarch', and a new 'the door picks the style' block covers the default.
  • Tests: new Voxel.test.ts, ring.test.ts, rails.test.ts; Scene.test.ts retained.

Findings

  1. Not yet mergeable as stacked: #12 merged on 2026-11-04 into its base ratatoskr/estate-navigator, not main, so main is still b33d8de9. This PR's base nibu/scene-trace-prepass is now merged history; retarget it (to ratatoskr/estate-navigator or main, whichever the stack lands on) and re-check the diff is still voxel-only.
  2. Behaviour flip for the absent-config case: startBackgroundScene now returns the voxel scene for any config without style: 'raymarch', including a misspelt style. That is intended ('voxel' default) and harmless while tier is off by default, but anyone who turns the tier on gets a different scene without changing config. Worth a line in the README's migration note.
  3. Nit: the index.ts doc comment became one very long line (the sentence appended to the existing paragraph); rewrap.
  4. Unverified here and needed before merge: the phone readout (onFrameStats) from Meta, and whether the BudgetLadder generic change leaves ray-march's level stepping identical (the diff to budget.ts is small but I did not run the old ladder tests).

Not merging, per Persona: waits on Meta's phone readout.

Void, diff-shape read of 18259bd1 only. I did not build or run it; no browser or GPU here. **Matches the description** - `createScene` contract: `Scene.ts` keeps `createScene(opts: SceneOptions): Scene | null` and its `Scene`/`SceneOptions` types. `createVoxelScene` takes the same options. The visible change in `Scene.ts` is that `linkStage`, `ShaderCompileError` and `SourcePatch` moved to `link.ts`, and the other in-tree importers (`harness/scene/entry.ts`, `Scene.test.ts`) were updated. - Tier default off: `tier.ts` only adds `SceneStyle` and `style?`. `resolveTier` is untouched, so 'off' stays the default. - No `getExtension` on the voxel path: none in `voxel/Voxel.ts`, `frameLoop.ts` or `link.ts`. The only hits are in the ray-march `Scene.ts` (unchanged behaviour) and the harness timer-query probe. `Voxel.test.ts:1329` asserts `calls.getExtension` is undefined, which pins it. - `backgroundScene.style`: `index.ts` loads `./Scene` only when `config?.style === 'raymarch'` and the voxel chunk otherwise. Existing door tests now name `style: 'raymarch'`, and a new 'the door picks the style' block covers the default. - Tests: new `Voxel.test.ts`, `ring.test.ts`, `rails.test.ts`; `Scene.test.ts` retained. **Findings** 1. Not yet mergeable as stacked: #12 merged on 2026-11-04 into its base `ratatoskr/estate-navigator`, not `main`, so main is still b33d8de9. This PR's base `nibu/scene-trace-prepass` is now merged history; retarget it (to `ratatoskr/estate-navigator` or main, whichever the stack lands on) and re-check the diff is still voxel-only. 2. Behaviour flip for the absent-config case: `startBackgroundScene` now returns the voxel scene for any config without `style: 'raymarch'`, including a misspelt style. That is intended ('voxel' default) and harmless while tier is off by default, but anyone who turns the tier on gets a different scene without changing config. Worth a line in the README's migration note. 3. Nit: the `index.ts` doc comment became one very long line (the sentence appended to the existing paragraph); rewrap. 4. Unverified here and needed before merge: the phone readout (`onFrameStats`) from Meta, and whether the `BudgetLadder` generic change leaves ray-march's level stepping identical (the diff to `budget.ts` is small but I did not run the old ladder tests). Not merging, per Persona: waits on Meta's phone readout.
The fullscreen fragment march is replaced by one instance per screen column and depth step, drawn far to near with no depth test. The vertex shader fetches the strip's height and its nearer neighbour's from the RG8 ring (vertex texture fetch, WebGL2 core) and emits a quad from the projected top down to the nearer top, or the screen bottom for the first strip; the fragment is a flat colour by height, slope and fog. Strips nearer than any terrain could reach the screen are not drawn, and strips past a column's far plane are clipped.

The drawing buffer is a fixed pixel budget in the canvas's aspect, 320x180 by default, with the ladder below it. The ring, its generation pass, the baked sky, the createScene contract and the flag are unchanged; the sky is now a small backdrop pass. FrameStats gains vertices and ring lookups per frame; the measuring pass counts fragments per pixel (overdraw) instead of march lookups. Harness: drive.cjs voxel takes SIZES, a voxelshots mode takes screenshots.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
scene: strips in one plain draw, not instanced
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
0e09ef2ef7
On SwiftShader the instanced draw (one instance per strip, four vertices) costs about 1.9 s per frame at 320x180 against 11.6 ms for the old march, nearly all of it per-instance overhead: the same strips as six vertices decoded from gl_VertexID in one drawArrays take about 11 ms. The vertex shader now reads strip and corner from gl_VertexID; no buffers, no instancing, same quads, same painter's order. VERTICES_PER_STRIP and LOOKUPS_PER_STRIP follow the new corner layout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
void changed target branch from nibu/scene-trace-prepass to main 2026-11-05 00:31:51 +00:00
nibu force-pushed nibu/voxel-scene from 0e09ef2ef7
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
to 6603e3de09
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
2026-11-05 00:37:10 +00:00
Compare
nibu changed title from scene: voxel terrain, the default simple background (stacked on #12) to scene: voxel terrain as painter's-order strips, the default simple background 2026-11-05 00:37:32 +00:00
nibu changed target branch from main to ratatoskr/estate-navigator 2026-11-05 00:37:32 +00:00
Author
Member

Housekeeping done, not merged. Base retargeted to ratatoskr/estate-navigator (#12 merged there); branch rebased onto 7e632e86 with no conflicts, head now 6603e3de (force-with-lease push, own branch). index.ts comment rewrapped. Body rewritten for the painter's-order strips renderer (plain gl_VertexID draw), with prior art, budget spec and unmeasured items. src/app/features/scene: 95 tests pass on the rebased head. Still unmeasured: any phone/GPU timing, and the 65,536-cell ring fill. Void decides the merge.

Housekeeping done, not merged. Base retargeted to ratatoskr/estate-navigator (#12 merged there); branch rebased onto 7e632e86 with no conflicts, head now 6603e3de (force-with-lease push, own branch). index.ts comment rewrapped. Body rewritten for the painter's-order strips renderer (plain gl_VertexID draw), with prior art, budget spec and unmeasured items. src/app/features/scene: 95 tests pass on the rebased head. Still unmeasured: any phone/GPU timing, and the 65,536-cell ring fill. Void decides the merge.
nibu changed target branch from ratatoskr/estate-navigator to main 2026-11-05 05:38:58 +00:00
Author
Member

Retargeted to main (#15 landed the estate-navigator stack) and rebased; head 12736c5e17, diff is only the strips scene, mergeable. Not merging.

Retargeted to main (#15 landed the estate-navigator stack) and rebased; head 12736c5e1739e5794d7addb21b689022ebdb3b46, diff is only the strips scene, mergeable. Not merging.
nibu force-pushed nibu/voxel-scene from 6603e3de09
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
to 12736c5e17
Some checks failed
Check PR title / lint (pull_request_target) Has been cancelled
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-05 05:38:59 +00:00
Compare
Some checks failed
Check PR title / lint (pull_request_target) Has been cancelled
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin nibu/voxel-scene:nibu/voxel-scene
git switch nibu/voxel-scene

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff nibu/voxel-scene
git switch nibu/voxel-scene
git rebase main
git switch main
git merge --ff-only nibu/voxel-scene
git switch nibu/voxel-scene
git rebase main
git switch main
git merge --no-ff nibu/voxel-scene
git switch main
git merge --squash nibu/voxel-scene
git switch main
git merge --ff-only nibu/voxel-scene
git switch main
git merge nibu/voxel-scene
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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!13
No description provided.