Audio
Most voxel engines bolt sound on as an afterthought: a positional
source, a distance falloff, done. roxlap’s roxlap-audio crate does
what the renderer does — it reads the actual voxels. A shot behind a
wall is muffled by exactly as much rock as the sound crosses; a gun
fired in a cavern rings, the same gun in a crawl-space is dry.
The crate splits cleanly in two, and the split is the whole design:
- The acoustics core turns a
Sceneplus a source and listener into plain numbers — an occlusion gain + lowpass cutoff per source, a reverb feedback + wet mix per listener. It owns no audio device, spawns no thread, and is fully deterministic, so it’s unit-tested against synthetic caves and you can call it from any thread. - A playback backend applies those numbers. The built-in one wraps
kira behind the
kiracargo feature; a host with its own audio stack implements theAudioOuttrait instead.
The snippets below come from a runnable example that prints the numbers the core computes for a little scene — no audio device needed:
cargo run -p roxlap-audio --example book_audio
Occlusion: hearing through rock
A source in a sealed room, a listener outside, a wall with a doorway:
// A source in a sealed stone room, a listener out in the open, and
// a 3-voxel wall between them (with a doorway punched to one side),
// all standing on a stone floor (z is DOWN, so "the ground" is the
// high-z slab everything rests on).
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::identity());
let grid = scene.grid_mut(id).expect("grid just added");
grid.set_rect(
IVec3::new(0, 0, 150),
IVec3::new(127, 127, 170),
Some(STONE),
); // floor
grid.set_rect(IVec3::new(6, 40, 96), IVec3::new(40, 88, 150), Some(STONE));
grid.set_rect(IVec3::new(10, 44, 100), IVec3::new(36, 84, 146), None);
grid.set_rect(IVec3::new(60, 40, 96), IVec3::new(62, 88, 150), Some(STONE));
grid.set_rect(IVec3::new(60, 62, 100), IVec3::new(62, 66, 112), None); // doorway
let source = DVec3::new(22.0, 64.0, 120.0); // inside the room
Occlusion is a small fan of rays from the source to the listener. Each
ray accumulates the solid thickness it crosses — not a yes/no hit,
the actual voxel path length — and the average becomes a transmission
in 0..=1 that drives a muffling lowpass and a volume drop:
// Occlusion is a fan of rays from the source to the listener, each
// accumulating the SOLID THICKNESS it crosses; the average becomes a
// transmission that drives a muffling lowpass + a volume drop.
let cfg = AcousticsConfig::default();
for (label, listener) in [
("line of sight", DVec3::new(30.0, 64.0, 120.0)),
("through the wall", DVec3::new(80.0, 64.0, 120.0)),
("past the doorway", DVec3::new(80.0, 64.0, 106.0)),
] {
let a: SourceAcoustics = source_acoustics(&scene, source, listener, &cfg);
println!(
"{label:>16}: transmission {:.2} cutoff {:>6.0} Hz gain {:+.1} dB",
a.transmission, a.lowpass_cutoff_hz, a.gain_db,
);
}
line of sight: transmission 1.00 cutoff 20000 Hz gain -0.0 dB
through the wall: transmission 0.17 cutoff 1399 Hz gain -19.8 dB
past the doorway: transmission 0.36 cutoff 2527 Hz gain -15.4 dB
Thickness is why this reads as sound and not as a gate: one voxel of
wall muffles less than five, and because the fan spreads, a doorway off
the direct line lets some rays through — the listener past the door
hears the source brighter and louder than through the sealed wall, but
still not clear. Sound leaks around corners instead of snapping on and
off. AcousticsConfig tunes the fan (ray count, jitter radius,
per-voxel absorption, the cutoff endpoints).
Reverb: the size of the space
The second half fires a fan the other way — from the listener, outward in every direction. How far the rays travel before hitting a wall reads as room size; the fraction that escape to open sky reads as openness:
// Reverb comes from a fan of rays around the LISTENER: the mean
// enclosed free path reads as room size (feedback), the fraction
// escaping to open sky as dryness. A `CavityEstimator` smooths the
// raw probe over time — call it at ~2 Hz, not every frame.
let mut cavity = CavityEstimator::new(CavityConfig::default());
for (label, listener) in [
("in the room", DVec3::new(22.0, 64.0, 120.0)),
("out in the open", DVec3::new(90.0, 64.0, 120.0)),
] {
// The first update seeds from the raw probe (no fade-in); in a
// game you'd feed each result to the reverb with a ~1 s tween.
cavity.reset();
let env = cavity.update(&scene, listener);
println!(
"{label:>16}: openness {:.2} reverb feedback {:.2} mix {:.2}",
env.openness, env.reverb_feedback, env.reverb_mix,
);
}
in the room: openness 0.00 reverb feedback 0.53 mix 0.50
out in the open: openness 0.59 reverb feedback 0.66 mix 0.00
A CavityEstimator maps that to reverb feedback (decay) and a wet mix,
and — crucially — smooths it over time. Real rooms don’t change
acoustics the instant you step through a doorway, and a per-frame probe
would jitter the reverb audibly; the estimator eases toward each new
reading over a second or so, which players read as natural. Call it at
a couple of hertz, not every frame, and feed the result to the reverb
with a long (~1 s) tween. Note the open-air line: even outdoors, half
the fan hits the ground the listener stands on (openness 0.59, not
1.0) — so dryness can’t come from openness reaching 1. Instead the
wet mix falls to zero once openness crosses a threshold (default
0.5, an open-ground listener’s floor), which is what makes outdoors
read as dry however far the sky-bound rays fly. CavityConfig tunes
the fan size, that outdoor-openness threshold, and the
feedback / wet-mix ranges.
Playback: the AudioOut boundary
The core hands you parameters; a backend turns them into sound. That
seam is the AudioOut trait — register a sound, set_listener,
play / play_loop a source, and apply_source / apply_listener
the computed acoustics:
// Per acoustic tick (roxlap-audio computes, the backend applies):
let occ = source_acoustics(&scene, source, listener, &acfg);
audio.play(shot, source, Some(&occ)); // starts already muffled
let env = cavity.update(&scene, listener); // ~2 Hz
audio.apply_listener(&env); // ~1 s tween
The built-in KiraAudio (feature kira) implements it with a proper
aux-send topology: one shared reverb on a send track, a pool of spatial
voices each carrying its own lowpass filter and a per-source reverb
send. Parameters are applied with tweens — fast (~120 ms) for
per-source occlusion and the listener pose, slow (~1 s) for the reverb
environment — so nothing zippers. A fresh one-shot, though, starts
already at its occluded parameters (play’s initial argument): a
gunshot’s envelope is gone in a tenth of a second, long before a ramp
would arrive, so it must be muffled from the first sample. Voices are a
fixed pool allocated up front and reused — one-shots steal the oldest
finished voice when the pool is full; loops hold their slot until
stopped.
The kira backend needs an audio device (ALSA on Linux; WebAudio in
the browser — kira runs on wasm out of the box). It’s off by default,
so a plain build pulls in no audio stack at all. The one
browser-specific rule — construct the audio system inside a user
gesture — lives in the Platforms chapter; the cave
web demo (trunk serve --features audio) is the worked example.
One more listener-side knob (stage WT): AudioOut::set_listener_lowpass
muffles the whole mix — every voice and the reverb tail — for the
submerged ear. Drive it per frame from CharacterBody::eye_in_water
(deliberately not part of the reverb environment: that rides a ~2 Hz
probe cadence, and a head-dip must muffle at frame rate). On kira it
is an opt-in master-track filter (KiraAudio::with_options);
LISTENER_LOWPASS_SUBMERGED_HZ is the canonical underwater cutoff.
Materials: glass sounds thin
By default every solid voxel muffles alike. The material tables (stage
AU2) change that — and they take the same colour→material map the
renderer’s set_terrain_materials and the debris system’s fracture
tables use, so “this colour is crystal” is declared once and drives
rendering, destruction and sound together. absorption re-weighs a
material’s effective thickness in the occlusion march (a 0.35
glass voxel muffles like a third of a stone one); damping_override
re-colours the reverb of whatever walls the cavity probe hits — a
glass chamber rings brighter than a stone one:
// AU2 — materials change what the rays hear. The colour→material
// map is the SAME one the renderer's `set_terrain_materials` (and
// the debris system's fracture tables) take; `absorption` weighs a
// wall's effective thickness, `damping_override` recolours the
// reverb of the walls around the listener. Everything is PAINTED,
// never carved — a carve leaves colour-less faces the classifier
// can't read.
let grid = scene.grid_mut(id).expect("grid");
// A 3-voxel glass pane…
grid.set_rect(
IVec3::new(76, 20, 100),
IVec3::new(78, 40, 140),
Some(GLASS),
);
// …and a small sealed glass booth (six painted slabs).
let (bl, bh) = (IVec3::new(98, 22, 118), IVec3::new(106, 30, 130));
for ax in 0..3 {
for side in 0..2 {
let mut slo = bl - IVec3::splat(2);
let mut shi = bh + IVec3::splat(2);
if side == 0 {
shi[ax] = bl[ax] - 1;
} else {
slo[ax] = bh[ax] + 1;
}
grid.set_rect(slo, shi, Some(GLASS));
}
}
// The same pane, heard with and without the glass tables: 3 voxels
// of wall count as ~1 voxel of muffling at 0.35×.
let by_pane = DVec3::new(70.0, 30.0, 120.0);
let behind_pane = DVec3::new(85.0, 30.0, 120.0);
let mcfg = AcousticsConfig {
material_map: vec![(GLASS.rgb_part(), GLASS_ID)],
absorption: vec![(GLASS_ID, 0.35)],
..AcousticsConfig::default()
};
let as_stone = source_acoustics(&scene, by_pane, behind_pane, &cfg);
let as_glass = source_acoustics(&scene, by_pane, behind_pane, &mcfg);
println!(
"same pane, stone vs glass tables: transmission {:.2} -> {:.2}",
as_stone.transmission, as_glass.transmission,
);
// Reverb character: from inside the booth, every wall hit
// classifies to the override's 0.1 damping — a glass chamber
// rings brighter than the stone default (0.4).
let mut glassy = CavityEstimator::new(CavityConfig {
material_map: vec![(GLASS.rgb_part(), GLASS_ID)],
damping_override: vec![(GLASS_ID, 0.1)],
..CavityConfig::default()
});
let env = glassy.update(&scene, DVec3::new(102.0, 26.0, 124.0));
println!(
"inside the glass booth: damping {:.2} (stone default 0.40)",
env.reverb_damping,
);
Two conventions worth knowing. Colours absent from the map classify as
material 0, so a table entry for id 0 re-weighs every unmapped
voxel — a deliberate global knob. And the .vxl format stores
surface colours only: interior cells inherit the last surface
colour a ray crossed (rock-like 1.0 before the first), and a carved
face is colour-less — paint what you want classified (the example’s
booth is built from painted slabs, not carved out). Empty tables keep
the exact pre-AU2 arithmetic, bit for bit.
Doppler
Motion bends pitch. doppler_factor is pure math over positions and
velocities — clamped to [0.5, 2.0] (an octave each way) and exactly
1.0 at rest — and the backend applies it through
AudioOut::set_source_pitch (the kira implementation tweens the
playback rate, ~120 ms):
// AU2 — Doppler is pure math over positions and velocities: feed
// the factor to `AudioOut::set_source_pitch` (the kira backend
// tweens the playback rate). Apply it to LOOPS — bending a
// one-shot mid-envelope reads as a glitch, not physics.
let listener = DVec3::new(80.0, 64.0, 120.0);
for (label, vel) in [
("flying toward the source", DVec3::new(-40.0, 0.0, 0.0)),
("flying away", DVec3::new(40.0, 0.0, 0.0)),
("standing still", DVec3::ZERO),
] {
let f = doppler_factor(source, DVec3::ZERO, listener, vel, DEFAULT_SPEED_OF_SOUND);
println!("{label:>26}: pitch x{f:.2}");
}
The default speed of sound is a deliberately game-tuned 90 u/s (at the physical 343 the demo’s speeds would barely bend a semitone); pass your own to retune. Apply Doppler to loops and long tails — a hum, an engine — driven by whatever velocity you track (a camera position delta per frame is fine, with a teleport guard). Pitch-bending a one-shot mid-envelope reads as a glitch, not physics.
The cave demo
The cave demo is the worked example — build it with the audio
feature:
cargo run --release -p roxlap-cave-demo --features audio
Every plasma shot cracks at the muzzle, each impact booms at the crater
it carves, and every glowing crystal hums — all muffled by the rock
between them and you, with reverb that swells as you fly into a big
chamber and dries as you leave it, and the hums bending in pitch as
you fly past (AU2 Doppler, driven by the camera’s velocity). The crystal hums show the voice
budget at work: only the nearest handful loop at once (with hysteresis
so a crystal at the edge of earshot doesn’t flicker), started and
stopped as you move, so a crystal-rich cave never drowns out the shots.
The wiring — one small DemoAudio struct driving the core and the kira
backend from the demo’s fire / carve / per-frame hooks — lives in
crates/roxlap-cave-demo/src/audio.rs.
The scene gallery (chapter 15) also has an Audio
tab: a walkable stone room / open field / glass cavern with every
number this chapter derives shown live in the HUD — including the
measured cost of the acoustics update. The numbers come from the pure
core, so the tab works in every build; add --features audio to the
scene demo to hear the three zone hums too.
Further reading
PORTING-AUDIO.md— the AU-stage design history: the ray budgets and smoothing borrowed from Sound Physics Remastered, Teardown and Overwatch, why kira, and the aux-bus / voice-stealing lessons.- The scene graph and
Picking & world queries — the
Scene::raycastand voxel-query machinery the acoustic rays are built on.