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

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.