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

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

ModeDescription
LinearLinear falloff between min/max distance
InverseRealistic 1/distance falloff
ExponentialSteep 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.