Foreword
This book teaches roxlap — a pure-Rust voxel-scene engine descended from Ken Silverman’s Voxlap — to a game developer who has never seen the codebase. It sits between the README (the pitch and a 40-line quickstart) and the docs.rs API reference (every type and function): the README tells you whether you want roxlap, docs.rs tells you what each item does, and this book tells you how the pieces fit together and how to build a game with them.
Three other kinds of documentation exist and are linked from here where they help, but are not replaced by the book:
- docs.rs — the API reference. The book prefers linking a type over restating its signature, so the reference is always the fresher of the two.
docs/porting/PORTING-*.md— internal stage histories: design rationale, dead ends, benchmarks. Read them when you want to know why something is built the way it is.- The demo scenes (
roxlap-scene-demo) — the runnable feature gallery. Every chapter’s topic has a demo tab you can fly around in; the demo tour maps features to scenes.
Code in this book is never pasted by hand: every snippet longer than a few lines is included directly from a compile-tested crate example or doctest, so if the book and the engine disagree, CI is already red.
The book tracks the engine: chapters are re-checked as stages land,
and every include is CI-gated, so a snippet that stops compiling turns
the build red before it can mislead you. How the book itself was built
(and its anti-rot policy) is recorded in
docs/porting/PORTING-BOOK.md.
Introduction & quickstart
roxlap is a voxel-scene engine in pure Rust. You describe a world as
voxel grids — carve, fill, stream, and animate them at runtime — and
one renderer facade draws it either on the CPU (a per-pixel 3D-DDA
raycaster that runs anywhere, no GPU required) or on the GPU (a
WGPU compute-shader marcher, same retro look at much higher frame
rates), falling back from one to the other automatically. The engine
reads Ken Silverman’s Voxlap asset formats (.vxl worlds, .kv6
sprites, .kfa animation rigs), so two decades of existing voxel
assets load directly.
This chapter gets a window on screen with a voxel world in it. By the end you will have seen the whole per-frame contract — everything after this is elaboration.
The three crates you talk to
roxlap is a Cargo workspace of small crates, but a game depends on three:
roxlap-render— the facade. OneSceneRenderertype that owns the backend choice (CPU or GPU), presentation, sprites, picking, and the post pipeline. Your game calls this.roxlap-scene— the world.Sceneholds many independently-placed chunked voxelGrids in one f64 world, with edits, streaming, and snapshots.roxlap-core— theCameraand the per-frame render settings.
Everything else (roxlap-formats, roxlap-gpu, …) arrives
transitively. Add to your Cargo.toml:
[dependencies]
roxlap-render = "0.22" # SceneRenderer — one renderer over CPU + GPU
roxlap-scene = "0.22" # Scene / Grid / edits / streaming
roxlap-core = "0.22" # Camera + per-frame render settings
glam = "0.30"
A minimal application
The complete program below ships as a compile-tested example —
crates/roxlap-render/examples/quickstart.rs
(~160 lines: a winit window, an event loop, and a slow orbit camera).
Run it first, then read the walkthrough:
cargo run --release -p roxlap-render --example quickstart
ROXLAP_GPU=0 cargo run --release -p roxlap-render --example quickstart # force CPU
Every snippet in this section is included verbatim from that file, so it cannot drift from what actually compiles.
Colours
/// [`VoxColor`] packs a voxel colour: RGB + a brightness byte (NOT
/// alpha; `rgb()` uses the neutral 0x80 — lighting bakes rewrite it).
const GRASS: VoxColor = VoxColor::rgb(0x4d, 0x8a, 0x3a);
const DOME: VoxColor = VoxColor::rgb(0x40, 0x60, 0xc0);
/// Sky/fog/tints are plain [`Rgb`] — no packing surprises.
const SKY: Rgb = Rgb::new(0x8f, 0xbc, 0xd4);
Two conventions to absorb immediately (both inherited from Voxlap, both covered properly in Concepts & conventions):
- Voxel colours are
VoxColors — RGB plus a brightness byte (not alpha!).VoxColor::rgb(r, g, b)packs at the neutral0x80. - Sky/fog/tint colours are plain
Rgb— a different type, so mixing the packings is a compile error.
Building a scene
/// A one-grid scene. Grid-local voxel coordinates; **+z is down**, so
/// the ground surface at `z = 210` fills downward toward the bedrock
/// placeholder at `z = 255`, and "up" is toward smaller z.
fn build_scene() -> Scene {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
let grid = scene.grid_mut(id).expect("grid just added");
grid.set_rect(
IVec3::new(-128, -128, 210),
IVec3::new(127, 127, 254),
Some(GRASS),
);
grid.set_sphere(IVec3::new(0, 0, 205), 30, Some(DOME));
scene
}
A Scene is a set of grids; each Grid is an unbounded chunked voxel
volume placed in the world by a GridTransform (f64 position +
quaternion rotation). Here one grid at the origin gets a solid ground
slab (set_rect) and a dome (set_sphere).
The third convention, and the one that trips everyone: +z points
DOWN. The ground surface sits at z = 210 and fills downward to
z = 254; the dome centre at z = 205 is above the ground. Voxlap
kept screen-y and world-z aligned, and roxlap keeps Voxlap’s
convention so assets and math port unchanged.
Creating the renderer
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_title("roxlap quickstart"))
.expect("create window"),
);
let size = window.inner_size();
// QE.7b - BackendPreference replaces want_gpu; the demos prefer
// GPU with automatic CPU fallback.
let backend = if std::env::var_os("ROXLAP_GPU").is_none_or(|v| v != "0") {
BackendPreference::PreferGpu
} else {
BackendPreference::Cpu
};
let opts = RenderOptions {
backend,
clear_sky: SKY,
..RenderOptions::default()
};
self.renderer = Some(SceneRenderer::new(
window.clone(),
(size.width, size.height),
&opts,
));
self.window = Some(window);
self.scene = Some(build_scene());
self.started = Some(Instant::now());
}
SceneRenderer::new takes anything implementing raw-window-handle —
winit here, but SDL or your own windowing works the same — plus the
surface size and RenderOptions. BackendPreference::PreferGpu asks
for the GPU compute backend and falls back to the CPU renderer
automatically when WGPU init fails, so the same binary runs on a
machine with no usable GPU. (The renderer field is declared before the
window in the struct so it drops first — the surface must release its
window handles while the window is still alive.)
Rendering a frame
/// One frame: orbit the camera, render the scene, present.
fn render_frame(renderer: &mut SceneRenderer, scene: &mut Scene, window: &Window, elapsed: f64) {
// `Camera::orbit` / `from_yaw_pitch` / `look_at` produce the
// canonical right-handed basis the sprite frustum cull needs —
// don't hand-roll one.
let camera = Camera::orbit(elapsed * 0.3, 0.35, 220.0, [0.0, 0.0, 195.0]);
let size = window.inner_size();
let settings = OpticastSettings::for_oracle_framebuffer(size.width.max(1), size.height.max(1));
// Defaults for everything but the sky; the GPU projection is
// derived from `settings`, so both backends show the same field
// of view.
let mut frame = FrameParams::new(&settings);
frame.sky_color = SKY;
frame.fog_color = SKY;
renderer.render(scene, &camera, &frame);
renderer.present(); // render() composites; present() finishes
}
The per-frame contract:
- Build a
Camera. Use the constructors (orbit,from_yaw_pitch,look_at) — they produce the right-handed basis the engine’s frustum culling expects. - Build
FrameParamsfromOpticastSettingsfor the current surface size, then override what you need (sky and fog colour here). renderdraws the scene into the backend’s target;presentputs it on screen. They are separate calls so you can draw overlays or an egui HUD between them — see Rendering & backends.
Teardown
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
// Drain in-flight GPU work before surface/window teardown so
// quitting never yanks the swapchain mid-submission.
if let Some(renderer) = self.renderer.as_mut() {
renderer.wait_idle();
}
}
Call wait_idle before the window is torn down so quitting never
yanks the swapchain out from under in-flight GPU work.
Explore the demos
The engine’s feature gallery is roxlap-scene-demo — eleven scenes
behind a menu (World, Sprites, Animation, Transparency, Lighting,
Spotlight, Particles, Doom, Picking, Primitives, Empty):
cargo run --release -p roxlap-scene-demo # CPU
ROXLAP_GPU=1 cargo run --release -p roxlap-scene-demo # GPU backend
ROXLAP_SCENE=Lighting cargo run --release -p roxlap-scene-demo # jump to a tab
The demo tour chapter maps each scene to the features it demonstrates; each topic chapter uses its scene as the worked example.
Where next
- Concepts & conventions — coordinate system, colour packing, units, camera basis: the five facts that make everything else make sense. Read this before writing real code.
- The scene graph — grids, chunks, edits, streaming.
- Rendering & backends — the facade in full.
Concepts & conventions
Five conventions run through every roxlap API. They are all inherited
from Voxlap — that heritage is what lets twenty years of .vxl /
.kv6 assets load unchanged — and three of them are backwards from
what most 3D engines taught you. Absorb this chapter before writing
real code; everything else in the book assumes it.
The snippets here come from a runnable, assertion-checked example:
cargo run -p roxlap-core --example book_conventions
+z is down
The world axes are x = east, y = north, and z pointing down
into the map. Consequences you will hit immediately:
- “Up” is toward smaller z. A tower is built by decreasing z; a mine shaft by increasing it.
- Within a chunk, z runs
0..256:z = 0is the top (sky),z = 255the bottom (bedrock). Ground surfaces typically sit somewhere in the 200s, filling downward — that is why the quickstart’s grass slab isset_rect(.., z=210 ..= z=254)with the dome above it atz = 205. - Positive camera pitch aims downward:
// +z points DOWN. Positive pitch aims the camera downward (the
// forward axis gains a positive z component); "up" in the world
// is toward smaller z.
// A camera pitched down has forward pointing at +z:
let cam = Camera::from_yaw_pitch([0.0, 0.0, 128.0], 0.0, 0.4);
assert!(cam.forward[2] > 0.0);
The horizontal mirror
Physically, the triple (east, north, up) is left-handed, so a faithfully projected view is horizontally mirrored relative to a real camera at the same heading — your geometric right lands on screen-left. This is Voxlap’s native projection, reproduced deliberately (it is baked into the bit-exact reference goldens); it is not a bug, and most games never notice it.
If your project needs an un-mirrored world, do not negate the
camera’s right vector — that breaks basis chirality (next section)
and silently culls every sprite. Fix it on the consumer side instead:
mirror one world axis when you place content, or negate your yaw
input. The full reasoning lives in the
roxlap-core crate docs
(“World handedness and the horizontal mirror”).
One voxel = one world unit
There is no separate voxel-size parameter: world coordinates are voxel
coordinates, scaled 1:1 (a grid’s placement can translate and rotate
them, but not scale — see the scene graph). Grids
store voxels in chunks of 128×128×256 (CHUNK_SIZE_XY /
CHUNK_SIZE_Z); you address voxels in grid-local integer coordinates
and the chunk decomposition is invisible to you.
The colour family: VoxColor, Rgb, OverlayColor
roxlap inherited three distinct colour packings from Voxlap, and each is its own newtype — mixing them up is a compile error rather than a visual bug:
VoxColor— voxel colours. RGB plus a brightness byte in the high bits (NOT alpha!):VoxColor::rgb(r, g, b)uses the neutral0x80, and lighting bakes (chapter 6) rewrite the byte per voxel — ambient and AO live there. Translucency is a material property, never a colour channel.Rgb— plain colour: sprite/particle tints, the sky / fog / clear colours, and colour→material map keys.OverlayColor— the one packing with real alpha, used only by the overlay-line API (Line3).
All three are transparent wrappers over the wire u32 (the .0
field), so file formats and GPU buffers are unaffected:
fn packed_colors() {
// The QE-B6 colour family: three packings, three types — mixing
// them up is a compile error, not an over-bright voxel.
// VoxColor = RGB + a brightness byte (NOT alpha; 0x80 = neutral,
// lighting bakes rewrite it per voxel — see the lighting chapter).
let grass = VoxColor::rgb(0x4d, 0x8a, 0x3a);
assert_eq!(grass.0, 0x80_4d_8a_3a); // .0 is the raw wire word
assert_eq!(grass.with_brightness(0xff).0, 0xff_4d_8a_3a);
// Rgb = plain colour: tints, sky/fog, material-map keys.
assert_eq!(Rgb::new(0x8f, 0xbc, 0xd4).0, 0x00_8f_bc_d4);
// OverlayColor = REAL alpha — only the overlay-line API uses it.
assert_eq!(OverlayColor::rgba(0xff, 0xd0, 0x40, 0x80).0, 0x80_ff_d0_40);
}
The camera basis — and the chirality footgun
A Camera is a position plus three
orthonormal f64 axes: right, down, forward (Voxlap’s
ist/ihe/ifo). The engine requires the right-handed relation
right × down = +forward. The renderer will happily draw terrain
under a basis with the opposite chirality — but the sprite frustum
cull rejects everything, which presents as “my sprites disappeared”
with no error anywhere.
The rule that keeps you safe: always build cameras with the
constructors — Camera::from_yaw_pitch, Camera::orbit,
Camera::look_at. Never hand-roll the axes, and never build a camera
by rotating Camera::default() — the default is a .vxl-header
placeholder whose basis is left-handed:
// The canonical constructors (`from_yaw_pitch` / `orbit` /
// `look_at`) produce the right-handed basis the engine requires:
// right × down == +forward. The sprite frustum cull depends on
// this chirality — a hand-rolled basis that gets it backwards
// renders the terrain fine and silently culls every sprite.
let cam = Camera::from_yaw_pitch([0.0, 0.0, 128.0], 0.6, 0.2);
assert!(approx_eq(cross(cam.right, cam.down), cam.forward));
// `Camera::default()` is the trap: its placeholder basis (from
// the .vxl header convention) is LEFT-handed. Never build an
// interactive camera by rotating `default()` — construct one.
let trap = Camera::default();
let anti = cross(trap.right, trap.down);
// right × down == -forward here: the wrong chirality.
assert!(approx_eq(anti, [0.0, -1.0, 0.0]));
Yaw/pitch conventions: yaw = 0 looks down +x, increasing yaw turns
toward +y; pitch = 0 is level, positive pitch aims down.
The precision model
Three numeric domains, each with a job:
- f64 for the world. Camera position/basis and every
GridTransform(grid origin + rotation quaternion) are f64, so a huge world doesn’t jitter far from the origin. - i32 for voxels. Edits and queries take grid-local integer voxel
coordinates (
glam::IVec3). - f32 for sprite instances. Per-instance sprite/clip transforms (chapter 7) are f32 — cheap to stream in bulk, precise enough for object-sized placements.
Where these conventions come from
They are Voxlap’s, kept so assets and ported math work unchanged. The
design history — what was ported faithfully, what was replaced
(the renderer itself is a clean-room per-pixel DDA, not Voxlap’s
raycaster) — lives in
docs/porting/,
starting from PORTING-RUST.md and PORTING-SCENE.md.
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).
Rendering & backends
Your game draws through one type:
SceneRenderer, the facade over
roxlap’s two renderers. You build a Scene, advance your game, and
call the same four or five methods every frame — which backend does
the marching is a construction-time choice, not an architectural one.
The snippets in this chapter and the next come from a runnable example — a foggy pillar avenue rendered through the full retro pipeline with an overlay gizmo:
cargo run --release -p roxlap-render --example book_pipeline
The two backends
Both are per-pixel voxel ray-marchers with the same retro look, and
both derive their projection from the same FrameParams, so a scene
frames identically on either:
- CPU — a clean-room per-pixel 3D-DDA over a brickmap, rayon-
parallel across row strips, presented via a software framebuffer
(softbuffer on native, a WebGL2 blit on wasm). Zero GPU
requirements: it runs in a VM, over remote desktop, on a machine
with broken drivers. Design history:
PORTING-DDA.md. - GPU — a WGPU/WGSL compute-shader marcher (two-level chunk +
voxel DDA with chunk-occupancy skip and distance-based mip LOD),
presented via a wgpu swapchain. Same image, several times the frame
rate, and the CPU budget goes back to your game. Empty space costs
next to nothing: rays cross provably-empty regions via a per-grid
occupancy pyramid (< 40 B/grid, maintained live on edits), so
sparse worlds — a floating ship over distant terrain — don’t pay
per-chunk for the air between (measured −30% frame time on
empty-gap-dominated views; byte-stable by construction). Design
history:
PORTING-GPU.md.
The facade keeps them in lockstep: scene edits, sprites, materials and lighting are tracked once and pushed to whichever backend is live.
Choosing a backend
RenderOptions::backend takes a BackendPreference:
Cpu— the software renderer, unconditionally (the default).PreferGpu— try WGPU; on failure, fall back to the CPU renderer with a warning through the [log] facade. The right choice for games: one binary runs everywhere.RequireGpu— GPU or an error. Use it when a silent software fallback would lie to you: benchmark rigs, GPU CI.
// PreferGpu tries WGPU and falls back to the CPU renderer with
// a `log::warn` when init fails; RequireGpu turns that failure
// into an error instead (benchmark rigs must not silently
// measure a software render). Plain Cpu never touches WGPU.
let opts = RenderOptions {
backend: if want_gpu {
BackendPreference::PreferGpu
} else {
BackendPreference::Cpu
},
clear_sky: SKY,
..RenderOptions::default()
};
let mut renderer =
match SceneRenderer::try_new(window.clone(), (size.width, size.height), &opts) {
Ok(r) => r,
Err(e) => {
// Even the last-resort CPU software surface failed —
// there is nothing to draw on.
eprintln!("cannot create a render surface: {e}");
event_loop.exit();
return;
}
};
SceneRenderer::new is the panicking convenience form of try_new
(the quickstart uses it); real games call try_new and show their
own error UI. Diagnostics — including why a machine fell back to
software rendering — go through log, so install a logger
(env_logger in the examples) or that warning is invisible.
The window parameter is anything
raw-window-handle in an Arc —
winit, SDL, GLFW, your own. On wasm, construct with
new_from_canvas_async over an HTML canvas instead (WebGPU, falling
back to the CPU path presented through WebGL2 — chapter 12).
Capability parity: supports()
A few features exist on one backend only — sky panoramas and sprite
carving are GPU-only, free per-frame depth picks are CPU-only, and so
on. The methods involved stay callable everywhere and degrade to
documented no-ops (or documented costs); supports(Feature::..) is
the queryable form of that parity table, so you can pick a strategy
at startup instead of discovering a no-op visually:
// Backend capabilities differ below the parity line; methods on
// the unsupported side degrade to documented no-ops. Query once
// at startup and pick a strategy — don't guess per frame.
match renderer.backend() {
Backend::Gpu => log::info!(
"GPU backend: {}",
renderer.adapter_info().unwrap_or("unknown adapter")
),
Backend::Cpu => log::info!("CPU backend (software per-pixel DDA)"),
}
if !renderer.supports(Feature::FreePickDepth) {
// GPU depth reads block on a device poll: fine per click,
// wrong per frame — so pick a reticle strategy up front.
log::info!("per-frame depth picks are expensive here; use view_ray + raycast");
}
The authoritative feature-by-feature table lives on the
Feature enum’s rustdoc — the book
deliberately doesn’t copy it (it changes as parity gaps close).
The frame protocol
One frame, in order:
tick(&camera, dt)— advances every facade-owned animation (clips, characters, billboard actors) in one call. Only needed once you use those (chapter 7).render(&mut scene, &camera, &frame)— composites the scene into the backend’s frame buffer. Does not present.- Overlays (optional) —
draw_lines/draw_imagesdraw into the composited frame, using its camera and depth buffer. - Exactly one of
present()orpaint_egui(..)— finishes and shows the frame.
At shutdown, call wait_idle() before the window is torn down —
otherwise the GPU backend’s in-flight work can leave the compositor
showing stale buffers (the “leftover triangles on exit” symptom).
FrameParams
The per-frame parameter block. It is #[non_exhaustive] — always
construct with FrameParams::new(&settings) and override fields, so
engine upgrades that add parameters don’t break your build:
// FrameParams::new gives working defaults for everything
// but `settings`; override what differs. Both backends
// derive their projection from `settings`, so CPU and
// GPU show the same field of view.
let settings =
OpticastSettings::for_oracle_framebuffer(size.width.max(1), size.height.max(1));
let mut frame = FrameParams::new(&settings);
frame.sky_color = SKY;
// CPU fog: distant voxels fade into the sky colour,
// fully fogged at 700 voxels (0 = fog off).
frame.fog_color = SKY;
frame.fog_max_scan_dist = 700;
What lives here: the shared OpticastSettings (framebuffer geometry,
projection, scan distances — both backends’ field of view is derived
from it, 2·atan(yres/2 / hz)), sky colour and optional sky, CPU fog
(colour + full-fog distance), per-face side_shades, the lights
rig (chapter 6), and the draw_sprites switch. Settings are cheap to
rebuild per frame from the current window size.
Overlay lines
draw_lines renders world-space segments over the frame — editor
gizmos, debug paths, hover wireframes. Note the colour type:
Line3.color is an OverlayColor — the one packing with a real
alpha byte (chapter 2’s colour family). Depth-tested lines are occluded by nearer
voxels; non-depth-tested ones draw on top (hover highlights).
/// The 12 edges of an axis-aligned box as depth-tested overlay lines
/// — the shape of most editor gizmos.
fn wire_box(lo: [f64; 3], hi: [f64; 3], color: OverlayColor) -> Vec<Line3> {
let p = [
[lo[0], lo[1], lo[2]],
[hi[0], lo[1], lo[2]],
[lo[0], hi[1], lo[2]],
[hi[0], hi[1], lo[2]],
[lo[0], lo[1], hi[2]],
[hi[0], lo[1], hi[2]],
[lo[0], hi[1], hi[2]],
[hi[0], hi[1], hi[2]],
];
const EDGES: [(usize, usize); 12] = [
(0, 1),
(1, 3),
(3, 2),
(2, 0), // z = lo face (the top — +z is down)
(4, 5),
(5, 7),
(7, 6),
(6, 4), // z = hi face (the bottom)
(0, 4),
(1, 5),
(2, 6),
(3, 7), // vertical edges
];
EDGES
.map(|(a, b)| Line3 {
a: p[a],
b: p[b],
color, // 0xAARRGGBB — high byte is alpha here, not shading
width_px: 1.5,
depth_test: true, // occluded by nearer voxels
})
.to_vec()
}
renderer.render(scene, &camera, &frame);
// Overlays go between render and present: they draw into
// the composited frame using its camera, projection and
// depth buffer — here a gizmo box around the dome.
let gizmo = wire_box(
[-32.0, -32.0, 173.0],
[32.0, 32.0, 237.0],
OverlayColor(0xff_ff_d0_40),
);
renderer.draw_lines(&camera, &gizmo);
// Exactly one of present() / paint_egui(..) finishes it.
renderer.present();
For textured quads (world-fixed or billboarded) there is the
upload_image / draw_images pair — same slot in the protocol, see
the docs.rs entries.
Where next
- The render pipeline — the fixed-resolution / SSAA / posterize post stack this chapter’s example already enables.
- Lighting & materials —
FrameParams::lightsand the material palette. - Picking & world queries —
pick,view_ray,pick_depth(also facade methods, same per-frame world).
The render pipeline
Both roxlap backends are per-pixel marchers, so frame cost scales with the number of pixels marched. Left alone, that couples your frame rate to the player’s window size — a 4K window costs ~9× a 720p one. The render pipeline breaks that coupling and, as a bonus, is where the deliberate retro look lives.
The example from the previous chapter enables the whole stack:
cargo run --release -p roxlap-render --example book_pipeline
The stages
Once configured, every frame flows through:
- March at
logical × ssaapixels — the only expensive stage. - SSAA resolve — box-downfilter the supersamples back to one colour per logical pixel.
- Posterize (optional) — dither + quantize each channel at the logical resolution.
- Upscale — nearest-neighbour to the window. Hard pixel edges, no smearing.
Everything is off by default: RenderResolution::Native, ssaa = 1,
posterize None renders exactly the naive way (logical == window).
The knobs are independent — use fixed resolution purely for
performance without any retro styling, or posterize at native
resolution.
// The retro pipeline, configured once (each takes effect from
// the next render): march a fixed 320×180 logical grid — frame
// cost stops tracking the window size — with 2×2 supersampling
// folded back down per logical pixel, then quantize to 6
// levels per channel with an ordered Bayer dither before the
// nearest-neighbour upscale to the window.
renderer.set_render_resolution(RenderResolution::Fixed { w: 320, h: 180 });
renderer.set_ssaa(2);
renderer.set_posterize(Some(PosterizeConfig::uniform(6, DitherMode::Bayer4x4)));
let (rw, rh) = renderer.render_dims(); // 640×360 marched…
let (lw, lh) = renderer.logical_dims(); // …resolved to 320×180
log::info!("marching {rw}×{rh}, resolving to {lw}×{lh}, upscaling to the window");
Logical resolution
set_render_resolution takes a RenderResolution:
Native— logical == window (default).Fixed { w, h }— the retro pixel grid. The march cost is now a constant regardless of window size. A logical aspect ratio different from the window’s stretches non-uniformly — the classic fixed-res behaviour, no letterboxing.Scale(f)— logical =window × f, aspect preserved.Scale(0.5)is “quarter the pixels, whatever the window is”.
logical_dims() reports the resolved logical size,
render_dims() the actual marched size (logical × ssaa) — useful
for HUD readouts and for sanity-checking what you asked for.
FOV is unaffected by any of this: both backends derive it from
FrameParams::settings, which is resolution-independent.
Supersampling
set_ssaa(2) marches a 2×2 sample grid per logical pixel and folds
it back down — anti-aliasing within the hard pixel grid (distant
thin geometry stops shimmering, edges settle). Cost is quadratic in
the factor (clamped to 1..=4); at a fixed 320×180 logical grid,
ssaa = 2 is a 640×360 march — usually still far cheaper than a
native-resolution window.
Posterize & dither
set_posterize(Some(PosterizeConfig { .. })) quantizes each colour
channel to a small number of levels — the reduced-palette look.
Uniform levels via PosterizeConfig::uniform(n, dither), or set
levels_r/g/b independently (classic hardware palettes were rarely
symmetric). Quantization alone produces hard banding on smooth
gradients (fog!), which is what the dither field is for:
DitherMode::None— plain rounding; embrace the bands.Bayer4x4— the ordered cross-hatch pattern of 90s consoles.BlueNoise— interleaved-gradient noise; finer grain, no repeating grid, reads as texture rather than pattern.
Dither is indexed by the logical pixel, so each hard pixel still resolves to exactly one colour — the upscale never blurs it.
The best way to choose values is live: the scene demo’s Render
pipeline HUD panel (F1) exposes resolution / SSAA / posterize /
dither as interactive controls over any demo scene:
cargo run --release -p roxlap-scene-demo
The egui HUD
An in-game UI presents through the same frame: enable the hud
feature on roxlap-render, run egui in your host (e.g. egui +
egui-winit), and finish the frame with paint_egui instead of
present:
let ppp = egui_ctx.pixels_per_point();
let jobs = egui_ctx.tessellate(full.shapes, ppp);
renderer.paint_egui(&jobs, &full.textures_delta, ppp);
The GPU backend paints via egui-wgpu; the CPU backend
software-rasterises the same tessellation into its framebuffer — the
HUD works even on the pure-software path. The UI draws at window
resolution, after the upscale: panels stay crisp over a chunky
320×180 world. A complete host (input plumbing, panels, the live
pipeline controls above) is
roxlap-scene-demo/src/host.rs.
Further reading
PORTING-PIPELINE.md— the RP-stage design history, including why TAA and texture-filtered upscales were rejected (they fight the crisp-retro goal).- Performance & tuning — the other knobs that bound frame cost (scan distances, mip ladder, streaming radii).
Lighting & materials
roxlap lights a scene in two layers. Baked lighting lives in each
voxel’s VoxColor brightness byte — remember from
chapter 2: that byte is shading intensity, not alpha — and
costs nothing per frame. Runtime lighting is a per-frame rig of
sun + point + spot lights with stylized hard voxel shadows, composited
on top. Both layers, and the transparent-voxel materials at the end of
this chapter, run on both backends.
The snippets come from a runnable example — a courtyard under a sweeping shadow-casting sun, orbiting coloured points, a spot cone, a glass wall and a volumetric fog cloud:
cargo run --release -p roxlap-render --example book_lighting
The formula that ties the layers together:
pixel = albedo × (baked byte × rig.ambient) + Σ direct light terms
The baked byte is the ambient/AO channel. With lights: None (the
default) you get exactly the classic render — baked byte only.
Baked lighting & ambient occlusion
Grid::bake(mode) walks the grid once and writes shading into every
voxel’s brightness byte. The two BakeModes:
BakeMode::Directional— estnorm shading, the classic Voxlap look (surface-normal-based sun shading, baked). Use it standalone when you don’t run a light rig — the cave and terrain demos ship this.BakeMode::AmbientOcclusion(AoParams)— crevices, pillar bases and inner corners darken. This is the right bake under a runtime rig: the rig treats the byte as its ambient fill, so AO gives contact shading everywhere the dynamic lights don’t reach.
/// A courtyard: floor, four pillars, a central monument and a glass
/// wall — then bake ambient occlusion into the brightness byte.
fn build_scene() -> Scene {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
let grid = scene.grid_mut(id).expect("grid just added");
grid.set_rect(
IVec3::new(-96, -96, 210),
IVec3::new(95, 95, 254),
Some(GRASS),
);
for (px, py) in [(-60, -60), (40, -60), (-60, 40), (40, 40)] {
grid.set_rect(
IVec3::new(px, py, 165),
IVec3::new(px + 12, py + 12, 209),
Some(STONE),
);
}
grid.set_rect(
IVec3::new(-10, -10, 150),
IVec3::new(10, 10, 209),
Some(MONUMENT),
);
// A glass wall (the colour is mapped to a translucent material at
// renderer setup — the terrain itself just stores the colour).
grid.set_rect(
IVec3::new(-70, -2, 170),
IVec3::new(-30, 1, 209),
Some(GLASS_RGB),
);
// A crystal capping the monument — the colour maps to an EMISSIVE
// material, so it glows through every lighting mode below.
grid.set_sphere(IVec3::new(0, 0, 147), 4, Some(CRYSTAL_RGB));
// Bake ambient occlusion into every voxel's brightness byte:
// crevices, pillar bases and inner corners darken. The runtime
// lights below read this byte as their ambient/AO fill. Re-bake
// after bulk edits (for small runtime carves use `bake_bbox` —
// it re-bakes just the hole).
grid.bake(BakeMode::AmbientOcclusion(AoParams {
strength: 0.85, // fraction of ambient removed in a crevice
radius: 1, // contact reach in voxels (1 = tight edges)
..AoParams::default()
}));
scene
}
The bake is neighbour-aware across chunk seams in all three axes (no brightness discontinuities at chunk borders) and rayon-parallel. Costs and cadence:
- Bake once after building a grid — it is a bulk operation.
- After a runtime carve, don’t re-bake the grid: pass the edit’s
bbox to
bake_bbox— it re-bakes a few hundred columns instead of whole chunks (the cave demo measured ~0.04 ms against 4–7 ms). - Streaming grids bake per chunk as they stream in — a scene-wide bake at startup would miss every chunk generated later.
The runtime rig
Lighting is per-frame state: build a
LightRig and set it on
FrameParams::lights. There are deliberately no light setters on the
renderer — lights flow the same way sky and fog already do, so
“remove the light” is just “stop passing it”.
// Lighting is per-frame: build a LightRig and hand it to
// FrameParams — there are no stateful light setters.
// The sun sweeps overhead (+z is down, so a positive z
// component keeps it above the horizon).
let a = t * 0.4;
let sun = DirectionalLight {
direction: [0.7 * a.cos() as f32, 0.7 * a.sin() as f32, 0.55],
color: [1.0, 0.93, 0.82], // warm daylight
intensity: 1.25,
casts_shadow: true,
};
// Two orbiting coloured fills; only one casts shadows
// (shadow casters share a small per-frame budget —
// excess casters are demoted with a log warning).
let b = t * 0.8;
let points = [
PointLight {
position: [70.0 * b.cos() as f32, 70.0 * b.sin() as f32, 195.0],
color: [1.0, 0.25, 0.2],
intensity: 4.0,
radius: 80.0,
casts_shadow: true,
},
PointLight {
position: [-70.0 * b.cos() as f32, -70.0 * b.sin() as f32, 195.0],
color: [0.25, 0.45, 1.0],
intensity: 4.0,
radius: 80.0,
casts_shadow: false,
},
];
// A spot cone pinning the monument: position + axis +
// inner/outer half-angles (soft edge between them).
let spots = [SpotLight {
position: [0.0, 0.0, 110.0],
direction: [0.0, 0.0, 1.0], // straight down
color: [1.0, 0.9, 0.6],
intensity: 6.0,
radius: 120.0,
inner_angle_deg: 12.0,
outer_angle_deg: 22.0,
casts_shadow: false,
}];
frame.lights = Some(LightRig {
sun: Some(sun),
points: &points,
spots: &spots,
// Dim the baked ambient so the runtime lights read
// as the key; the baked byte is the ambient/AO fill.
ambient: [0.32, 0.34, 0.4],
shadow_strength: 0.85,
shadow_bias_voxels: 1.5,
shadow_max_dist: 256.0,
// Stylized cel look: quantize diffuse into 6 bands
// and ramp shadows toward a cool tint instead of
// black. `bands: 0` = smooth diffuse.
bands: 6,
shadow_tint: [0.16, 0.2, 0.34],
});
The three light types:
- Sun (
DirectionalLight) — one per scene,directionis the way the light travels. With +z down, keep a positive z component to stay above the horizon. - Point (
PointLight) — world position, hardradiuscutoff (zero contribution beyond it; keep radii tight, they bound the shading work). - Spot (
SpotLight) — a point light with a cone:directionis the axis,inner_angle_deg/outer_angle_degare half-angles with a smoothstep falloff between them. Internally a spot is a point light (a point is the 180°-cone degenerate), so spots share the point-light count and shadow budgets.
Shadows are stylized hard voxel shadows, flagged per light with
casts_shadow. Casters are budgeted per frame: only the first few
flagged lights actually cast; the rest are demoted to shadowless with
a log warning — never dropped. The rig-level knobs:
shadow_strength (how dark), shadow_bias_voxels (~1.5 kills
self-shadow acne), shadow_max_dist (sun-ray length cap).
Stylized mode: bands ≥ 1 quantizes the diffuse into discrete
cel levels, and the sun term drives a gradient from shadow_tint
(cool, unlit) to the sun colour (warm, lit) — hue-shifted shadows
instead of plain darkening, which keeps the retro identity instead of
reading as generic Phong. bands: 0 is smooth diffuse. Compare them
live in the Lighting demo scene (J toggles, [/] change bands).
Materials: transparent voxels
Both backends march rays strictly front-to-back, which makes order-correct transparency free — no depth sorting, no OIT scheme: a translucent voxel just composites over whatever the ray finds behind it.
Materials stay out of the colour word (its high byte is taken —
that’s the brightness/ambient story above). Instead the renderer owns
a 256-entry material palette; a voxel carries a one-byte material
id. Id 0 is permanently opaque, so anything without material data
renders exactly as before. A Material is an opacity plus a
BlendMode:
Opaque— first hit wins (the default path).AlphaBlend— front-to-backovercompositing. Glass, shells, windows. Opacity is per surface, independent of thickness.Additive— adds light without occluding, order-independent. Spell glows, fire, muzzle flashes.Volumetric— Beer–Lambert absorption: per-cell opacity is1 − (1 − α)^path_length, so a filled volume reads denser through its core than its rim. True smoke, fog, murky water.
Materials attach at three levels:
// The global material palette: 256 slots, id 0 permanently
// opaque. A material is opacity + blend mode; voxels reference
// it by id.
renderer.define_material(MAT_GLASS, Material::alpha_blend(110));
renderer.define_material(MAT_FOG, Material::volumetric(28));
// Emissive (EV): the crystal renders at over-bright albedo —
// immune to the bake, the runtime rig, shadows and side shades
// (only fog still applies). `with_emissive` composes with any
// blend mode; `Material::glow(e)` is the opaque shorthand.
renderer.define_material(MAT_CRYSTAL, Material::alpha_blend(180).with_emissive(255));
// Terrain: map voxel colours (low 24 bits) → material ids. Every
// grid voxel in the glass colour now composites translucently.
renderer.set_terrain_materials(&[
(GLASS_RGB.rgb_part(), MAT_GLASS),
(CRYSTAL_RGB.rgb_part(), MAT_CRYSTAL),
]);
- Terrain —
set_terrain_materialsmaps voxel colours (low 24 bits) to material ids: glass walls and water pools built with plainset_rectcalls. - Whole sprite instance —
set_sprite_instance_material(id, mat); pair withset_sprite_instance_alphato pulse opacity per frame without touching the volume. - Per voxel —
add_sprite_model_with_materials/add_voxel_clip_with_materialstake a colour→material map, so one model mixes opaque and translucent voxels (a window: opaque frame, glass panes — static or animated).
The hollow-shell trap
Volumetric needs actual interior voxels to absorb through — and the
standard Kv6::from_fn constructor culls interiors (a normal sprite
only needs its shell). Build filled volumes with
from_fn_keep_interior:
/// A **filled** fog ball for the volumetric material. The default
/// `Kv6::from_fn` culls interior voxels (a sprite normally only needs
/// its shell) — but Beer–Lambert opacity accumulates along the ray's
/// path *through* the volume, so a hollow shell would read as two thin
/// films. `from_fn_keep_interior` keeps the inside; its second closure
/// says which colours to keep (translucent ones — opaque interiors
/// would still be dead weight).
fn fog_cloud() -> Kv6 {
const DIM: u32 = 28;
let c = (DIM as f32 - 1.0) * 0.5;
let r2 = (DIM as f32 * 0.48).powi(2);
Kv6::from_fn_keep_interior(
DIM,
DIM,
DIM,
|x, y, z| {
let (dx, dy, dz) = (x as f32 - c, y as f32 - c, z as f32 - c);
(dx * dx + dy * dy + dz * dz <= r2).then_some(FOG_RGB)
},
|color| color == FOG_RGB,
)
}
If your volumetric cloud renders as two thin films (front and back face only), this is why.
Backend parity
Translucent compositing runs on both backends — CPU since the TV
stage’s march rework, GPU via its own per-span accumulation paths
(sprites and terrain alike). The residual gaps are stylistic, not
structural: translucent sprites stay flat-lit (no side-shades, fog
or tint on the translucent layers), and compositing is per-pass — a
translucent sprite over translucent terrain resolves per pass rather
than through one unified blend. Neither has mattered in practice; if
one bites you, supports() (chapter 4) is still the
place a future split would surface.
Emissive voxels & baked glow
A material can also emit: Material { emissive, .. } (or the
Material::glow(e) shorthand, or .with_emissive(e) on any blend
mode) renders the voxel at albedo × (128 + e/2) / 128 — from 1× up
to ~2× over-bright at 255 — and skips everything that would darken
it: the baked brightness byte, per-face side shades, the runtime rig,
shadows, cel bands. Only fog still applies, so a distant glow fades
like the rest of the world. Emissive composes with translucency: an
AlphaBlend crystal glows through its own body. Both backends
render it identically; the terrain material map above is all the
wiring it needs (the MAT_CRYSTAL lines in the snippet).
Making the voxel bright is half the effect — a glow that doesn’t
light its surroundings reads as a sticker. The other half is the
point-light bake (voxlap’s lightmode 2): register the glow
sources on the grid and bake with BakeMode::PointLights, and each
light writes a cube-law Lambertian pool into the surrounding
brightness bytes over a deliberately dim directional base — light
pools reading against gloom. Baked means free at render time, on both
backends:
grid.set_sphere(p, params.crystal_radius, Some(params.color));
// The light floats in the air a few voxels off the
// surface so the pool spreads over the wall around it.
let lp = air + dir * (k - 3).max(0);
grid.bake_lights.push(crate::BakeLight {
pos: glam::Vec3::new(lp.x as f32, lp.y as f32, lp.z as f32),
radius: params.light_radius,
strength: params.light_strength,
});
// EV.4 — plant the glowing crystals (voxels + their bake lights)
// BEFORE the bake so the first bake already writes their pools.
plant_crystals(grid, preset, seed);
// WT.4 — flood the lower cave: after the crystals, before the
// bake (see the fn doc for why the order is load-bearing).
flood_below_waterline(grid);
// EV.4 — point-light bake: the dim voxlap lightmode-2 base plus a
// glow pool around every crystal. The gloom is deliberate — the
// cave now reads by crystal light.
grid.bake(roxlap_scene::BakeMode::PointLights);
Grid::bake_bbox — the incremental relight primitive for runtime
carves — picks the grid’s bake_lights up automatically, so shooting
a hole next to a crystal re-bakes the crater with its glow pool
intact. A BakeLight’s strength is on the brightness-byte scale:
the gain at distance d is roughly strength / d², so 2000 is a
reading-torch and 8000 floods a small cavern. For a light that
moves or flickers, use a runtime PointLight from the rig instead —
the bake is for scenery that stays put.
The cave demo is the live showcase: crystal clusters planted on
cavity walls, each one an emissive translucent blob plus a
BakeLight (cargo run --release -p roxlap-cave-demo).
Further reading
- The Lighting, Spotlight and Transparency demo scenes —
every effect in this chapter, live with toggles
(
ROXLAP_SCENE=Lighting cargo run --release -p roxlap-scene-demo). PORTING-DYNLIGHT.md,PORTING-SPOTLIGHT.md,PORTING-TRANSPARENCY.md— design history: why shadows are hard-edged, why spots fold into the point path, why transparency needed no OIT.
Sprites & animation
Everything that moves in a roxlap world is a sprite: a voxel model
(.kv6) instanced into the scene with its own f32 transform. Sprites
are not a separate rendering world — they march through the same
per-pixel DDA, so they get the scene’s lighting, stylized shadows
(cast and receive), and materials for free. On top of static models
sit three animation systems: flipbook voxel clips, skeletal
characters, and Doom-style billboards.
The snippets come from a runnable example — three gems (one plain, one scaled, one spinning), a pulsing animated clip, and the same clip as a camera-facing billboard:
cargo run --release -p roxlap-render --example book_sprites
Models & instances
The unit of registration is a model (one Kv6 volume); the unit
of placement is an instance. Register once, instance many:
// One model, many instances: register the kv6 once, then place
// posed instances. A pose is a position + a local→world basis;
// scaling the basis scales the model — there is no separate
// scale field.
let gem = renderer.add_sprite_model(&gem_kv6());
renderer
.add_sprite_instance_posed(
gem,
DynSpriteTransform {
pos: [-50.0, 0.0, 202.0],
..DynSpriteTransform::default() // identity basis: authored size
},
)
.expect("model just registered");
renderer
.add_sprite_instance_posed(
gem,
DynSpriteTransform {
pos: [0.0, 0.0, 198.0],
right: [2.0, 0.0, 0.0], // basis ×2 ⇒ the gem renders
up: [0.0, 2.0, 0.0], // at twice its authored size
forward: [0.0, 0.0, 2.0],
},
)
.expect("model just registered");
let spinner = renderer
.add_sprite_instance_posed(
gem,
DynSpriteTransform {
pos: [50.0, 0.0, 202.0],
..DynSpriteTransform::default()
},
)
.expect("model just registered");
Where models come from: paint one in
Demiurg (the roxlap asset
editor) or MagicaVoxel (chapter 11), parse a .kv6 file
(roxlap_formats::kv6), or build
one in code —
Kv6::from_fn (surface-only, as here), solid_cube / solid_box,
or from_fn_keep_interior for filled volumes
(chapter 6).
The pose type, DynSpriteTransform, is a position plus a
local→world basis (all f32 — chapter 2’s precision
split). Two things the basis buys you:
- Scale — there is no scale field; scaling the basis vectors is the scale, and non-uniform or skewed bases work too.
- Rotation — any orthogonal basis. For per-frame motion, rewrite the transform; it’s an in-place update, not a re-upload:
// Rewrite the spinner's basis: a yaw rotation about the
// world vertical. Per-frame transform writes are the
// intended path — they update in place, no re-upload.
if let Some(id) = self.spinner {
let (s, c) = (t * 1.5).sin_cos();
let (s, c) = (s as f32, c as f32);
renderer.set_sprite_instance_transform(
id,
DynSpriteTransform {
pos: [50.0, 0.0, 202.0],
right: [c, s, 0.0],
up: [-s, c, 0.0],
forward: [0.0, 0.0, 1.0],
},
);
}
// One call advances everything facade-owned: clip
// players, characters, billboard actors, and the
// camera-facing of plain billboards.
renderer.tick(&camera, dt);
For bulk motion use set_sprite_instance_transforms (plural) — one
batched flush for hundreds of instances (the particle system in
chapter 8 rides exactly that path). Instances are
removed with remove_sprite_instance; models with
remove_sprite_model + compact_sprite_models. Per-instance
appearance knobs: set_sprite_instance_tint / _alpha /
_material / _lighting / _shadow_flags.
Animated voxel clips (.rvc)
A voxel clip is the “GIF for voxels”: a fixed-bbox sequence of
frames stored as keyframes + deltas, with per-frame durations and a
loop mode. Author one from kv6 frames in code (below), import a GIF
(further down), or load a .rvc file:
/// A pulsing orb as an animated **voxel clip** — the "GIF for voxels":
/// four kv6 frames sharing one bounding box, encoded as keyframe +
/// deltas. 150 ms per frame, looping.
fn orb_clip() -> VoxelClip {
const DIM: u32 = 18;
let c = (DIM as f32 - 1.0) * 0.5;
let frames: Vec<Kv6> = [5.0_f32, 6.5, 8.0, 6.5]
.iter()
.map(|&r| {
let r2 = r * r;
Kv6::from_fn(DIM, DIM, DIM, |x, y, z| {
let (dx, dy, dz) = (x as f32 - c, y as f32 - c, z as f32 - c);
(dx * dx + dy * dy + dz * dz <= r2).then_some(ORB)
})
})
.collect();
VoxelClip::from_kv6_frames(&frames, 1.0, LoopMode::Loop, &[], 150, 1)
.expect("frames are non-empty and share dims")
}
(from_kv6_frames_auto picks keyframe-vs-delta per frame by encoded
cost — the turnkey choice for real assets.)
Register the decoded clip and place instances — _playing attaches a
per-instance player that tick advances; frame swaps cost O(changed
frame), not O(volume):
// Register the clip once, then instance it. `_playing` starts
// its per-instance player: `tick` (or `advance_voxel_clips`)
// advances the clock and swaps frames — O(changed frame), not
// O(volume).
let clip = renderer.add_voxel_clip(&orb_clip().decode().expect("self-authored clip"));
renderer
.add_clip_instance_playing(
clip,
DynSpriteTransform {
pos: [0.0, -60.0, 200.0],
..DynSpriteTransform::default()
},
1.0, // playback speed
0, // start phase, ms
)
.expect("clip just registered");
The player is fully scriptable: set_clip_instance_paused /
_speed / _frame (scrub) / _clock_ms, and
set_clip_instance_clip swaps which animation an instance plays —
that swap is how billboard actors change state below. For live
authoring there is update_clip_frame (rewrite one frame in place),
and for long cutscene-grade clips streaming clips
(add_streaming_clip) keep only the current frame resident instead
of the whole timeline.
Billboards: the Doom look
A billboard is a clip instance that auto-faces the camera — the Doom/Build sprite. Because it is still a voxel object (a flat slab of voxels), it casts and receives real shadows and takes materials, no special path:
// The same clip as a Doom-style billboard: a camera-facing
// instance. Cylindrical = yaw-only facing (stays vertical, the
// Build-engine look); Spherical also pitches with the view.
renderer
.add_billboard_instance(clip, [0.0, 60.0, 200.0], BillboardMode::Cylindrical)
.expect("clip just registered");
Cylindrical rotates about the vertical only (the classic look — and
its cast shadow stays a sane vertical card); Spherical also pitches
with the view. tick re-faces every billboard each frame.
Importing 2D art: with the gif / png cargo features,
gif_import / png_import turn an animated GIF or a PNG sequence
into a voxel clip — each frame a 1-voxel-thick cutout slab, palette
and timing preserved. That’s the whole asset pipeline for Doom-style
monsters: draw (or rip) sprite sheets, import, place.
Billboard actors (add_billboard_actor) are the high-level layer
for such monsters: a BillboardActorDef holds named states
(“walk”, “attack”, …), each with N-way directional clips (8-way
in the demo); every tick the renderer picks the clip from the
camera’s bearing versus the actor’s facing_yaw, faces it, and
advances its animation. Drive gameplay with set_actor_state /
set_actor_transform / set_actor_tint. Lighting per actor or
instance via BillboardLighting — FullBright for pickups and
projectiles that shouldn’t darken in shadow.
BillboardActorDef::scale sets world units per slab voxel (1.0 =
the classic 1:1) — the knob for a giant boss or a half-height imp
from the same sheet; it composes with the clip’s own
voxel_world_size and behaves identically on both backends. The
Doom demo scene is the worked example (it synthesises its GIFs
at startup, so there is no binary asset to squint at).
Characters (.rkc) and KFA rigs
For articulated models roxlap has two systems, both asset-driven — the book shows no code because the code is three calls; the substance is in the assets:
.rkccharacters — the modern container: meshes + a bone skeleton + animation clips, where a bone attachment is either a static mesh or a voxel clip (a flickering torch in a hand). The whole lifecycle is four calls:roxlap_formats::character::parse(&bytes)loads the container,add_character(&ch, clip)spawns one playing the chosen clip,tickadvances it every frame, andset_character_world_transformmoves it. Author them in Demiurg, the roxlap asset editor (chapter 11); the wire format isroxlap_formats::character, and the Animation demo scene is the loading example.- KFA rigs — Ken Silverman’s original animated-sprite format
(
.kfa), supported for asset compatibility: the host owns theKfaSprites and callsset_kfa_sprites/update_kfa_poses. See theroxlap-hostdemo. New projects should prefer.rkc.
Further reading
- Demo scenes: Sprites (instancing + transforms), Animation (clips + characters), Doom (billboards + actors).
PORTING-SPRITE-API.md,PORTING-VOXEL-CLIP.md,PORTING-BILLBOARD.md— the design histories (why clips are keyframe+delta, why a billboard is a 1-voxel slab and not a textured quad).
Particles
ParticleSystem is roxlap’s effects engine: fountains, smoke, sparks,
debris. It is deliberately a host-side system built on the sprite
API from chapter 7 — every particle is an ordinary kv6
sprite instance, so particles are lit, shadowed and materialed like
everything else, and they collide with the actual voxel world.
The snippets come from a runnable example — a bouncing water fountain, a buoyant smoke column, and a scripted explosion every four seconds that carves real craters:
cargo run --release -p roxlap-render --example book_particles
The system and its emitters
One ParticleSystem per scene, seeded — the same seed replays the
same effects. Effects are emitters, described declaratively by a
ParticleEmitterDef: construct with new(model) and override what
the effect needs. The two ambient effects show most of the vocabulary:
// One system per scene, seeded for reproducible effects. The
// particles instantiate ordinary kv6 sprite models.
let mut particles = ParticleSystem::new(SEED);
let spark = renderer.add_sprite_model(&spark_kv6());
let puff = renderer.add_sprite_model(&puff_kv6());
let debris = renderer.add_sprite_model(&debris_kv6());
// A water fountain: a tight upward cone of translucent
// droplets that fall back and bounce. +z is down, so the
// fountain fires along −z and gravity (+z) pulls it back.
particles.add_emitter(ParticleEmitterDef {
pos: [0.0, 0.0, 199.0],
spawn: SpawnMode::Rate(140.0),
lifetime: 2.2..3.2,
velocity: VelocityDef {
cone: Some(ConeDef {
axis: [0.0, 0.0, -1.0], // straight up
half_angle_deg: 11.0,
speed: 26.0..34.0,
}),
..VelocityDef::default()
},
collision: CollisionMode::Bounce { restitution: 0.45 },
scale: 0.7,
fade_out_frac: 0.3,
tint: Rgb(0x0060_a8ff),
material: MAT_WATER,
..ParticleEmitterDef::new(spark)
});
// A smoke column: buoyant (negative-z "gravity"), dragged,
// spinning, growing while it condenses in and thins out.
particles.add_emitter(ParticleEmitterDef {
pos: [-40.0, 20.0, 198.0],
shape: EmitterShape::Sphere { radius: 2.0 },
spawn: SpawnMode::Rate(16.0),
lifetime: 3.5..5.0,
velocity: VelocityDef {
base: [0.0, 0.0, -7.0], // rises: −z is up
spread: 1.0,
..VelocityDef::default()
},
gravity: [1.2, 0.0, -1.5], // buoyant, drifting east
drag: 0.9,
spin: -0.6..0.6,
scale: 0.8,
scale_end: Some(2.8),
fade_in_frac: 0.25,
fade_out_frac: 0.45,
tint: Rgb(0x00b8_b8b8),
tint_end: Some(Rgb(0x0050_5050)),
material: MAT_SMOKE,
// WorldUp: stable shading that doesn't swim as the camera
// orbits (FaceNormal is the default; FullBright for glows).
lighting: BillboardLighting::WorldUp,
..ParticleEmitterDef::new(puff)
});
The def’s fields group into three concerns:
- Spawning —
spawn(Rate(n)per second with fractional accumulation,Burst(n)once on add,Manual+ explicitburst()calls),shape(point / sphere / box aroundpos),lifetime(a sampled range). - Motion —
velocity(a fixedbase+ isotropicspread+ an optionalConeDef, composed by addition),gravity(positive z is down: the default[0, 0, 22]falls; smoke rises with a negative-z term),drag,spin. - Look —
scale→scale_end(growing smoke, shrinking sparks),fade_in_frac/fade_out_frac(alpha ramps at the ends of life),tint→tint_end(white-hot → ember),material(the palette from chapter 6 — smoke wants alpha-blend, sparks additive),lighting,shadows.
Note the model trick: all three effects share plain white models — the per-particle tint does the colouring, so one 2-voxel cube serves as droplet and spark both.
Shadows default to off in both directions for particles — hundreds of shadow-casting instances is a perf trap; opt back in per effect.
The per-frame protocol
// The whole per-frame particle protocol is one call:
// simulate, collide against the scene's voxels, and
// mirror live particles into sprite instances.
particles.tick_with_scene(renderer, dt, scene);
One call simulates, collides, and mirrors live particles into sprite
instances (through the same batched-transform path as any bulk sprite
motion). Use tick(renderer, dt) when nothing collides —
tick_with_scene is what enables collision:
CollisionMode::None— pass through (default).Kill— die on contact: impact sparks, raindrops.Bounce { restitution }— arcade reflection off voxel faces.
The collision test is a point sample nudged along the velocity — cheap and honest about what it is: fast particles can tunnel through one-voxel walls, resting particles stop being tested. It’s an effects system, not a physics engine.
Explosions that change the world: carve_debris
The signature roxlap effect — because particles and world share one voxel vocabulary, an explosion can become its debris:
/// One explosion: `carve_debris` samples the floor's own voxel
/// colours, carves the crater out of the grid, and bursts the
/// removed voxels back as tumbling, bouncing, tinted debris — the
/// world visibly becomes the particles. A transient spark burst
/// goes on top (`Burst` spawns on add; removing the emitter lets
/// the sparks live out their lifetimes).
fn explode(&mut self, world: [f32; 3], voxel: IVec3) {
let (Some(particles), Some(scene), Some(grid)) =
(self.particles.as_mut(), self.scene.as_mut(), self.grid)
else {
return;
};
if let Some(debris) = self.debris {
particles.carve_debris(
scene,
grid,
voxel,
4, // crater radius, voxels
8.0..16.0, // radial kick away from the crater centre
&ParticleEmitterDef {
lifetime: 1.4..2.4,
collision: CollisionMode::Bounce { restitution: 0.35 },
spin: -7.0..7.0,
scale: 0.9,
scale_end: Some(0.4),
fade_out_frac: 0.2,
..ParticleEmitterDef::new(debris)
},
);
}
if let Some(spark) = self.spark {
let em = particles.add_emitter(ParticleEmitterDef {
pos: [world[0], world[1], world[2] - 0.5],
spawn: SpawnMode::Burst(24),
lifetime: 0.5..1.1,
velocity: VelocityDef {
spread: 24.0,
..VelocityDef::default()
},
collision: CollisionMode::Kill,
scale: 0.6,
fade_out_frac: 0.5,
tint: Rgb(0x00ff_c840), // white-hot…
tint_end: Some(Rgb(0x00ff_3000)), // …to ember red
material: MAT_SPARK,
lighting: BillboardLighting::FullBright,
..ParticleEmitterDef::new(spark)
});
particles.remove_emitter(em);
}
}
carve_debris samples the crater’s voxel colours, carves the sphere
out of the grid (the same edit path as chapter 3,
lighting re-bake included), and spawns one tinted debris particle per
removed surface voxel with a radial kick. The floor’s own colours
tumble away — no artist-authored debris needed.
Budgets keep it bounded: set_carve_debris_cap limits debris per
carve (default CARVE_DEBRIS_CAP), set_max_particles caps the
whole system (default DEFAULT_MAX_PARTICLES; overflow spawns are
dropped and counted in dropped_spawns, never reallocated). For HUD
counters read particle_count() / particles().
Performance envelope: the PS-stage benchmark holds ~10 000 live particles at ≈225 µs of simulation per frame — effects budget-level, not gameplay-limiting. The knobs that matter when a scene gets heavy: crater radius (a radius-4 carve samples ~70 voxels), burst sizes, and keeping particle shadows off.
Further reading
- The Particles demo scene — this chapter’s effects plus
crosshair-aimed interactive explosions and explosion light flashes
(
ROXLAP_SCENE=Particles cargo run --release -p roxlap-scene-demo). PORTING-PARTICLES.md— the PS-stage design history and benchmarks.
Audio
Most voxel engines bolt sound on as an afterthought: a positional
source, a distance falloff, done. roxlap’s roxlap-audio crate does
what the renderer does — it reads the actual voxels. A shot behind a
wall is muffled by exactly as much rock as the sound crosses; a gun
fired in a cavern rings, the same gun in a crawl-space is dry.
The crate splits cleanly in two, and the split is the whole design:
- The acoustics core turns a
Sceneplus a source and listener into plain numbers — an occlusion gain + lowpass cutoff per source, a reverb feedback + wet mix per listener. It owns no audio device, spawns no thread, and is fully deterministic, so it’s unit-tested against synthetic caves and you can call it from any thread. - A playback backend applies those numbers. The built-in one wraps
kira behind the
kiracargo feature; a host with its own audio stack implements theAudioOuttrait instead.
The snippets below come from a runnable example that prints the numbers the core computes for a little scene — no audio device needed:
cargo run -p roxlap-audio --example book_audio
Occlusion: hearing through rock
A source in a sealed room, a listener outside, a wall with a doorway:
// A source in a sealed stone room, a listener out in the open, and
// a 3-voxel wall between them (with a doorway punched to one side),
// all standing on a stone floor (z is DOWN, so "the ground" is the
// high-z slab everything rests on).
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let grid = scene.grid_mut(id).expect("grid just added");
grid.set_rect(
IVec3::new(0, 0, 150),
IVec3::new(127, 127, 170),
Some(STONE),
); // floor
grid.set_rect(IVec3::new(6, 40, 96), IVec3::new(40, 88, 150), Some(STONE));
grid.set_rect(IVec3::new(10, 44, 100), IVec3::new(36, 84, 146), None);
grid.set_rect(IVec3::new(60, 40, 96), IVec3::new(62, 88, 150), Some(STONE));
grid.set_rect(IVec3::new(60, 62, 100), IVec3::new(62, 66, 112), None); // doorway
let source = DVec3::new(22.0, 64.0, 120.0); // inside the room
Occlusion is a small fan of rays from the source to the listener. Each
ray accumulates the solid thickness it crosses — not a yes/no hit,
the actual voxel path length — and the average becomes a transmission
in 0..=1 that drives a muffling lowpass and a volume drop:
// Occlusion is a fan of rays from the source to the listener, each
// accumulating the SOLID THICKNESS it crosses; the average becomes a
// transmission that drives a muffling lowpass + a volume drop.
let cfg = AcousticsConfig::default();
for (label, listener) in [
("line of sight", DVec3::new(30.0, 64.0, 120.0)),
("through the wall", DVec3::new(80.0, 64.0, 120.0)),
("past the doorway", DVec3::new(80.0, 64.0, 106.0)),
] {
let a: SourceAcoustics = source_acoustics(&scene, source, listener, &cfg);
println!(
"{label:>16}: transmission {:.2} cutoff {:>6.0} Hz gain {:+.1} dB",
a.transmission, a.lowpass_cutoff_hz, a.gain_db,
);
}
line of sight: transmission 1.00 cutoff 20000 Hz gain -0.0 dB
through the wall: transmission 0.17 cutoff 1399 Hz gain -19.8 dB
past the doorway: transmission 0.36 cutoff 2527 Hz gain -15.4 dB
Thickness is why this reads as sound and not as a gate: one voxel of
wall muffles less than five, and because the fan spreads, a doorway off
the direct line lets some rays through — the listener past the door
hears the source brighter and louder than through the sealed wall, but
still not clear. Sound leaks around corners instead of snapping on and
off. AcousticsConfig tunes the fan (ray count, jitter radius,
per-voxel absorption, the cutoff endpoints).
Reverb: the size of the space
The second half fires a fan the other way — from the listener, outward in every direction. How far the rays travel before hitting a wall reads as room size; the fraction that escape to open sky reads as openness:
// Reverb comes from a fan of rays around the LISTENER: the mean
// enclosed free path reads as room size (feedback), the fraction
// escaping to open sky as dryness. A `CavityEstimator` smooths the
// raw probe over time — call it at ~2 Hz, not every frame.
let mut cavity = CavityEstimator::new(CavityConfig::default());
for (label, listener) in [
("in the room", DVec3::new(22.0, 64.0, 120.0)),
("out in the open", DVec3::new(90.0, 64.0, 120.0)),
] {
// The first update seeds from the raw probe (no fade-in); in a
// game you'd feed each result to the reverb with a ~1 s tween.
cavity.reset();
let env = cavity.update(&scene, listener);
println!(
"{label:>16}: openness {:.2} reverb feedback {:.2} mix {:.2}",
env.openness, env.reverb_feedback, env.reverb_mix,
);
}
in the room: openness 0.00 reverb feedback 0.53 mix 0.50
out in the open: openness 0.59 reverb feedback 0.66 mix 0.00
A CavityEstimator maps that to reverb feedback (decay) and a wet mix,
and — crucially — smooths it over time. Real rooms don’t change
acoustics the instant you step through a doorway, and a per-frame probe
would jitter the reverb audibly; the estimator eases toward each new
reading over a second or so, which players read as natural. Call it at
a couple of hertz, not every frame, and feed the result to the reverb
with a long (~1 s) tween. Note the open-air line: even outdoors, half
the fan hits the ground the listener stands on (openness 0.59, not
1.0) — so dryness can’t come from openness reaching 1. Instead the
wet mix falls to zero once openness crosses a threshold (default
0.5, an open-ground listener’s floor), which is what makes outdoors
read as dry however far the sky-bound rays fly. CavityConfig tunes
the fan size, that outdoor-openness threshold, and the
feedback / wet-mix ranges.
Playback: the AudioOut boundary
The core hands you parameters; a backend turns them into sound. That
seam is the AudioOut trait — register a sound, set_listener,
play / play_loop a source, and apply_source / apply_listener
the computed acoustics:
// Per acoustic tick (roxlap-audio computes, the backend applies):
let occ = source_acoustics(&scene, source, listener, &acfg);
audio.play(shot, source, Some(&occ)); // starts already muffled
let env = cavity.update(&scene, listener); // ~2 Hz
audio.apply_listener(&env); // ~1 s tween
The built-in KiraAudio (feature kira) implements it with a proper
aux-send topology: one shared reverb on a send track, a pool of spatial
voices each carrying its own lowpass filter and a per-source reverb
send. Parameters are applied with tweens — fast (~120 ms) for
per-source occlusion and the listener pose, slow (~1 s) for the reverb
environment — so nothing zippers. A fresh one-shot, though, starts
already at its occluded parameters (play’s initial argument): a
gunshot’s envelope is gone in a tenth of a second, long before a ramp
would arrive, so it must be muffled from the first sample. Voices are a
fixed pool allocated up front and reused — one-shots steal the oldest
finished voice when the pool is full; loops hold their slot until
stopped.
The kira backend needs an audio device (ALSA on Linux; WebAudio in
the browser — kira runs on wasm out of the box). It’s off by default,
so a plain build pulls in no audio stack at all. The one
browser-specific rule — construct the audio system inside a user
gesture — lives in the Platforms chapter; the cave
web demo (trunk serve --features audio) is the worked example.
One more listener-side knob (stage WT): AudioOut::set_listener_lowpass
muffles the whole mix — every voice and the reverb tail — for the
submerged ear. Drive it per frame from CharacterBody::eye_in_water
(deliberately not part of the reverb environment: that rides a ~2 Hz
probe cadence, and a head-dip must muffle at frame rate). On kira it
is an opt-in master-track filter (KiraAudio::with_options);
LISTENER_LOWPASS_SUBMERGED_HZ is the canonical underwater cutoff.
Materials: glass sounds thin
By default every solid voxel muffles alike. The material tables (stage
AU2) change that — and they take the same colour→material map the
renderer’s set_terrain_materials and the debris system’s fracture
tables use, so “this colour is crystal” is declared once and drives
rendering, destruction and sound together. absorption re-weighs a
material’s effective thickness in the occlusion march (a 0.35
glass voxel muffles like a third of a stone one); damping_override
re-colours the reverb of whatever walls the cavity probe hits — a
glass chamber rings brighter than a stone one:
// AU2 — materials change what the rays hear. The colour→material
// map is the SAME one the renderer's `set_terrain_materials` (and
// the debris system's fracture tables) take; `absorption` weighs a
// wall's effective thickness, `damping_override` recolours the
// reverb of the walls around the listener. Everything is PAINTED,
// never carved — a carve leaves colour-less faces the classifier
// can't read.
let grid = scene.grid_mut(id).expect("grid");
// A 3-voxel glass pane…
grid.set_rect(
IVec3::new(76, 20, 100),
IVec3::new(78, 40, 140),
Some(GLASS),
);
// …and a small sealed glass booth (six painted slabs).
let (bl, bh) = (IVec3::new(98, 22, 118), IVec3::new(106, 30, 130));
for ax in 0..3 {
for side in 0..2 {
let mut slo = bl - IVec3::splat(2);
let mut shi = bh + IVec3::splat(2);
if side == 0 {
shi[ax] = bl[ax] - 1;
} else {
slo[ax] = bh[ax] + 1;
}
grid.set_rect(slo, shi, Some(GLASS));
}
}
// The same pane, heard with and without the glass tables: 3 voxels
// of wall count as ~1 voxel of muffling at 0.35×.
let by_pane = DVec3::new(70.0, 30.0, 120.0);
let behind_pane = DVec3::new(85.0, 30.0, 120.0);
let mcfg = AcousticsConfig {
material_map: vec![(GLASS.rgb_part(), GLASS_ID)],
absorption: vec![(GLASS_ID, 0.35)],
..AcousticsConfig::default()
};
let as_stone = source_acoustics(&scene, by_pane, behind_pane, &cfg);
let as_glass = source_acoustics(&scene, by_pane, behind_pane, &mcfg);
println!(
"same pane, stone vs glass tables: transmission {:.2} -> {:.2}",
as_stone.transmission, as_glass.transmission,
);
// Reverb character: from inside the booth, every wall hit
// classifies to the override's 0.1 damping — a glass chamber
// rings brighter than the stone default (0.4).
let mut glassy = CavityEstimator::new(CavityConfig {
material_map: vec![(GLASS.rgb_part(), GLASS_ID)],
damping_override: vec![(GLASS_ID, 0.1)],
..CavityConfig::default()
});
let env = glassy.update(&scene, DVec3::new(102.0, 26.0, 124.0));
println!(
"inside the glass booth: damping {:.2} (stone default 0.40)",
env.reverb_damping,
);
Two conventions worth knowing. Colours absent from the map classify as
material 0, so a table entry for id 0 re-weighs every unmapped
voxel — a deliberate global knob. And the .vxl format stores
surface colours only: interior cells inherit the last surface
colour a ray crossed (rock-like 1.0 before the first), and a carved
face is colour-less — paint what you want classified (the example’s
booth is built from painted slabs, not carved out). Empty tables keep
the exact pre-AU2 arithmetic, bit for bit.
Doppler
Motion bends pitch. doppler_factor is pure math over positions and
velocities — clamped to [0.5, 2.0] (an octave each way) and exactly
1.0 at rest — and the backend applies it through
AudioOut::set_source_pitch (the kira implementation tweens the
playback rate, ~120 ms):
// AU2 — Doppler is pure math over positions and velocities: feed
// the factor to `AudioOut::set_source_pitch` (the kira backend
// tweens the playback rate). Apply it to LOOPS — bending a
// one-shot mid-envelope reads as a glitch, not physics.
let listener = DVec3::new(80.0, 64.0, 120.0);
for (label, vel) in [
("flying toward the source", DVec3::new(-40.0, 0.0, 0.0)),
("flying away", DVec3::new(40.0, 0.0, 0.0)),
("standing still", DVec3::ZERO),
] {
let f = doppler_factor(source, DVec3::ZERO, listener, vel, DEFAULT_SPEED_OF_SOUND);
println!("{label:>26}: pitch x{f:.2}");
}
The default speed of sound is a deliberately game-tuned 90 u/s (at the physical 343 the demo’s speeds would barely bend a semitone); pass your own to retune. Apply Doppler to loops and long tails — a hum, an engine — driven by whatever velocity you track (a camera position delta per frame is fine, with a teleport guard). Pitch-bending a one-shot mid-envelope reads as a glitch, not physics.
The cave demo
The cave demo is the worked example — build it with the audio
feature:
cargo run --release -p roxlap-cave-demo --features audio
Every plasma shot cracks at the muzzle, each impact booms at the crater
it carves, and every glowing crystal hums — all muffled by the rock
between them and you, with reverb that swells as you fly into a big
chamber and dries as you leave it, and the hums bending in pitch as
you fly past (AU2 Doppler, driven by the camera’s velocity). The crystal hums show the voice
budget at work: only the nearest handful loop at once (with hysteresis
so a crystal at the edge of earshot doesn’t flicker), started and
stopped as you move, so a crystal-rich cave never drowns out the shots.
The wiring — one small DemoAudio struct driving the core and the kira
backend from the demo’s fire / carve / per-frame hooks — lives in
crates/roxlap-cave-demo/src/audio.rs.
The scene gallery (chapter 15) also has an Audio
tab: a walkable stone room / open field / glass cavern with every
number this chapter derives shown live in the HUD — including the
measured cost of the acoustics update. The numbers come from the pure
core, so the tab works in every build; add --features audio to the
scene demo to hear the three zone hums too.
Further reading
PORTING-AUDIO.md— the AU-stage design history: the ray budgets and smoothing borrowed from Sound Physics Remastered, Teardown and Overwatch, why kira, and the aux-bus / voice-stealing lessons.- The scene graph and
Picking & world queries — the
Scene::raycastand voxel-query machinery the acoustic rays are built on.
Destruction
The signature voxlap moment: carve a tunnel under an overhang and the unsupported rock does not hang there politely — it breaks off, falls, and bursts into rubble where it lands. roxlap’s destruction pipeline reproduces that loop on top of pieces earlier chapters covered — edits, sprites (chapter 7), particles (chapter 8) — with two engine parts of its own:
- Island detection (
roxlap-scene): after any carve,detect_islandsfinds the voxel regions that no longer connect to support. It floods the chunk format’s RLE runs directly (one run is one search node — no dense decode), 6-connected, seeded from the carve’s bounding box. A region that reaches a bedrock-anchored column bottom is supported; one that grows past the budget is presumed supported (a cheap early exit); everything else comes back as anIsland— its voxels, colours and bounds. DebrisSystem(roxlap-render): the crumble loop. It extracts an island from its grid, optionally fractures it per material, registers each piece as a falling sprite at the exact world pose of the voxels it replaced, integrates the fall, and reports impacts for the shatter. Like the particle system it is host-owned and split into a pure simulation half (spawn_island/update— unit-testable, no renderer) and a facade half (sync— batched sprite updates).
The snippets below come from a runnable, windowless example that prints what each stage does:
cargo run -p roxlap-render --example book_destruction
Detecting what came loose
A scene with something to lose — a floor, an anchored pillar, a beam cantilevered off it, crystal grown near the tip:
// A stone cantilever over a floor: a pillar anchored to the floor
// (and through it to the format's bedrock at the column bottoms —
// the support the detector floods toward), a beam sticking out
// sideways, and a crystal cluster grown near the beam's tip.
// z is DOWN: the floor is the high-z slab, the rock rises toward
// smaller z.
let mut scene = Scene::new();
let grid = scene.add_grid(GridTransform::identity());
let g = scene.grid_mut(grid).expect("grid just added");
g.set_rect(IVec3::new(0, 0, 200), IVec3::new(63, 63, 255), Some(STONE));
g.set_rect(
IVec3::new(30, 30, 160),
IVec3::new(31, 31, 199),
Some(STONE),
);
g.set_rect(
IVec3::new(32, 30, 158),
IVec3::new(48, 31, 159),
Some(STONE),
);
g.set_rect(
IVec3::new(44, 30, 154),
IVec3::new(47, 31, 157),
Some(CRYSTAL),
);
g.bake(BakeMode::Directional);
Carve, then ask. Detection is a separate call on purpose: hosts decide when it runs (the cave demo runs it on its background carve worker, right after the carve, on the same chunk):
// Shoot the beam off at its root, then ask what came loose: a
// budgeted span-BFS floods every region the carve touched;
// whatever cannot reach bedrock-anchored ground (and stays under
// the budget) comes back as an `Island` — its voxels, colours and
// bounds. The pillar survives (still anchored); the beam's tip
// and its crystal do not.
let g = scene.grid_mut(grid).expect("grid");
g.set_sphere(IVec3::new(33, 30, 158), 4, None);
let islands = detect_islands(
g,
IVec3::new(29, 26, 154), // the carve's bbox, any corner order
IVec3::new(37, 34, 162),
DEFAULT_ISLAND_BUDGET,
);
for isl in &islands {
println!(
"island: {} voxels, bbox {:?}..{:?}",
isl.voxels.len(),
isl.bbox.0,
isl.bbox.1
);
}
Two properties matter in practice:
- The budget is a design knob, not just a guard. A detached region
bigger than
budgetvoxels stays put —DEFAULT_ISLAND_BUDGET(4096) means shooting the single support out from under a whole gallery will not drop the gallery. Raise it if your game wants building-sized collapses; the flood’s cost isO(min(region, budget))per component, so the worst case is priced in advance. - Support means the format’s bedrock. Every materialised chunk’s column bottom (local z = 255) is uncarvable by construction, so a region touching it can never fall — this holds on stacked-chunk grids too.
Falling
// Hand each island to the debris system. `spawn_island` extracts
// the voxels from the grid (one carve + one incremental re-bake;
// re-mip stays the caller's job, like any edit), splits them per
// the fracture tables — rock into rounded Voronoi lumps, crystal
// into sharp plates that keep their emissive material — and
// registers every fragment as a falling body at the exact world
// pose of the voxels it replaced.
let mut debris = DebrisSystem::new();
debris.set_fracture_patterns(
&[(CRYSTAL.rgb_part(), CRYSTAL_MATERIAL)],
&[
(0, FracturePattern::Chunks { cell: 6 }),
(CRYSTAL_MATERIAL, FracturePattern::Shards { plates: 3 }),
],
);
for isl in islands {
debris.spawn_island(&mut scene, grid, isl, BakeMode::Directional);
}
println!("falling fragments: {}", debris.debris_count());
spawn_island does the irreversible part: the island’s voxels leave
the grid (a coalesced carve plus one incremental re-bake — re-mip is
your job, exactly as for your own edits; fold the island’s bbox into
whatever remip_bbox call your carve path already makes, or distant
mips keep drawing the rock that just fell). The sprite appears at the
island’s world_pivot, so nothing visually jumps — the voxels become
a falling body in place.
Fracture is data, keyed by material: Chunks { cell } breaks matter
into rounded jittered-Voronoi lumps (stone), Shards { plates }
slices it with near-parallel planes (glass, crystal); unmapped
materials fall Whole. A mixed island splits per material group, and
with the colour→material map installed the fragment sprites register
with it — a crystal shard keeps its translucent, emissive material
and glows all the way down (chapter 6). Fragments get a
small outward drift (fracture_impulse) so a broken slab visibly
comes apart instead of falling as a stack.
Physics is deliberately voxlap-simple and deterministic: vertical gravity with a terminal clamp, a cosmetic spin hashed from the island’s position (identical scenes crumble identically), and collision against the scene’s solid voxels with the descent marched in substeps — a fast body cannot skip through a one-voxel shelf on a slow frame. The AABB is the island’s unrotated box; the spin never affects collision.
Landing and shattering
// Per frame a windowed host calls `debris.tick(renderer, &scene,
// dt)` and shatters each drained impact into a colour-true
// particle burst (`ParticleSystem::voxel_debris(&hit.burst_sites(),
// …)`). The windowless half is `update` + `drain_impacts`: each
// landed fragment reports where it hit, how fast, and the
// world-space burst sites — one per voxel, in the voxel's own
// colour — a particle system scatters.
for _ in 0..600 {
debris.update(&scene, 1.0 / 60.0);
for hit in debris.drain_impacts() {
println!(
"impact at z={:.1} @ {:.1} u/s -> {} burst sites",
hit.pos.z,
hit.speed,
hit.burst_sites().len(),
);
}
}
assert_eq!(debris.debris_count(), 0, "everything landed");
A windowed host wires the impact to the particle system from
chapter 8 — burst_sites() hands back one world-space
site per island voxel, in that voxel’s own colour, positioned where
the body landed:
for hit in debris.drain_impacts() {
particles.voxel_debris(&hit.burst_sites(), from, 4.0..9.0, &burst_def);
}
The burst is the same machinery carve_debris uses for bullet
craters, so crater debris and crumble debris look and behave like the
same rock. Feed hit.pos to your impact sound while you are at it —
the cave demo routes it through the same occlusion-shaded boom as a
bullet hit (chapter 9).
The cave demo’s wiring
roxlap-cave-demo shows the full production shape (chapter
15): detection runs on the background carve worker
(same thread that already carves, relights and re-mips the chunk
clone), the extraction happens there too so the batch’s re-mip covers
it, and the main thread only spawns the returned islands and ticks the
system. Rock is mapped to Chunks, crystals to Shards, and
ROXLAP_NO_CRUMBLE=1 switches the whole thing back to plain carves.
The asset pipeline
Everything roxlap loads lives in
roxlap-formats — a standalone
crate with no renderer dependency, so the same parsers power your
game, your level editor, and your asset-conversion scripts. Every
format follows one discipline: a hand-written parser (no decoder
dependencies), a per-format ParseError that says exactly what broke
and where, hardening against crafted files (allocation caps, bounds
checks), and — for every format the engine can author — a symmetric
writer.
The snippets come from a headless, assertion-checked example:
cargo run -p roxlap-formats --example book_assets
The editor: Demiurg
Demiurg is the voxel asset
editor built on roxlap — its viewport is the engine itself, so what
you paint is byte-for-byte what the game shows (same packed colours,
same z-down world, same lighting). It edits .kv6, .vox and .rkc,
keeps a lossless .demiurg project file, and exports straight to the
engine formats: .kv6 sprite models, .rkc rigged characters
(skeleton + keyframe animation), and .vxl worlds. If you are
authoring assets for roxlap, start there; MagicaVoxel (next section)
is the general-purpose alternative.
Authoring in MagicaVoxel: .vox
The industry-standard voxel editor is the intended authoring tool.
vox::parse reads what every MagicaVoxel build writes — SIZE +
XYZI model pairs and the RGBA palette (files without one get the
official default palette); multi-model files yield their models in
file order:
// MagicaVoxel import: parse the .vox bytes, convert each model to
// a Kv6 ready for `add_sprite_model`. MagicaVoxel is z-UP; the
// conversion flips to roxlap's z-down, so a model that is right-
// side-up in the editor is right-side-up in the engine.
let bytes = synthesize_vox();
let file = vox::parse(&bytes).expect("valid .vox");
assert_eq!(file.models.len(), 1);
assert_eq!(file.models[0].voxels.len(), 2);
let models = file.to_kv6_models();
assert_eq!((models[0].xsiz, models[0].ysiz, models[0].zsiz), (3, 3, 3));
Two things the conversion handles for you:
- The z-flip. MagicaVoxel is z-up, roxlap is z-down;
to_kv6maps(x, y, z)→(x, y, zsiz−1−z), so right-side-up in the editor is right-side-up in the engine. - Colour packing. Palette colours become
VoxColors at the neutral brightness (VoxColor::rgb). Palette alpha is dropped — translucency in roxlap is a material, not a colour channel, so pair the import with a colour→material map (chapter 6).
Scope note: the .vox scene graph (nTRN/nGRP transforms),
materials and cameras are skipped — models come without world
placement; your game places them.
The Voxlap heritage formats
These are the formats Ken Silverman’s tools and games produced — supporting them is why two decades of assets load directly:
| Format | What it is | Module |
|---|---|---|
.kv6 | One voxel sprite model (Slab6) | kv6 |
.kvx | Build-engine voxel model (Shadow Warrior, Blood) | kvx |
.vxl | A whole voxel world, column-compressed (Ace of Spades maps) | vxl |
.kfa | An animation rig over a kv6 (Ken’s animator) | kfa |
Each has parse(bytes) and serialize(..), and the round trip is
byte-stable — serialize(parse(bytes)) == bytes — so a tool can
rewrite an asset it only meant to inspect without churning it:
// Every reader has a symmetric writer, and the round trip is
// byte-stable — serialize(parse(bytes)) == bytes — so tools can
// rewrite assets without churn.
let gem = kv6::serialize(&models[0]);
let reparsed = kv6::parse(&gem).expect("self-authored kv6");
assert_eq!(kv6::serialize(&reparsed), gem);
Worlds have a code-authoring path too — Vxl::from_dense folds any
occupancy predicate into the slab format (this is also how procedural
generators build chunks, chapter 3):
// Worlds: build a Vxl from a dense predicate (the one-call "model
// → slab format" path), then round-trip the .vxl wire bytes. This
// is also the per-chunk encoding scene snapshots use internally.
let world = Vxl::from_dense(64, |x, y, z| {
(z >= 200 && x + y < 100).then_some(VoxColor::rgb(0x4d, 0x8a, 0x3a))
});
let bytes = roxlap_formats::vxl::serialize(&world);
let reparsed = roxlap_formats::vxl::parse(&bytes).expect("self-authored .vxl");
assert_eq!(
reparsed.voxel_color(10, 10, 200),
Some(VoxColor::rgb(0x4d, 0x8a, 0x3a))
);
// Above the terrain is air.
assert_eq!(reparsed.voxel_color(10, 10, 100), None);
roxlap’s own containers
Two formats are roxlap-native, built for what the heritage formats can’t hold:
.rvc— voxel clips (chapter 7): fixed-bbox animation flipbooks, keyframes + deltas, per-frame durations:
// Animated clips: kv6 frames → .rvc bytes → back. All frames must
// share one bounding box (clips are fixed-bbox); the decode yields
// the flipbook the renderer registers (`add_voxel_clip`).
let frames: Vec<_> = [1u32, 2, 3, 2] // a pulsing cube, Chebyshev radius
.iter()
.map(|&r| {
kv6::Kv6::from_fn(7, 7, 7, |x, y, z| {
let d = |v: u32| (i64::from(v) - 3).unsigned_abs();
(d(x).max(d(y)).max(d(z)) <= u64::from(r))
.then_some(VoxColor::rgb(0xd0, 0xa0, 0x50))
})
})
.collect();
let clip = VoxelClip::from_kv6_frames(&frames, 1.0, LoopMode::Loop, &[], 150, 1)
.expect("frames share dims");
let rvc = clip.serialize(); // the on-disk .rvc
let reparsed = VoxelClip::parse(&rvc).expect("self-authored .rvc");
assert_eq!(reparsed.decode().expect("decodes").frames.len(), 4);
.rkc— characters (charactermodule): meshes + skeleton + animation clips in one chunked, forward-compatible container (a reader skips chunk types it doesn’t know, so old engines open new files). Bone attachments reference static meshes or voxel clips.
2D art: GIF and PNG import
With the gif / png cargo features, gif_import / png_import
turn animated GIFs and PNG sequences (or APNGs) into voxel clips —
each frame a 1-voxel-thick cutout slab, transparency and frame timing
preserved. This is the Doom-style billboard pipeline from
chapter 7: 2D sprite sheets in, shadow-casting voxel
objects out.
Saves
Scene snapshots (chapter 3) are the save-game
format: a versioned envelope holding every grid’s config plus its
chunks — each chunk encoded with the same vxl::serialize shown
above. There is no separate “save format” to learn; a snapshot is
made of the wire formats on this page.
The command-line tool
Everything above is also scriptable without writing Rust:
roxlap-cli (in the workspace) wraps the common asset operations —
cargo run -p roxlap-cli -- info castle.vox # identify + summarise any asset
cargo run -p roxlap-cli -- vox2kv6 castle.vox castle.kv6
cargo run -p roxlap-cli -- vox2rvc walk.vox walk.rvc 100 # models → clip frames
info works on every format on this page (including scene
snapshots — it prints the grid list with chunk and edit counts), so
it doubles as a save-file debugger.
Further reading
- docs.rs/roxlap-formats — every module on this page, with per-field format documentation.
roxlap-formats/examples/parse_kv6.rs— a minimal file-from-disk loader.- The
editmodule — the carve/insert span machinery from chapter 3 lives in this same crate, so level tools get it without the renderer.
Picking & world queries
“What did the player click?” and “what’s in front of me?” are the two questions every interactive voxel game asks constantly. roxlap answers them at two levels: screen→world picking on the renderer (unproject a pixel) and world queries on the scene (march a ray, inspect a voxel). Both resolve to the same currency: a grid id plus a grid-local voxel coordinate — exactly what the edit API from chapter 3 consumes, so pick → carve is two calls.
The snippets come from a runnable example — an orbiting camera that outlines the voxel under the screen centre every frame, and carves a crater wherever you click:
cargo run --release -p roxlap-render --example book_picking
Two picking paths — and when to use each
Path 1: view_ray + Scene::raycast — geometric. Unproject the
pixel into a world-space ray under whichever projection the last
frame used (CPU and GPU project differently; view_ray hides that),
then march the scene’s voxels:
// Per-frame hover: unproject the screen centre with
// `view_ray`, march the scene with `Scene::raycast`.
// No depth buffer involved — identical on CPU and GPU
// and cheap enough to run every frame.
let centre = (f64::from(w) * 0.5, f64::from(h) * 0.5);
let hover = renderer
.view_ray(&camera, centre.0, centre.1)
.and_then(|ray| scene.raycast(ray.origin, ray.dir, 2048.0));
if let Some(hit) = hover {
// `hit.voxel` is grid-local; this grid sits at the
// world origin, so the outline needs no transform.
renderer.draw_lines(
&camera,
&voxel_outline(hit.voxel, OverlayColor(0xff_ff_e0_40)),
);
}
No depth buffer involved, identical on both backends, cheap enough
for every frame — hover highlights, crosshair targeting, AI
line-of-sight. The RayHit gives you the grid, the grid-local voxel,
the world-space hit point, the distance t, and the voxel’s colour.
Path 2: pick — depth-based. Read the rendered frame’s z-buffer
at the pixel, reconstruct the world point, resolve it to a voxel:
// Click-time picking: `pick` unprojects the pixel with
// the last frame's projection, reads its depth there,
// and resolves the hit to (grid, grid-local voxel).
// Depth is free on CPU; on GPU it blocks on a readback
// — a click-time call, not a per-frame one.
if let Some(hit) = renderer.pick(scene, &camera, mx, my) {
let grid = scene.grid_mut(hit.grid).expect("hit grid exists");
// Carve a crater, then re-light just the hole.
grid.set_sphere(hit.voxel, 4, None);
let r = IVec3::splat(4);
grid.bake_bbox(
hit.voxel - r,
hit.voxel + r,
roxlap_scene::BakeMode::Directional,
);
}
pick returns exactly what the player sees (it reads the frame that
was actually rendered), but its cost is backend-dependent: the CPU
backend reads an in-memory z-buffer (free), the GPU backend stages
the depth buffer and blocks on a device poll. That makes it a
click-time call, not a per-frame one — the
Feature::FreePickDepth probe from chapter 4 is the
queryable form of this distinction. On the wasm GPU path the
readback cannot block at all, so the pick answers with one frame of
latency — call it again next frame; the semantics table lives in the
Platforms chapter. Two more semantics to know:
sprites don’t occlude the pick (a cursor sprite under the pointer is
transparent to it), and the depth belongs to the last rendered
frame — pass the camera that frame used.
The lower-level pieces are exposed too: pick_depth (just the
distance), pixel_ray (just the un-normalised direction) — for tile
selection, intersect the view ray with a plane instead of the voxels.
World queries without a screen
Both scene-level queries work headless (tools, servers, AI):
Scene::raycast(origin, dir, max_dist)— the same march as path 1, minus the unprojection. Grid rotations are handled: the ray is transformed into each grid’s local frame, and the nearest hit across all grids wins.Scene::resolve_voxel(world, ray_dir)— “which voxel is this world point on?”: used internally bypickto turn a depth hit into a voxel address; useful whenever you already have a world point from other means. Theray_dirnudges the sample off the surface so a point on a face resolves to the solid side.Grid::voxel_solid/voxel_color— the point queries from chapter 3, for when you already know the grid.
Collision: use the controller
Movement collision is not a picking pattern anymore: the engine ships
CharacterBody — a walking body with move-and-slide collision,
gravity, jumping and step-up, covered in
the scene-graph chapter (with a runnable
book_controller example). Reach for the raw queries on this page
only for non-character shapes (projectiles want Scene::raycast,
area effects want the point queries).
Further reading
- The Picking demo scene — cursor-follow pick mode over a
multi-grid world
(
ROXLAP_SCENE=Picking cargo run --release -p roxlap-scene-demo). - docs.rs/roxlap-render (
pick,view_ray,pick_depth,pixel_ray,PickHit,Ray) and docs.rs/roxlap-scene (raycast,resolve_voxel,RayHit).
Platforms
One Cargo workspace covers Linux, Windows, macOS (x86_64 and arm64)
and the browser — no C dependencies, no per-platform source trees.
The per-architecture work is in SIMD kernels (SSE2 / NEON /
WebAssembly simd128, all via core::arch, with a scalar fallback
as the correctness reference) and in presentation, which is what this
chapter is about.
Windowing: bring your own loop
SceneRenderer::new takes Arc<W> where W: HasWindowHandle + HasDisplayHandle + Send + Sync — the
raw-window-handle traits, not any
specific windowing crate. The facade re-exports the bounds, so:
- winit — what the quickstart and every book example use.
- SDL2 — the
roxlap-sdl-democrate is the worked example (including the wrapper that makes SDL’s handleSend + Sync). - Anything else (GLFW, a custom engine shell) — implement the two
traits, pass the size explicitly, report changes via
resize.
Presentation is backend-internal: the GPU backend owns a wgpu swapchain; the CPU backend presents through softbuffer on native. You never touch either.
The browser
The same engine runs on wasm32 — both demos are live proof:
cd crates/roxlap-web # engine demo (~360 KB wasm + 18 KB JS)
trunk serve # → http://localhost:8080
cd crates/roxlap-cave-web # caves + crystals + carving + crumble
trunk serve --features audio # optional: the full soundscape
Construction differs (there is no raw-window-handle in a browser):
SceneRenderer::new_from_canvas_async(canvas, size, &opts) — async
because the browser drives wgpu’s adapter request through its event
loop; run it in a wasm_bindgen_futures::spawn_local task.
The backend story mirrors native, with one twist per layer:
- GPU = WebGPU via wgpu, where the browser supports it.
- CPU fallback = the same software DDA (with
simd128batches), presented through a WebGL2 blit of the framebuffer — so even browsers without WebGPU get the engine. The constructor never fails; failures log to the console and fall through. RequireGpudegrades toPreferGpu(the async constructor has no error channel), andsupports(Feature::Capture)isfalse— WebGPU cannot block on a readback.
Threads. CPU rendering fans out over Web Workers via
wasm-bindgen-rayon — a 4-core phone gets roughly 3× the
single-threaded frame rate. The catch is deployment: worker shared
memory needs SharedArrayBuffer, which browsers only enable under
cross-origin isolation. Your host must send two headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Without them the thread pool silently doesn’t spin up and you get the
single-threaded rate. (Building with threads also needs the nightly
toolchain pinned in the web crates — -Z build-std with atomics;
plain library consumers on stable are unaffected.) Per-host header
recipes, mobile touch controls, and the full setup live in
crates/roxlap-web/README.md.
Audio. The kira backend runs on WebAudio unchanged — same
crate, same API as the Audio chapter. The one
browser-specific rule is the autoplay policy: the gesture is the
constructor. An AudioContext created outside a user-gesture
handler starts suspended and, on some browsers, never recovers — so
build the audio system lazily inside the first click / first touch
handler, never during init:
// In the click handler — the FIRST user gesture:
if state.audio.is_none() {
state.audio = WebAudio::new(); // constructs KiraAudio HERE
}
Heavy main-thread frames can starve the browser’s audio callback into
crackling; the demos accept that. If the whole demo stutters, check
the build profile first — trunk serve defaults to a debug build,
and a debug-built renderer is slow enough to read as an engine
problem (the cave demo’s Trunk.toml sets release = true for this
reason).
Picking. pick_depth / pick read the GPU depth buffer back,
and WebGPU has no blocking readback — so the wasm GPU path answers
with one frame of latency: a call submits the readback for its
pixel and returns the latest completed result. Poll it (call again
next frame); the first call after a click usually returns None, and
the value that eventually arrives may belong to the previously
requested pixel. Everywhere else picking is synchronous:
| Backend | pick_depth / pick semantics |
|---|---|
| CPU (all targets) | synchronous — in-memory z-buffer, free |
| GPU, native | synchronous — blocking readback, click-time cost |
| GPU, wasm | one-frame latency — submit now, poll next frame |
Hosts that need a synchronous answer in the browser keep the CPU
backend, or skip the depth buffer entirely with the depth-free
view_ray + Scene::raycast composition from the
Picking chapter — that path is identical on every
backend and target.
What CI actually proves
Every push runs the full test suite on x86_64 Linux, Apple Silicon
macOS and aarch64 Linux — the DDA renderer’s hash tests are plain
IEEE f32 arithmetic and pass bit-identically on all three, so
“pixel-identical across architectures” is a gated claim, not an
aspiration. The wasm32 build is type- and lint-gated (cargo clippy
on the pinned nightly, including the browser audio stack); GPU tests
self-skip on runners without an adapter and run where one exists
(Metal on the mac runners).
Troubleshooting
Symptoms first — each of these has burned somebody:
- “I asked for the GPU but I’m on the CPU renderer” — the
fallback logs why through the [
log] facade, and nothing shows without a logger: install one (env_logger::init()in the demos) before debugging anything else.adapter_info()returningNoneis the runtime tell. - No Vulkan on NixOS (and friends) — outside a dev shell that
provides it, wgpu needs
libvulkan.so.1onLD_LIBRARY_PATH(match the ELF bitness). The repo’sflake.nixwires it up;nix developis the easy path. - Hybrid-GPU laptops (PRIME/offload) — if the discrete GPU path
misbehaves (driver/compositor sync bugs present exactly this way),
ROXLAP_GPU_POWER=lowsteers wgpu to the integrated adapter without a rebuild;highforces discrete. cargo buildfails on-lSDL2— onlyroxlap-sdl-demolinks system SDL2, and it is excluded from the workspace’sdefault-membersfor exactly this reason. Install the SDL2 dev package or just don’t build-p roxlap-sdl-demo.- Web crates fail on stable Rust —
roxlap-web/roxlap-cave-webpin a nightly viarust-toolchain.toml(-Z build-std+ atomics for the worker pool). Rustup honours the pin automatically inside those directories; library consumers on stable are unaffected. - Browser runs single-threaded — the COOP/COEP headers above are
missing.
crossOriginIsolated === falsein the console confirms it; the thread pool fails silently by design.
Further reading
PORTING-WASM.md— the R10 series: SIMD batches, worker-pool threading, bundle-size history.- Rendering & backends — the
BackendPreferencesemantics this chapter’s fallbacks hang off.
Performance & tuning
Both renderers are per-pixel ray-marchers, so nearly every knob in this chapter bounds one of two quantities:
frame cost ≈ (pixels marched) × (steps per ray)
Pixels are bounded by the render pipeline (chapter 5); steps per ray by scan distances and mip ladders (below); and everything else is about not doing per-frame work you don’t need.
Bounding the ray
OpticastSettings (built per frame, passed via FrameParams) owns
the CPU marcher’s budget:
max_scan_dist— the hard ray-length cap, in voxels. The single biggest lever; pair it withFrameParams::fog_max_scan_dist(chapter 4) so the cutoff reads as atmosphere instead of a wall.mip_levels/mip_scan_dist— the CPU mip ladder: rays beyondmip_scan_diststep through progressively coarser copies of the world, so distance costs logarithmically instead of linearly. The demos runmip_levels = 6,mip_scan_dist = 64. Per-grid opt-out:Grid::mip_levels_override(small rotated object grids don’t benefit).
The GPU marcher takes its step budget from the same settings
(FrameParams::gpu_outer_steps derives it) and has its own LOD
distance: RenderOptions::gpu_mip_scan_dist — rays farther than this
sample coarser chunk mips (2× frame-rate at the horizon in the GPU.11
measurements).
Bounding the world
- Streaming radii (chapter 3):
r_activeis simulation/render breadth,r_evict − r_activeis the hysteresis. The World demo runs (256, 384). - Upload budgets (GPU backend,
RenderOptions):gpu_chunk_upload_budgetcaps dirty-chunk installs per frame (small = bounded frame spikes, more pop-in when flying fast);gpu_clip_upload_budgetdoes the same for clip registration. - Threads: CPU rendering parallelises over rayon internally —
bound the pool with the standard
RAYON_NUM_THREADS. Streaming generation has its own pool:Scene::set_streaming_threads(n).
Lessons the engine already paid for
The PF/PR performance series distilled into host-side rules:
- Never read env vars (or any syscall-ish config) per frame. The
single worst cliff in roxlap’s history was an
env::var_osinside a per-step loop; the recovery series it headlined took the engine-only frame rate from ~12 to ~47 FPS. Cache at startup; the engine does the same with its own overrides. - Batch bulk transforms —
set_sprite_instance_transforms(plural) over per-instance calls in a loop (chapter 7). - Re-light the edit, not the world —
bake_bboxafter runtime carves (chapter 6). - Let quiet frames stay quiet. Change detection is version-based: don’t touch chunks (or bump versions) you didn’t modify, and the engine’s dirty-poll and re-mip sweeps skip themselves.
Engine environment variables
The library crates read five variables, all init-time overrides
of RenderOptions / adapter selection (never per frame — see above):
| Variable | Overrides | Meaning |
|---|---|---|
ROXLAP_GPU_MIP_SCAN_DIST | RenderOptions::gpu_mip_scan_dist | GPU LOD distance (world units) |
ROXLAP_GPU_CHUNK_BUDGET | RenderOptions::gpu_chunk_upload_budget | Dirty-chunk installs per frame (0 = unbounded) |
ROXLAP_GPU_CLIP_BUDGET | RenderOptions::gpu_clip_upload_budget | Clip-upload staging flushes (0 = unbounded) |
ROXLAP_GPU_POWER | GpuRendererSettings::power_preference | low | high adapter preference — also the escape hatch when a discrete-GPU driver misbehaves |
ROXLAP_GPU_SPRITE_LOD_PX | RenderOptions::gpu_sprite_lod_px | Sprite mip-LOD threshold, screen px (1.0 = CPU-identical; raise for distant-sprite speed) |
(ROXLAP_GPU itself — the backend switch you’ve seen in every example
— is a demo convention, not an engine variable: the examples read it
and pick a BackendPreference. Your game should expose its own
setting.) A RenderConfig consolidation of these into one struct is
on the quality-stage backlog (QE-C6); until then the env overrides
win over the corresponding RenderOptions fields.
CPU or GPU?
Feature parity is queryable — supports() and the Feature
rustdoc table
(chapter 4); the book doesn’t copy it. The
performance character is simple: the GPU backend is several times
faster at the same pixel count and shrugs at resolution; the CPU
backend’s cost tracks pixels almost linearly — so on the CPU path,
fixed logical resolution (chapter 5) is not a
styling choice, it is the perf model. Measure with RequireGpu on
rigs where a silent CPU fallback would corrupt the numbers.
Further reading
PORTING-PERF.md— the PF series: what got 12.8× raycasts and 23× bbox re-bakes.PORTING-QUALITY.md— the QE backlog, including theRenderConfigconsolidation.
Demo tour
roxlap-scene-demo is the engine’s living feature gallery: eleven
scenes behind one host, each built to showcase one subsystem — and
each chapter of this book uses “its” scene as the worked example.
cargo run --release -p roxlap-scene-demo # CPU backend
ROXLAP_GPU=1 cargo run --release -p roxlap-scene-demo # GPU backend
ROXLAP_SCENE=Doom cargo run --release -p roxlap-scene-demo # start on a scene
Global controls: Tab opens the scene menu, F1 toggles the HUD
(FPS, camera, per-scene status, and the live Render pipeline
panel from chapter 5), Esc releases the
cursor. WASD + mouse-look everywhere.
The scenes
| Scene | Showcases | Book chapter |
|---|---|---|
| World | Streaming hills + a rotating ship: multi-grid composition, chunk streaming, LOD billboards, collision-checked flight. R spins the ship, T prints streaming stats, H jumps to a top-down vantage. | 3 |
| Sprites | Sprite models + the dynamic add/remove API: a checkerboard coco field, a shoot-to-carve blob, a streaming spinner ring, per-instance tints. | 7 |
| Animation | KFA skeletal animation + an .rkc character with a flame voxel-clip attachment, side by side. | 7 |
| Transparency | Alpha glass, additive glow, pulsing smoke, a mixed-material window, a volumetric fog cloud — over translucent terrain. | 6 |
| Lighting | The runtime rig: sweeping shadow-casting sun + orbiting point lights, stylized bands (J, [/]), live AO retuning (N/M). | 6 |
| Spotlight | One spot cone in a dark room: F flashlight mode, O sweep, [/] cone width, K shadows. | 6 |
| Particles | The particle system: fountain over a water pool, smoke column, crosshair-aimed explosions that carve craters. | 8 |
| Audio | The acoustics core made walkable: stone room / open field / glass cavern with live occlusion, reverb environment and Doppler numbers in the HUD (from the pure core — every build has the tab; --features audio also plays it). | 9 |
| Scale | Per-grid voxel_world_size: a chunky planet (4.0) + a fine bobbing ship (0.25) at a true 16× ratio in one scene; P pauses the bob, K toggles sun shadows. | 3 |
| Doom | GIF billboard sprites + an 8-way directional actor (Q/E turn it, Space walk/idle) — synthesised as GIFs at startup, so the full import path runs live. | 7 |
| Picking | Top-down cursor picking: view_ray/pick, snap-to-surface, click-to-mark. | 12 |
| Primitives | Overlay gizmos (draw_lines) + 2D image quads (draw_images) + the pick_image eyedropper. | 4 |
| Empty | Just sky — the minimal DemoScene contract, and a handy GPU-vs-CPU present-cost baseline. | — |
Demo environment variables
These belong to the demo host, not the library (the engine’s own variables are in chapter 14):
| Variable | Effect |
|---|---|
ROXLAP_GPU=1/0 | Prefer the GPU backend / force CPU (all demos + examples) |
ROXLAP_SCENE=<name> | Start on a scene (table above) |
ROXLAP_RENDER_RES=WxH|native|<scale>, ROXLAP_SSAA=n, ROXLAP_POSTERIZE=n, ROXLAP_DITHER=bayer|blue|none | Seed the render-pipeline panel from the environment |
ROXLAP_STATIC=1 | World scene: static 32×32 ground instead of streaming hills (A/B + repro) |
ROXLAP_STACKED_GROUND=1 | World scene: the stacked-chunk ground variant (regression repros) |
ROXLAP_NO_MARKERS=1 | World scene: drop the LOD marker pillars (engine-only perf runs) |
ROXLAP_SPRITE_GRID=n | Sprites scene: coco field size (default 4) |
ROXLAP_NO_CRUMBLE=1 | Cave demo: plain carves, no island crumble (chapter 10) |
ROXLAP_RKC=path, ROXLAP_RKC_DUMP=path, ROXLAP_KFA_DUMP=path | Animation scene: load / dump character containers |
ROXLAP_AUTOFIRE=1, ROXLAP_NOFLASH=1, ROXLAP_FPS_LOG=1 | Particles / host probes: scripted explosions, flash A/B, per-second FPS log |
ROXLAP_CAPTURE=<dir> (+ _MS, _FRAMES), ROXLAP_CAMERA=x,y,z,yaw,pitch | Gallery recorder: write PPM frames on a wall-clock interval, then exit; override the start pose for framing |
(roxlap-host and the GPU probe example carry a few more of the same
flavour — ROXLAP_HOST_SPRITE_*, ROXLAP_GPU_RES — documented in
their own sources.)
Beyond the gallery
Two more demos ship in the workspace and double as integration
references: roxlap-cave-demo (procedural caves, plasma-bullet
carving with local relight — the README quickstart; rock the carve
disconnects crumbles: it detaches, falls and bursts into debris,
crystals shattering into glowing plates, chapter 10;
run it with --features audio for voxel-muffled sound,
chapter 9) and the web demos from
chapter 13. And the book’s own
examples (quickstart, book_* in roxlap-render, roxlap-scene,
roxlap-core, roxlap-formats, roxlap-audio) are deliberately
minimal single-topic hosts — often the better starting point for your
own project.
For a complete game on the engine, see the community-built roxlap-game-demo: a mining ship in procedurally generated asteroid fields — physics flight, cannon + tractor beam, crystal collection, an energy economy — everything this book covers, assembled into one playable loop.