Spatial Audio
Spatial audio is positional sound. A source at a location in the world, a listener at another location, and an audio signal that is attenuated by distance and panned by direction. The result is the listener can tell where a sound is coming from without seeing it. Footsteps behind, gunfire to the left, a waterfall in the distance.
Audio Listener
The listener is the entity whose position and orientation define "the ears." Almost always the camera.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { let camera = spawn_camera(world, Vec3::new(0.0, 2.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); world.core.add_components(camera, AUDIO_LISTENER); world.core.set_audio_listener(camera, AudioListener); } }
There is one active listener. If two entities both have the component, the audio system picks one and ignores the other.
Spatial Audio Source
A spatial source is an AudioSource with the spatial flag set, attached to an entity with a transform. Position comes from the transform, attenuation comes from the distance to the listener.
#![allow(unused)] fn main() { fn spawn_ambient_sound(world: &mut World, position: Vec3, sound_name: &str) -> Entity { let entity = world.spawn_entities( AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1 )[0]; world.core.set_local_transform(entity, LocalTransform { translation: position, ..Default::default() }); world.core.set_audio_source(entity, AudioSource::new(sound_name) .with_spatial(true) .with_looping(true) .playing(), ); entity } }
Distance Attenuation
The audio system attenuates the source by distance to the listener. The volume falls off as the listener moves away.
#![allow(unused)] fn main() { world.core.set_audio_source(entity, AudioSource::new("waterfall") .with_spatial(true) .with_looping(true) .playing(), ); }
Rolloff Modes
| Mode | Description |
|---|---|
Linear | Linear falloff between min/max distance |
Inverse | Realistic 1/distance falloff |
Exponential | Steep falloff, good for small sounds |
Linear is the most predictable for gameplay tuning. Inverse matches how real sound behaves and is the right pick for outdoor ambient. Exponential drops off fast and keeps a small sound from leaking past its source.
Moving Sound Sources
A spatial source's position is wherever its transform happens to be each frame. Move the transform and the sound moves with it.
#![allow(unused)] fn main() { fn update_helicopter(world: &mut World, helicopter: Entity, dt: f32) { if let Some(transform) = world.core.get_local_transform_mut(helicopter) { transform.translation.x += 10.0 * dt; } mark_local_transform_dirty(world, helicopter); } }
The dirty flag is what tells the transform sync system to update the global transform that the audio system reads.
Non-Spatial (2D) Audio
UI sounds, music, and narration should not be spatialized. They want to play at full volume regardless of where the camera is pointing.
#![allow(unused)] fn main() { world.core.set_audio_source(entity, AudioSource::new("ui_click").playing()); }
Without the with_spatial(true) call, the source routes straight to the master bus.
Directional Audio Sources
A directional source emits more strongly along its forward axis than to the sides. PA speakers, megaphones, anything with a cone of audibility.
#![allow(unused)] fn main() { world.core.set_audio_source(entity, AudioSource::new("announcement") .with_spatial(true) .playing(), ); }
Manual Volume Control
The built-in attenuation works for most cases. When a specific source needs a custom curve, drive the volume from a system that runs each frame.
#![allow(unused)] fn main() { fn update_audio_attenuation(world: &mut World, source: Entity, listener: Entity) { let source_pos = world.core.get_global_transform(source).map(|t| t.translation()); let listener_pos = world.core.get_global_transform(listener).map(|t| t.translation()); if let (Some(src), Some(lst)) = (source_pos, listener_pos) { let distance = (lst - src).magnitude(); let max_distance = 50.0; let volume = (1.0 - distance / max_distance).clamp(0.0, 1.0); if let Some(audio) = world.core.get_audio_source_mut(source) { audio.volume = volume; } } } }
The closed-form falls off linearly to zero at 50 meters. Replace the formula with whatever shape the source needs.
Common Patterns
Ambient Soundscape
Layering several looping sources at different positions builds a convincing outdoor scene. The layers do not need to be synchronized. Their independence is what makes the scene feel alive instead of looped.
#![allow(unused)] fn main() { fn setup_forest_ambience(world: &mut World) { spawn_ambient_sound(world, Vec3::zeros(), "forest_ambient"); spawn_ambient_sound(world, Vec3::new(10.0, 5.0, 0.0), "bird_chirp"); spawn_ambient_sound(world, Vec3::new(-8.0, 4.0, 5.0), "bird_song"); spawn_ambient_sound(world, Vec3::new(0.0, 0.0, 20.0), "stream"); } }
Footstep System
Spawning a one-shot source per footstep is the cheapest way to get positional footsteps. The source disappears with the sound.
#![allow(unused)] fn main() { fn play_footstep_at_position(world: &mut World, position: Vec3) { let entity = world.spawn_entities(AUDIO_SOURCE | LOCAL_TRANSFORM | GLOBAL_TRANSFORM, 1)[0]; world.core.set_local_transform(entity, LocalTransform { translation: position, ..Default::default() }); world.core.set_audio_source(entity, AudioSource::new("footstep") .with_spatial(true) .playing(), ); } }
For high footstep rates, a pool of reusable sources is cheaper than spawning and despawning every step. The pool form keeps the entity count flat and avoids the per-spawn allocation churn.