Skip to content

Voxel planet: chunk octree, ray-guided residency, per-voxel materials, material contract - #353

Draft
Tuafo wants to merge 13 commits into
mainfrom
claude/voxel-ray-guided-residency
Draft

Tuafo wants to merge 13 commits into
mainfrom
claude/voxel-ray-guided-residency

Conversation

@Tuafo

@Tuafo Tuafo commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Nine changes to the voxel planet pass, plus a merge of main (crates/passes/3d/helio-pass-voxel-planet). The design and every measurement are in docs/chunk-octree.md.

Pulsar pins this branch in Far-Beyond-Pulsar/Pulsar-Native#1395 (draft). Merge this PR first, then re-pin Pulsar to Helio main.

3D chunk octree with ray-guided residency (f866cbb6)

  • Octree replaces the tile quadtree. Nodes are 64³ chunks (face, level, cx, cy, cz). Classification accounts for what a node's descendants hold (caves, brushes at any size, baked bricks), so a coarse node never hides finer detail.
  • Residency is a cut of the octree. Rays report the node their pixel wants (one pixel per 8x8 block per frame). Buried nodes draw as rock until a ray or the eye reaches them. LRU eviction runs when slots or leaf pages run short.
  • Footprint pool. Footprint columns stay on the GPU across frames in 2048 slots. A chunk now costs 19–22 µs to generate, down from 27–30.
  • The old tile, layer, gap and deep-tile paths are deleted.

Docs: corrupt captures are an NVIDIA driver fault (955e7cf5)

The occasional captures with 8x8 haze blocks are not an engine race. Only NVIDIA's Vulkan driver shows them (616.64, RTX 3060): D3D12 never does, and synchronization validation finds no hazard. Reported to NVIDIA as bug 6939141. The docs list what was tried and what changes it.

Per-voxel materials for edited cells, and a brush index (cdaf7714)

  • Per-voxel materials. Cells whose material edits set (add, paint, baked cells) keep their own material in 4 KiB detail pages. A leaf page gets one only when it holds such a cell. Other leaves keep their surface and subsurface pair.
  • Drawing reads the cell's own byte first. A per-slot flag skips the lookup in chunks with no detail pages.
  • PickHit has a new material field. Pulsar's PickHit constructor in voxel_backend.rs needs material: 0 when its Helio submodule is bumped.
  • BrushIndex (a loose grid per face) replaces the scan of every brush per chunk in queue_footprint.

Measurements

Before After
mountain_slope hold, primary rays (mean of 4 rounds) 2.861 ms 2.861 ms
Cruise flight: chunk columns, per chunk 5.6 µs 5.5 µs
Cruise flight: chunk builds, per chunk 2.8 µs 2.9 µs
Cruise flight: draw p50 3.79 ms 3.80 ms

Without the per-slot flag, the detail lookup cost +0.9% on primary rays.

Generation materials match Planet::material (e5398492)

The new report test natural_cells_against_the_leaf_rule (ignored by default) compared drawn materials with Planet::material on natural mountain ground and in dug pit walls. Before this commit, 40.5% of pixels differed (0% on flat grassland), mostly from a CPU/GPU mismatch.

  • The mismatch. The generation kernel evaluated ground_material at the column's domain point; Planet::material uses the cell's volume point. The snowline's noise decorrelates 2 km up, so whole views flipped (snow drawn as stone). The slope estimates also differed.
  • Point. Each evaluation now uses its cell's volume point and the surface byte.
  • Slope. Levels 0–3 use Planet::material_slope. Each computed footprint evaluates a lattice of level-4 heights, through three dispatches, and keeps the block slopes in the footprint pool.
  • New gate, uniform_leaves_draw_canonical_materials. On leaves whose 16 columns all top out in them with one material, the top faces draw the canonical material exactly: 31,710 pixels, none wrong (779 wrong before).
  • What's left is the leaf rule itself: 9.35% of pixels. That's 8.8% for cells under air in mixed leaves, and 33.5% for cells under ground, mostly in dug walls.
  • Cost: generation goes from about 20 µs to 22.4–22.8 µs a chunk (+10%), all of it from the lattices at levels 1–3. Drawing is unchanged.

