Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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. One SceneRenderer type that owns the backend choice (CPU or GPU), presentation, sprites, picking, and the post pipeline. Your game calls this.
  • roxlap-scene — the world. Scene holds many independently-placed chunked voxel Grids in one f64 world, with edits, streaming, and snapshots.
  • roxlap-core — the Camera and 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 neutral 0x80.
  • 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:

  1. Build a Camera. Use the constructors (orbit, from_yaw_pitch, look_at) — they produce the right-handed basis the engine’s frustum culling expects.
  2. Build FrameParams from OpticastSettings for the current surface size, then override what you need (sky and fog colour here).
  3. render draws the scene into the backend’s target; present puts 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

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 = 0 is the top (sky), z = 255 the bottom (bedrock). Ground surfaces typically sit somewhere in the 200s, filling downward — that is why the quickstart’s grass slab is set_rect(.., z=210 ..= z=254) with the dome above it at z = 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 neutral 0x80, 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 constructorsCamera::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 stable GridIds, answers world-level queries (raycast, resolve_voxel — chapter 11).
  • Grid — one voxel volume: a GridTransform (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 the name field 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_up voxels.
  • 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 binds G and C to 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):

  • PhysicsWaterVolumes on the grid: grid-local voxel AABBs whose top face (min z — +z is down) is the surface. Scene:: water_depth_at / in_water answer in world units under any grid transform, and a Walk-mode CharacterBody submerged past swim_enter_frac of its height swims automatically: buoyancy against gravity floats it bobbing at the surface, WalkInput::jump strokes up (and breaches into a real jump when the head is above water — one-shot until released), the new WalkInput::sink dives. 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:

  1. tick(&camera, dt) — advances every facade-owned animation (clips, characters, billboard actors) in one call. Only needed once you use those (chapter 7).
  2. render(&mut scene, &camera, &frame) — composites the scene into the backend’s frame buffer. Does not present.
  3. Overlays (optional) — draw_lines / draw_images draw into the composited frame, using its camera and depth buffer.
  4. Exactly one of present() or paint_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

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:

  1. March at logical × ssaa pixels — the only expensive stage.
  2. SSAA resolve — box-downfilter the supersamples back to one colour per logical pixel.
  3. Posterize (optional) — dither + quantize each channel at the logical resolution.
  4. 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, direction is the way the light travels. With +z down, keep a positive z component to stay above the horizon.
  • Point (PointLight) — world position, hard radius cutoff (zero contribution beyond it; keep radii tight, they bound the shading work).
  • Spot (SpotLight) — a point light with a cone: direction is the axis, inner_angle_deg/outer_angle_deg are 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-back over compositing. 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 is 1 − (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),
        ]);
  • Terrainset_terrain_materials maps voxel colours (low 24 bits) to material ids: glass walls and water pools built with plain set_rect calls.
  • Whole sprite instanceset_sprite_instance_material(id, mat); pair with set_sprite_instance_alpha to pulse opacity per frame without touching the volume.
  • Per voxeladd_sprite_model_with_materials / add_voxel_clip_with_materials take 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 BillboardLightingFullBright 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:

  • .rkc characters — 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, tick advances it every frame, and set_character_world_transform moves it. Author them in Demiurg, the roxlap asset editor (chapter 11); the wire format is roxlap_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 the KfaSprites and calls set_kfa_sprites / update_kfa_poses. See the roxlap-host demo. New projects should prefer .rkc.

