The scene graph
A roxlap world is a Scene: a sparse
set of grids, each an unbounded voxel volume with its own f64
world placement. This chapter covers the whole life of that world —
placing grids, editing voxels, saving, and streaming.
Every snippet comes from a runnable, assertion-checked example:
cargo run -p roxlap-scene --example book_scene_graph
The mental model
Three layers, top to bottom:
Scene— owns the grids, hands out stableGridIds, answers world-level queries (raycast,resolve_voxel— chapter 11).Grid— one voxel volume: aGridTransform(f64 origin + f64 quaternion) plus a sparse map of chunks. A missing chunk is air — nothing is stored for empty space, and a grid has no intrinsic bounds.- Chunks — 128×128×256 blocks in Voxlap’s column-compressed slab format. You never address them directly: edits and queries take grid-local voxel coordinates and the decomposition is automatic. Inserting into empty space materialises the touched chunks; carving air is a no-op.
Each grid carries an f64 origin + quaternion and a voxel_world_size
(world units per voxel — Grid scale below); the classic use
is a static “world” grid plus a handful of dynamic object grids:
let mut scene = Scene::new();
// A static "world" grid at the origin…
let ground_id = scene.add_grid(GridTransform::at(DVec3::ZERO));
// …and a second grid placed and rotated independently — its own
// f64 world position + quaternion, same chunked voxel payload.
let ship_id = scene.add_grid(GridTransform {
origin: DVec3::new(300.0, -80.0, -40.0),
rotation: DQuat::from_rotation_z(0.5),
voxel_world_size: 1.0,
});
// Object grids should not paint their own (grid-local, rotating)
// sky — leave the sky to the world grid.
scene.grid_mut(ship_id).expect("just added").render_sky = false;
render_sky = false on object grids matters more than it looks: sky
pixels are rendered in each grid’s local frame, so a rotating ship
that paints its own sky visibly fights the world’s sky. One grid owns
the sky; the rest opt out.
Grid scale
Every grid also has a voxel_world_size — how many world units one
voxel spans. The default 1.0 is the classic 1:1; GridTransform::at_scale
sets it. This lets a coarse planet grid (4.0, big chunky voxels) and a
finely detailed ship grid (0.25, small smooth voxels) share one scene
at their true relative sizes — a 16× ratio — without either grid changing
its voxel budget.
// Per-grid scale: `voxel_world_size` is world units per voxel. A grid
// built with `at_scale(origin, 2.0)` has voxels twice world size — a
// coarse "planet" — while `0.25` gives a fine detail grid; both coexist
// at their true relative sizes. Only the world↔grid boundary scales:
// edits + queries stay in voxels, unchanged.
let mut scaled = Scene::new();
let planet = scaled.add_grid(GridTransform::at_scale(DVec3::ZERO, 2.0));
scaled
.grid_mut(planet)
.expect("just added")
// Same voxel edit API — grid-local coordinates.
.set_voxel(IVec3::new(5, 5, 10), Some(STONE));
// Grid-local voxel z=10 sits at WORLD z = 20 (10 voxels × 2.0). A world
// raycast down that column reports the grid-local voxel it hit AND a
// WORLD-space `t` — so hits across grids of different scale compare
// correctly (the raycaster marches voxels but returns world distance).
let hit = scaled
.raycast(DVec3::new(11.0, 11.0, 0.0), DVec3::new(0.0, 0.0, 1.0), 64.0)
.expect("ray hits the scaled voxel");
assert_eq!(hit.voxel, IVec3::new(5, 5, 10)); // grid-local, unscaled
assert!((hit.t - 20.0).abs() < 1e-4); // WORLD distance: 10 voxels × 2.0
The design keeps it cheap: scale is applied only at the world↔grid-local
boundary. Every marcher, sampler, edit and bake below that boundary still
works in plain voxels — so set_voxel / voxel_solid are unchanged, and a
grid’s own geometry doesn’t care about its scale. What does change is
world-space: a raycast marches the grid’s voxels but returns a world
t, so hits across grids of different scale compare correctly; shadows,
collision, streaming radii and LOD all resolve in world units too. The Scale
demo tab shows it live — a fine ship casting a hard cross-grid sun shadow
onto the coarse planet, on both backends.
Scale survives a save: save_snapshot persists voxel_world_size (wire
version 2), and older v1 snapshots load as unscaled 1.0.
Editing voxels
The core edit API is three methods, one convention: Some(colour)
inserts solid voxels, None carves to air.
let ground = scene.grid_mut(ground_id).expect("just added");
// Solid slab: grid-local voxel coords, inclusive on both ends,
// decomposed across as many chunks as it spans (here 4×4).
ground.set_rect(
IVec3::new(-200, -200, 200),
IVec3::new(199, 199, 254),
Some(GRASS),
);
// `Some(colour)` inserts, `None` carves back to air.
ground.set_sphere(IVec3::new(0, 0, 195), 12, Some(STONE));
ground.set_rect(IVec3::new(-3, -200, 190), IVec3::new(3, 199, 196), None);
set_voxel(pos, colour)— one voxel.set_rect(lo, hi, colour)— an axis-aligned box, inclusive on both ends, any corner order.set_sphere(centre, radius, colour)— Euclidean ball.
All three take grid-local coordinates and may span any number of
chunks. They are also the engine’s runtime carving path — the cave
demo’s plasma bullets and the particle system’s debris craters
(chapter 8) are set_sphere(.., None) at heart.
Cheap point queries pair with them:
// Solid tests: inside the slab, then inside the carved tunnel.
assert!(ground.voxel_solid(IVec3::new(0, 0, 210)));
assert!(!ground.voxel_solid(IVec3::new(0, 100, 193)));
// Colour queries answer on surface voxels; deep interior cells
// are untextured in the slab format and read as `None`.
assert_eq!(ground.voxel_color(IVec3::new(50, 50, 200)), Some(GRASS));
The recolour gotcha
The slab format stores surfaces, and insertion fills air — so inserting a new colour over already-solid voxels changes nothing:
// GOTCHA: inserting over already-solid voxels does NOT repaint
// them — span insertion only fills air. Recolour = carve, then
// insert.
let (lo, hi) = (IVec3::new(20, 20, 200), IVec3::new(24, 24, 204));
ground.set_rect(lo, hi, Some(RED));
assert_eq!(ground.voxel_color(IVec3::new(22, 22, 200)), Some(GRASS)); // unchanged!
ground.set_rect(lo, hi, None); // carve…
ground.set_rect(lo, hi, Some(RED)); // …then insert
assert_eq!(ground.voxel_color(IVec3::new(22, 22, 200)), Some(RED));
This is not a rare corner: “paint that wall red” is exactly this operation. Remember the idiom — recolour = carve, then insert.
Colour callbacks
A carve exposes interior walls the artist never coloured; the plain
edits paint them black. The _with_colfunc variants
(set_rect_with_colfunc / set_sphere_with_colfunc, with
SpanOp::Carve or SpanOp::Insert) ask a closure for every touched
voxel instead — position-dependent colour, jitter, texture lookups:
// A carve exposes fresh interior walls; the plain edits paint
// them colour 0 (black). The `_with_colfunc` variants ask a
// closure instead — it sees grid-local coordinates, so gradients
// stay continuous across chunk seams. Here: a crater whose walls
// darken with depth.
ground.set_sphere_with_colfunc(IVec3::new(60, 60, 200), 8, SpanOp::Carve, |_x, _y, z| {
let depth = (z - 192).clamp(0, 15) as u8;
VoxColor::rgb(0x6b, 0x40 + depth * 3, 0x2f)
});
The closure receives grid-local coordinates (not chunk-local), so
a gradient stays continuous across chunk seams. This is roxlap’s
answer to Voxlap’s vx5.colfunc global — as a parameter instead of
engine state.
One more thing edits do implicitly: each write bumps the touched
chunk’s version counter and dirty extent, which is how the renderers
(and the streaming persistence below) know what changed. You don’t
manage that — but after bulk terrain edits you will want to re-bake
lighting (Grid::bake, chapter 6).
Snapshots
Scene::save_snapshot() serialises the whole scene to bytes —
chunks, transforms, per-grid config (sky/LOD/streaming settings) and
edit-version counters — inside a versioned envelope, so a newer
engine refuses (rather than misreads) a snapshot format it doesn’t
know. Scene::load_snapshot(&bytes) restores it:
// Name grids before saving: ids survive the round-trip, but the
// generator / store hooks are host code and cannot be serialised
// — the name is your key for rebinding them after a load.
scene.grid_mut(ground_id).expect("ground exists").name = Some("ground".into());
let bytes = scene.save_snapshot(); // versioned envelope
let restored = Scene::load_snapshot(&bytes).expect("self-authored snapshot");
assert_eq!(restored.grid_count(), 2);
let (_, ground2) = restored
.grids()
.find(|(_, g)| g.name.as_deref() == Some("ground"))
.expect("rebind by name");
assert_eq!(ground2.voxel_color(IVec3::new(22, 22, 200)), Some(RED));
Two things a snapshot cannot carry:
- Host hooks. Generators and chunk stores (below) are your code —
rebind them after loading, keyed on
Grid::name. That is what thenamefield exists for. - Store-only chunks. Only materialised chunks are serialised; a
streamed-out edited chunk lives in your
ChunkStore, which you persist alongside the snapshot.
(If you want your own on-disk format instead of the envelope,
Scene::to_snapshot() / Scene::from_snapshot() expose the plain
serde value underneath.)
Streaming & procedural generation
For worlds bigger than memory — or generated on the fly — a grid can
stream: you attach a ChunkGenerator and a StreamRadius, then
pump once per frame with the camera’s world position.
/// A deterministic floor generator: chunk layer `z = 0` gets a solid
/// slab, checkerboarded per chunk so streamed tiles are visible;
/// every other layer is left as air. `generate` must be a pure
/// function of `chunk_idx` (plus the generator's own config) — that
/// determinism is what makes evict + re-stream sound.
#[derive(Debug)]
struct FloorGenerator;
impl ChunkGenerator for FloorGenerator {
fn generate(&self, chunk_idx: IVec3) -> Vxl {
// `Vxl::empty(CHUNK_SIZE_XY)` is the canonical all-air chunk
// shape — the same one `Grid::ensure_chunk` materialises.
let mut vxl = Vxl::empty(CHUNK_SIZE_XY);
if chunk_idx.z == 0 {
let light = (chunk_idx.x + chunk_idx.y).rem_euclid(2) == 0;
let color = if light {
VoxColor::rgb(0x74, 0x9e, 0x58)
} else {
VoxColor::rgb(0x5d, 0x84, 0x46)
};
roxlap_formats::edit::set_rect(&mut vxl, [0, 0, 240], [127, 127, 255], Some(color));
}
vxl
}
}
The contract that makes streaming sound: generate is a
deterministic function of the chunk index (plus the generator’s
own config). Evicting a pristine chunk is then lossless — walking back
regenerates the identical bytes. (A generator can also decline indices
via should_generate when whole layers have no content.)
// Streaming: attach a generator + radii to a grid, then pump once
// per frame with the camera's world position. Chunks within
// `r_active` stream in; chunks beyond `r_evict` drop out; the band
// between is hysteresis so a camera hovering on a boundary
// doesn't thrash.
let stream_id = scene.add_grid(GridTransform::at(DVec3::new(0.0, 4096.0, 0.0)));
let store = Arc::new(MemoryStore::default());
let grid = scene.grid_mut(stream_id).expect("just added");
grid.set_generator(Some(Arc::new(FloorGenerator)));
grid.set_chunk_store(Some(store));
grid.stream_radius = StreamRadius::new(256.0, 384.0);
let camera_near = DVec3::new(0.0, 4096.0, 100.0);
scene.pump_streaming_sync(camera_near); // real games: `pump_streaming`
let grid = scene.grid(stream_id).expect("still there");
assert!(grid.chunk_count() > 0); // floor streamed in around the camera
assert!(grid.voxel_solid(IVec3::new(5, 5, 240)));
The radii semantics: chunks with AABB distance ≤ r_active from the
camera (grid-local voxel units) must be loaded; chunks beyond
r_evict are dropped; the band between is hysteresis — untouched in
either direction — so a camera hovering at a boundary doesn’t thrash
generate/evict cycles.
The example pumps pump_streaming_sync, which generates inline —
deterministic, good for tools and tests. Games call
pump_streaming, which dispatches generation onto a background
rayon pool and installs finished chunks on later pumps (frame-rate
stays smooth while terrain builds); Scene::set_streaming_threads(n)
bounds the pool.
Persisting edits with ChunkStore
Determinism covers pristine chunks. An edited chunk — the player
dug a hole — would silently revert to generator output on
evict + re-stream. A ChunkStore closes that hole: eviction hands
every edited chunk (version ≠ 0) to store, and stream-in consults
load before the generator (a stored chunk always wins):
/// Minimal in-memory [`ChunkStore`]: edited chunks handed over at
/// eviction, returned on stream-in (so player edits survive walking
/// away). A real game would write a save-file region instead.
#[derive(Debug, Default)]
struct MemoryStore {
chunks: Mutex<HashMap<IVec3, (Vxl, u64)>>,
}
impl ChunkStore for MemoryStore {
fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64) {
let entry = (vxl.clone(), version);
self.chunks
.lock()
.expect("store poisoned")
.insert(chunk_idx, entry);
}
fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)> {
self.chunks
.lock()
.expect("store poisoned")
.get(&chunk_idx)
.cloned()
}
}
// Edit the streamed floor, walk far away, come back. The active
// set follows the camera: the old region evicts (edited chunks
// are handed to the ChunkStore first) while a fresh set streams
// in around the new position. On return the store wins over the
// generator, so the edit survives; without a store, eviction
// would silently revert the chunk to generator output.
scene
.grid_mut(stream_id)
.expect("still there")
.set_voxel(IVec3::new(5, 5, 240), None); // dig a hole
scene.pump_streaming_sync(camera_near + DVec3::new(0.0, 100_000.0, 0.0));
let grid = scene.grid(stream_id).expect("still there");
assert!(grid.chunk(IVec3::ZERO).is_none()); // home chunk evicted…
assert!(grid.chunk_count() > 0); // …but the far region streamed in
scene.pump_streaming_sync(camera_near);
let grid = scene.grid(stream_id).expect("re-streamed");
// The hole survived evict + re-stream.
assert!(!grid.voxel_solid(IVec3::new(5, 5, 240)));
store runs inline during eviction — keep it cheap (queue bytes for
a writer thread). load may block under pump_streaming (it runs on
the background pool) but runs inline under the synchronous paths.
Walking on the world: the character controller
Everything above edits and streams the world; CharacterBody is how
a player stands on it — an engine-owned walking body with
move-and-slide collision, gravity, jumping, auto step-up and the
demos’ fly camera, all headless (it needs a Scene and nothing
else). The snippets come from a runnable, assertion-checked example:
cargo run -p roxlap-scene --example book_controller
// A walking body: a feet-positioned collision box, f64 world.
// Distances are voxels, times seconds; +z is DOWN, so gravity is
// positive and a jump impulse negative.
let mut body = CharacterBody::new(CharacterDef {
radius: 0.4, // xy half-extent of the box
height: 1.8, // feet → head (toward -z)
eye_height: 1.62, // feet → camera anchor
step_up: 1.05, // auto-step ledges up to 1 voxel
solidity: Solidity {
bedrock_blocks: false, // match your renderer's policy
passable: Some(water_passes),
},
..CharacterDef::default()
});
body.teleport(DVec3::new(50.0, 50.0, 95.0)); // FEET position
The body is a feet-positioned box: pos.z is the feet, the head is
at pos.z - height (chapter 2: +z is DOWN — gravity is positive, a
jump impulse negative, and getting a sign wrong compiles fine and
runs upside down). All positions are f64 world space, so the body
composes with GridTransform placement and the f64 camera.
// Per frame: build a wish direction from input (unit-length or
// zero — walk() clamps), call walk() EVERY frame (a zero wish is
// what stops the body), then anchor the camera at the eye.
let dt = 1.0 / 60.0;
for frame in 0..480 {
let input = WalkInput {
wish: DVec3::new(1.0, 0.2, 0.0), // toward the ledge
jump: frame == 120, // one hop on the way
..WalkInput::default() // sink (WT.1) + future fields
};
body.walk(&scene, dt, input);
}
let eye = body.eye_pos(); // = Camera::from_yaw_pitch(eye.into(), yaw, pitch)
The per-frame contract in that loop:
- Call
walk()every frame, input or not — the zero wish is what stops the body. Skipping idle frames leaves stale velocity (the classic symptom: every key briefly moves you the way you were already going). - Movement is substepped so fast bodies never tunnel through thin
floors, slides clamp flush against the blocking voxel plane, and a
grounded body auto-steps ledges up to
step_upvoxels. - Jumps are press-buffered and coyote-timed (the small grace windows
live in
CharacterDef);on_ground()/hit_head()report contact. MoveMode::{Walk, Fly, Noclip}switches grounded movement, the sliding fly camera (no gravity, instant start/stop), and probe-free ghosting. The scene demo’s World tab bindsGandCto walk mode and a third-person view over exactly this API.
Everything is deterministic — pure f64, no RNG — so the same scene and input sequence replays the same trajectory, and gameplay code is unit-testable without a renderer.
What counts as solid
Collision reads the same voxel world the renderer draws, through a
Solidity policy: the voxlap bedrock placeholder plane is a knob
(bedrock_blocks — match it to how you render that plane, or you
get invisible walls / fall-through floors), and an optional colour
veto makes chosen voxels passable:
/// Colour veto for the collision probe: voxels this colour are
/// passable (water, foliage, ladders); everything else blocks. A
/// plain `fn`, keyed like a material map — compare `.rgb_part()`,
/// the brightness byte belongs to the lighting bake.
const WATER: VoxColor = VoxColor::rgb(0x30, 0x60, 0xc8);
fn water_passes(c: VoxColor) -> bool {
c.rgb_part() == WATER.rgb_part()
}
Two voxlap-format facts constrain pass-through geometry (both pinned as engine tests): only voxels with a stored colour can be vetoed, so water curtains work up to 2 voxels thick (thicker slabs grow a colourless hidden core that always blocks) and must sit at least 1 voxel inside the chunk (edge voxels lose their side colours — the encoder treats out-of-chunk neighbours as solid). The Transparency demo’s glass/water wall is the visual version: the water half lets you through, the glass half does not.
Water & swimming
Deep water is exactly what the colour veto cannot do (the 2-voxel limit above), so the engine splits water in two (stage WT):
- Physics —
WaterVolumes on the grid: grid-local voxel AABBs whose top face (min z — +z is down) is the surface.Scene:: water_depth_at/in_wateranswer in world units under any grid transform, and aWalk-modeCharacterBodysubmerged pastswim_enter_fracof its height swims automatically: buoyancy against gravity floats it bobbing at the surface,WalkInput::jumpstrokes up (and breaches into a real jump when the head is above water — one-shot until released), the newWalkInput::sinkdives. The passive water forces (buoyancy + drag, scaled by submersion) apply to wading walkers too — the continuity is what keeps the waterline state stable for any tuning. Volumes persist in snapshots (wire v3). - Visuals — ordinary voxels: a 1–2-voxel volumetric-material surface shell where air crosses the waterline (grazing views accumulate thickness and go opaque — a cheap Fresnel; a solid fill would merge into the surrounding slabs and drop every submerged surface’s colours). The shell is itself the veto’s textbook case: colour-key it passable and bodies, bullets and debris cross it.
// Physics water is a WaterVolume on the grid — a grid-local voxel
// AABB whose TOP face (min z — +z is down) is the surface. It is
// independent of the voxels: fill the same region with a
// volumetric-material shell for the visuals, or don't.
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(id).expect("grid");
// Basin floor, then 20 voxels of water above it (surface z = 100).
g.set_rect(
IVec3::new(20, 20, 120),
IVec3::new(140, 140, 130),
Some(VoxColor::rgb(0x50, 0x90, 0x50)),
);
g.add_water_volume(IVec3::new(20, 20, 100), IVec3::new(140, 140, 119));
// A Walk-mode body dropped in swims AUTOMATICALLY once submerged
// past `swim_enter_frac` of its height: buoyancy floats it to a
// bob at the surface, `jump` strokes up, `sink` strokes down, and
// a jump with the head above water breaches.
let mut body = CharacterBody::new(CharacterDef::default());
body.teleport(DVec3::new(80.0, 80.0, 115.0)); // deep in the pool
for _ in 0..600 {
body.walk(&scene, 1.0 / 60.0, WalkInput::default());
}
assert!(body.is_swimming(), "floated to the surface bob");
assert!(!body.eye_in_water(&scene), "bobbing: the camera is dry");
// Dive: hold `sink`. The submerged EYE is the hook for the
// underwater feel — drive the WT.2 frame tint and the WT.3
// listener lowpass from this one flag.
for _ in 0..90 {
body.walk(
&scene,
1.0 / 60.0,
WalkInput {
sink: true,
..WalkInput::default()
},
);
}
assert!(body.eye_in_water(&scene), "diving: tint + muffle now");
eye_in_water is the hook for the underwater feel: drive the
render facade’s full-screen Tint and the audio backend’s listener
lowpass (AudioOut::set_listener_lowpass) from that one flag — see
the Lighting & materials and Audio
chapters. The cave demo (native and web) is the worked example:
V to walk, wade in, dive for the crystals.
Further reading
- docs.rs/roxlap-scene — the full API, including the world queries deferred to chapter 11.
PORTING-SCENE.md— why the scene graph is built this way (the S1–S7 design history).