Dug walls draw their cells' own materials (c0f1779c)

  • The problem: a leaf's subsurface byte follows its centre's depth, so about a third of a pit's wall pixels drew rock where the cells are soil a cell deep.
  • The fix:
    • Every chunk under an edit now gets a per-cell scratch slot.
    • Each exposed solid cell gets its edit's material, or else its natural Planet::material.
    • Cells the leaf's pair already draws correctly are dropped, so only leaves that need it get detail pages.
  • New gate, dug_walls_draw_canonical_materials: all 57,050 wall and floor pixels of pits at three varied spots draw Planet::material, at about 170 detail pages per pit.
  • The natural-cells report: 3.56% of pixels wrong (9.35% before), none under ground. What's left is the surface vote in unedited mixed leaves.

GPU test worlds sized to fit VRAM (4f11cf98)

  • The problem: each GPU test built a game-sized world, about 2.9 GB of VRAM with its device. Eight parallel tests overflowed a 12 GB GPU, and the suite crawled or stalled.
  • The fix: ChunkWorld::with_capacity takes a Capacity (chunk slots, detail pages, footprint pool slots). new keeps the game default and its environment variables. Tests use 16,384 chunk slots, about 1.1 GB each.

Large brushes into data: edits on data, step 1 (acfcf426)

At 10,000 large digs 8 m across, one more edit cost 3.8 ms of CPU sync and 4.35 ms of GPU generation:

  • every edit re-hashed, re-indexed and re-uploaded every large brush;
  • every coarse chunk under them evaluated all of them.

The fixes:

  • Large brushes are baked into the edit store at their bake level, the first level where they span at most 32 cells. Coarser levels follow by majority. Below that level they stay analytic, and at it every cell reads as the analytic brush did.
  • Snapshots are now version 3. Older snapshots bake their large brushes on load.
  • The brush index is split by the levels a brush applies at, so coarse chunks skip baked brushes.
  • Edit sync is incremental. It reuses the large-brush prefix, diffs only the changed tail, and writes the buffer tail in place.
  • A GPU brick cache (64 MB). Regenerated chunks no longer re-upload the same bricks.

At 10,000 large digs, one more edit now costs 0.51 ms of sync, 0.48 ms of update CPU and 0.37 ms of GPU generation, against 0.34, 0.30 and 0.20 ms at 100.

Edited chunks kept across eviction: edits on data, step 2 (725f4c88)

  • Packing. When the plan evicts an edited chunk, the GPU packs its 64-tree (about 12 KB on average), reads it back, and the CPU keeps it.
  • Restoring. When that chunk is wanted again, it's restored by copy: no footprint, no terrain program and no brushes.
  • Empty chunks. An edited chunk that comes out empty is remembered as empty. Before this, the plan kept re-requesting dug chunks it had just found empty, and each one was regenerated from the whole history.
  • Invalidation. An edit over a kept chunk drops the kept copy.

New gate, edited_chunks_come_back_by_copy: a site under 2,000 large digs, left for a far view and then seen again.

On return Edited chunks restored / generated Generation GPU Pixels as before
Keeping off 0 / 534 308 ms all
Keeping on 1041 / 0 58 ms all

An edit over restored chunks draws exactly, and it comes back with them after another round trip.

Kept chunks in saves (41bdb45e, 1dcc418f)

  • Saving. keep_resident() also packs the resident edited chunks. kept_chunks() then takes every kept chunk; it shares the packs, so it's cheap on the render thread. KeptChunks::to_bytes() deflates them (about 1 KB per chunk) on the saving thread.
  • Not in the snapshot. The chunks are saved next to the edit snapshot, not inside it. A host folds only its oldest edits into a snapshot (Pulsar keeps its newest 4,096 listed), while the chunks reflect every edit. The version-4 snapshot section from 41bdb45e is removed; snapshots stay at version 3.
  • Journal. VoxelEditJournal stores a renderer's chunks for its edits as VoxelKeptChunks: the bytes, plus the journal length and hash they were made for. They're saved as kept, and any change to the edits drops them.
  • Loading. KeptChunks::from_bytes() reads the chunks back and load_kept() refills a new world's cache. It checks a fingerprint of the generation code, terrain and world, and refuses chunks saved for a different edit history.
  • Through the pass. PlanetFrame.kept goes into any world built for that planet; ChunkPass::kept_loaded reports how many loaded, or why they were refused. PlanetFrame.export (KeptExport) is a save's request: the pass packs the resident edited chunks over the next frames, asking for frames meanwhile, then answers with the planet shown and its kept chunks.