Further reading

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:

  • Spawningspawn (Rate(n) per second with fractional accumulation, Burst(n) once on add, Manual + explicit burst() calls), shape (point / sphere / box around pos), lifetime (a sampled range).
  • Motionvelocity (a fixed base + isotropic spread + an optional ConeDef, composed by addition), gravity (positive z is down: the default [0, 0, 22] falls; smoke rises with a negative-z term), drag, spin.
  • Lookscalescale_end (growing smoke, shrinking sparks), fade_in_frac / fade_out_frac (alpha ramps at the ends of life), tinttint_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 Scene plus 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 kira cargo feature; a host with its own audio stack implements the AudioOut trait 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::raycast and 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_islands finds 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 an Island — 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 budget voxels 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 is O(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 8burst_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_kv6 maps (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:

FormatWhat it isModule
.kv6One voxel sprite model (Slab6)kv6
.kvxBuild-engine voxel model (Shadow Warrior, Blood)kvx
.vxlA whole voxel world, column-compressed (Ace of Spades maps)vxl
.kfaAn animation rig over a kv6 (Ken’s animator)kfa

Each has parse(bytes) and serialize(..), and the round trip is byte-stableserialize(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 (character module): 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 edit module — 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 by pick to turn a depth hit into a voxel address; useful whenever you already have a world point from other means. The ray_dir nudges 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-demo crate is the worked example (including the wrapper that makes SDL’s handle Send + 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 simd128 batches), 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.
  • RequireGpu degrades to PreferGpu (the async constructor has no error channel), and supports(Feature::Capture) is false — 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:

Backendpick_depth / pick semantics
CPU (all targets)synchronous — in-memory z-buffer, free
GPU, nativesynchronous — blocking readback, click-time cost
GPU, wasmone-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() returning None is the runtime tell.
  • No Vulkan on NixOS (and friends) — outside a dev shell that provides it, wgpu needs libvulkan.so.1 on LD_LIBRARY_PATH (match the ELF bitness). The repo’s flake.nix wires it up; nix develop is 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=low steers wgpu to the integrated adapter without a rebuild; high forces discrete.
  • cargo build fails on -lSDL2 — only roxlap-sdl-demo links system SDL2, and it is excluded from the workspace’s default-members for exactly this reason. Install the SDL2 dev package or just don’t build -p roxlap-sdl-demo.
  • Web crates fail on stable Rustroxlap-web / roxlap-cave-web pin a nightly via rust-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 === false in 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 BackendPreference semantics 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 with FrameParams::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 beyond mip_scan_dist step through progressively coarser copies of the world, so distance costs logarithmically instead of linearly. The demos run mip_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_active is simulation/render breadth, r_evict − r_active is the hysteresis. The World demo runs (256, 384).
  • Upload budgets (GPU backend, RenderOptions): gpu_chunk_upload_budget caps dirty-chunk installs per frame (small = bounded frame spikes, more pop-in when flying fast); gpu_clip_upload_budget does 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_os inside 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 transformsset_sprite_instance_transforms (plural) over per-instance calls in a loop (chapter 7).
  • Re-light the edit, not the worldbake_bbox after 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):

VariableOverridesMeaning
ROXLAP_GPU_MIP_SCAN_DISTRenderOptions::gpu_mip_scan_distGPU LOD distance (world units)
ROXLAP_GPU_CHUNK_BUDGETRenderOptions::gpu_chunk_upload_budgetDirty-chunk installs per frame (0 = unbounded)
ROXLAP_GPU_CLIP_BUDGETRenderOptions::gpu_clip_upload_budgetClip-upload staging flushes (0 = unbounded)
ROXLAP_GPU_POWERGpuRendererSettings::power_preferencelow | high adapter preference — also the escape hatch when a discrete-GPU driver misbehaves
ROXLAP_GPU_SPRITE_LOD_PXRenderOptions::gpu_sprite_lod_pxSprite 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 the RenderConfig consolidation.

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

SceneShowcasesBook chapter
WorldStreaming 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
SpritesSprite models + the dynamic add/remove API: a checkerboard coco field, a shoot-to-carve blob, a streaming spinner ring, per-instance tints.7
AnimationKFA skeletal animation + an .rkc character with a flame voxel-clip attachment, side by side.7
TransparencyAlpha glass, additive glow, pulsing smoke, a mixed-material window, a volumetric fog cloud — over translucent terrain.6
LightingThe runtime rig: sweeping shadow-casting sun + orbiting point lights, stylized bands (J, [/]), live AO retuning (N/M).6
SpotlightOne spot cone in a dark room: F flashlight mode, O sweep, [/] cone width, K shadows.6
ParticlesThe particle system: fountain over a water pool, smoke column, crosshair-aimed explosions that carve craters.8
AudioThe 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
ScalePer-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
DoomGIF 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
PickingTop-down cursor picking: view_ray/pick, snap-to-surface, click-to-mark.12
PrimitivesOverlay gizmos (draw_lines) + 2D image quads (draw_images) + the pick_image eyedropper.4
EmptyJust 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):

VariableEffect
ROXLAP_GPU=1/0Prefer 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|noneSeed the render-pipeline panel from the environment
ROXLAP_STATIC=1World scene: static 32×32 ground instead of streaming hills (A/B + repro)
ROXLAP_STACKED_GROUND=1World scene: the stacked-chunk ground variant (regression repros)
ROXLAP_NO_MARKERS=1World scene: drop the LOD marker pillars (engine-only perf runs)
ROXLAP_SPRITE_GRID=nSprites scene: coco field size (default 4)
ROXLAP_NO_CRUMBLE=1Cave demo: plain carves, no island crumble (chapter 10)
ROXLAP_RKC=path, ROXLAP_RKC_DUMP=path, ROXLAP_KFA_DUMP=pathAnimation scene: load / dump character containers
ROXLAP_AUTOFIRE=1, ROXLAP_NOFLASH=1, ROXLAP_FPS_LOG=1Particles / host probes: scripted explosions, flash A/B, per-second FPS log
ROXLAP_CAPTURE=<dir> (+ _MS, _FRAMES), ROXLAP_CAMERA=x,y,z,yaw,pitchGallery 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.)

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.