Gate a_loaded_world_restores_its_edited_chunks: the dug site is saved (313 chunks, 316 KiB on top of the snapshot's 260) and loaded into a new world:

  • edited chunks: 1,172 restored and none generated, against 313 generated without the kept chunks;
  • generation GPU: 90 ms against 234 ms;
  • every pixel matches the saved world;
  • damaged bytes and other histories are refused.

New saved_edited_chunks_come_back_through_the_frame (voxel_pass_graph): 111 chunks exported in 6 frames. Once loaded, 84 edited chunks are restored and none generated. Far-Beyond-Pulsar/Pulsar-Native#1395 wires this into the editor's save and load.

Merge of main (b451b343)

main's voxel debug views were written against the old tile-quadtree chunks/mod.rs, which this branch replaced. They're ported onto the octree files:

  • the renderer's debug mode forwarded to the voxel shaders, with local diagnostics in bits 0x200–0x800;
  • the trace-cost view (mode 32). The cost sits in the visibility word's high bits and the material in five bits, so PickHit.material now reads five;
  • the UV reference grid (mode 1). Its texture and sampler use group 1 bindings 25 and 26, because 24 is the ray-feedback buffer.

Tests

  • cargo test --release -p helio-pass-voxel-planet at default threads: 75 unit tests and 24 GPU tests pass.
  • helio-default-graphs: voxel_pass_graph (9 tests) passes. environment_join and scene_join's camera-relative tests fail exactly as they do on main; this branch changes nothing outside the voxel crate and its voxel_flight example.
  • New: edited_cells_draw_their_own_materials. All 16,306 edited-cell pixels match; with the lookup disabled, 14,610 of them are wrong.
  • New: the_brush_index_finds_what_a_scan_finds, kept_chunks_stay_with_exactly_their_edits (helio-voxel-data, 23 tests pass).
  • New: uniform_leaves_draw_canonical_materials, dug_walls_draw_canonical_materials, edited_chunks_come_back_by_copy, a_loaded_world_restores_its_edited_chunks and sealed_large_brushes_read_the_same_at_their_bake_level (unit).

🤖 Generated with Claude Code

Tuafo and others added 3 commits October 15, 2026 19:11
Replace the tile quadtree with an octree of 64^3 chunk nodes (face,
level, cx, cy, cz). Classification is descent-aware: a coarse node
answers for what its descendants hold (DESCENT margin, cave band from
volume_bounds, brushes at any size, baked-brick index), so rounding,
caves and small digs never hide detail a finer level has.

Residency is a cut of the octree. A footprint's contiguous Mixed band
switches levels as one; rays report the node their pixel wants (one
pixel per 8x8 block per frame) and only keep nodes from refining;
Buried nodes draw as rock until rays or the eye reach them; LRU
eviction runs under slot and leaf pressure. Settled waits for a full
64-frame sweep of ray reports since the last change.

Footprint columns and halos stay on the GPU in a 2048-slot pool across
frames (terrain-only, so edits never invalidate them): 19-22 us a chunk
against 27-30 before.

The old tile, layer, gap and deep-tile paths are deleted. Design,
results and invariants are in docs/chunk-octree.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The intermittent capture outliers (8x8 and 4x8 blocks of haze) are not
an engine race. The publish writes every pixel, yet those blocks of every
G-buffer target and of depth keep the frame's earlier clear. Only NVIDIA's
Vulkan driver (616.64, RTX 3060) shows it: D3D12 never does, and
synchronization validation finds no hazard. Record what does and doesn't
change it, and the report to NVIDIA (bug 6939141).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cells whose material edits set (add, paint, baked cells) keep their own
material: chunk jobs with material edits write each cell's edited material
into a cell_materials slot, and chunk_build gives every leaf page holding
such a leaf a 4 KiB detail page (a byte a cell; 0: the leaf's pair) off a
free stack, returned on eviction. Drawing reads the cell's byte first, at
the hit and in the top face's material sample; a per-slot flag skips the
lookup in chunks without detail pages. PickHit reports the drawn material.

queue_footprint finds a footprint's brushes through a BrushIndex (a loose
grid per face) instead of scanning every brush per chunk, and decides
which chunks fit the batch (detail slots included) before taking a
footprint pool slot.

Tests: edited cells draw their own materials (16306 pixels exact; 14610
wrong without the lookup); the index finds what a scan finds; a report of
natural cells against Planet::material (40.5 % of mountain pixels differ,
38.8 % from the kernel evaluating ground_material at the column's domain
point instead of the cell's volume point, 10.7 % from the leaf rule).
mountain_slope primary rays and generation cost unchanged within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Tuafo Tuafo self-assigned this Oct 16, 2026
@Tuafo Tuafo moved this from Todo to In Progress in Pulsar Tracker Oct 16, 2026
Tuafo and others added 2 commits October 16, 2026 13:57
The generation kernel evaluated ground_material at the column's domain
point and with an in-tile slope; Planet::material (picks, a dig's yield)
at the cell's volume point and with level-4 relief. The noise behind the
snowline and rock patches is unrelated 2 km up, so whole mountain views
drew snow as stone: 40.5 % of natural level-0 pixels at varied spots drew
another material than the canonical one.

Each evaluation now uses its cell's volume point and the surface byte,
and levels 0 to 3 use Planet::material_slope: per computed footprint, a
lattice of level-4 heights (a ring of shared nodes, the heights spread
over workgroups, then the block slopes) kept in the footprint pool.

uniform_leaves_draw_canonical_materials gates it: the top faces of
leaves whose columns all top in them with one material draw the
canonical material exactly (31710 pixels, 779 wrong before). The
natural-cells report now isolates the leaf rule: 9.35 % of pixels, 33 %
in dug walls. Generation +2 us a chunk (levels 1 to 3's lattices);
drawing unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each GPU test built a game's full-size ChunkWorld (131072 chunk slots,
about 2.9 GB of device memory with its device). Eight parallel tests on a
12 GB GPU paged device memory, and the suite crawled or stalled past 25
minutes.

ChunkWorld::with_capacity takes a Capacity (chunk slots, detail pages,
footprint pool slots); new keeps the game default and its environment
variables. Tests use 16384 chunks (the largest view settles with 12901):
1.1 GB a test, the whole suite in 240 s at default threads, peak 5.2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Tuafo Tuafo changed the title Voxel planet: chunk octree, ray-guided residency, per-voxel materials for edits Voxel planet: chunk octree, ray-guided residency, per-voxel materials, material contract Oct 16, 2026
Tuafo and others added 3 commits October 16, 2026 14:47
A leaf's subsurface byte follows its centre's depth, so a third of a
pit's wall pixels drew rock where the cells are soil a cell deep. Every
chunk under edits now gets a cell_materials slot (128 a batch): the
columns kernel writes each cell's edit material and the column's natural
top (scanned over the chunk as terrain::generated_top does), and
chunk_build gives each exposed solid cell without an edit material its
natural Planet::material, clears the cells its leaf's pair draws as they
are (never in a leaf drawn with its mid's material), and gives leaves
with cells left detail pages.

dug_walls_draw_canonical_materials gates it: pits at three varied spots,
all 57050 wall and floor pixels exact (about 170 detail pages a pit). The
natural-cells report: 3.56 % of pixels wrong (9.35 %), none under ground.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's voxel changes were made on the tile-quadtree chunks/mod.rs this
branch replaced (with a rustfmt pass); they are ported onto the octree
files in their style: the renderer's debug mode forwarded to the voxel
shaders (set_debug_mode; local diagnostics in bits 0x200-0x800), the
trace-cost view (mode 32, cost in the visibility word's high bits, the
material in five), the UV reference grid (mode 1). The grid's texture and
sampler take group 1 bindings 25 and 26: 24 is the ray feedback buffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tuafo and others added 5 commits October 16, 2026 15:29
Dug walls' detail pages at every level filled a test world's 2048 after
ten digs 8 m across; levels 0 and 1 (DETAIL_LEVELS) take 368. Coarser
leaves keep their pairs.

edit_cost_against_history (ignored, a report) measures one more edit
after n edits: small digs (baked) cost the same at 10000; large ones
(analytic) cost 3.2 ms of sync and 13 ms a frame at 10000. The docs plan
edits on data from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 10000 large digs (8 m across) one more edit cost 3.8 ms of sync and
4.35 ms of GPU: every edit re-hashed, re-indexed and re-uploaded every
large brush, and every coarse chunk under them evaluated them all.

- Large brushes bake at their bake level (planet::bake_level, the first
  whose cells they span at most 32 of): sealing bakes them there
  (EditStore::bake_at), coarser levels follow by majority, and a coarse
  cell whose children are mostly unchanged keeps its own edit. Below it
  they stay analytic. At the bake level a cell reads as the analytic brush
  did. Snapshots are version 3; older ones bake their large brushes on
  load, under the cells baked after them.
- BrushIndex keeps brushes apart by the levels they apply at, so coarse
  footprints skip the baked ones.
- Incremental sync: the view keeps the large layer's faces and index while
  seals only append to it, diffs the brush lists past their common start,
  writes the brush buffer's tail in place, and extends the brick index by
  a seal's columns.
- A GPU brick cache (64 MB, least recently used reused): regenerated
  chunks send their brick tables, not their bricks again; a uniform brick
  is its table entry.

At 10000 large digs: sync 0.51 ms, update CPU 0.48 ms, generation GPU
0.37 ms (0.34, 0.30, 0.20 at 100).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An edited chunk the plan evicts is packed on the GPU before its pages go
back (pack_main: its meta, mids, each leaf page's leaves, materials and
normals, its detail pages; 12 KB on average) and read back, kept on the
CPU (Capacity::kept_bytes, least recently used dropped). A kept node the
plan wants again is restored by copy (restore_main: new pages off the
free stacks, its result written as a build's), without a footprint, the
terrain program or the brushes over it. An edited chunk that comes out
empty is kept as such: the plan, a few frames behind its results, asked
for dug chunks it had just found empty, each generated again from the
history. Edits over kept nodes drop them.

edited_chunks_come_back_by_copy: a site under 2000 large digs, left and
seen again: 1041 edited chunks restored and none generated (534 without
keeping), 58 ms of generation GPU against 308, every pixel as before; an
edit over restored chunks draws exactly, and comes back with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before saving, ChunkWorld::keep_resident packs the resident edited chunks
too, and kept_snapshot writes every kept chunk: a header (a fingerprint
of the generation shaders, terrain constants and world, and the history's
length and hash) and the packs deflated. Planet::snapshot_with_chunks
carries them as an opaque section of the edit snapshot (version 4);
from_snapshot_with_chunks gives them back and load_kept keeps them in a
new world for exactly that history, so it restores what the history made
instead of generating it.

a_loaded_world_restores_its_edited_chunks: the dug site saved (313
chunks, 316 KiB besides the snapshot's 260) and loaded: 1172 edited
chunks restored, none generated (313 without them), 90 ms of generation
GPU against 234, every pixel as saved; another history is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kept chunks are saved beside the edit snapshot, not in it: a host folds
only its oldest edits into a snapshot (Pulsar lists its newest 4096), and
the chunks are of every edit. The snapshot's version 4 chunk section goes.

ChunkWorld::kept_chunks takes the kept chunks sharing their packs (cheap
on the render thread); KeptChunks::to_bytes deflates them on the saving
thread and from_bytes reads them back; load_kept takes them, for exactly
their history.

PlanetFrame gains kept (chunks for a world built for its planet;
ChunkPass::kept_loaded says how many, or why they were refused) and
export (KeptExport: a save asks, the pass packs the resident edited chunks
over the next frames, asking for frames meanwhile, and answers with the
planet shown and its kept chunks).

VoxelEditJournal keeps a renderer's chunks of its edits
(VoxelKeptChunks: the bytes and the journal length and hash they are for),
saved as "kept" and dropped by any change of the edits.

saved_edited_chunks_come_back_through_the_frame: 111 chunks exported in 6
frames; loaded, 84 edited chunks restored and none generated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tuafo added a commit to Far-Beyond-Pulsar/Pulsar-Native that referenced this pull request Oct 17, 2026
A level save now keeps the voxel planet renderer's chunks of the terrain's
edits with its journal, so a loaded level copies what the edits made
instead of generating it from the terrain and every edit.

- HelioRenderer owns a VoxelChunkSaves (request/answer, condvar), given to
  the planet backend and to the editor through HelioEditorMailbox; the
  level editor keeps it in SceneDomain.voxel_chunk_saves.
- save_blocking, after compacting the journals, asks for the chunks when a
  terrain with edits keeps none (keep_voxel_chunks, waiting up to 5 s);
  the backend has its pass pack them (PlanetFrame.export), answers for the
  journal its shown planet was built from, and the save deflates them off
  the render thread and keeps them only while the journal is unchanged.
- A rebuilt world parses the journal's kept chunks on the world builder
  thread and passes them in PlanetFrame.kept; the pass loads them into
  the new world.

Helio 1dcc418f (Far-Beyond-Pulsar/Helio#353): kept chunks travel with the
journal and the frame, not the edit snapshot.

Tests: voxel_kept_chunks (90 chunks, 105 KiB, answered in 7 frames; a new
renderer loads all 90) and a_level_save_keeps_the_renderers_voxel_chunks
(the editor's save while the viewport draws; the level loads with them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant