3D audio — sources, zones, the listener

Sound placed in a 3D world: quieter with distance, louder in front of a speaker, muffled behind a wall, echoing in a hall. It runs on the game mixer — desktop and iOS only, a silent no-op on Android, the web and the web player; an app's ordinary sounds play through AudioPlayer.

  • make a sound come from an object in the 3D world (an engine, a generator, an enemy)
  • make a sound quieter the farther away it is
  • play an explosion or impact sound at a point in the world
  • add echo inside a room, cave or hall (reverb zones)
  • muffle a sound behind a wall
  • hear from the character instead of the camera
  • 3D sound in headphones (binaural)

Platforms: iOS (the full engine variant; audio.playAt also on the 2d and core ones), desktop, the le.codes viewer app on iOS; a silent no-op on Android, the web and in the web player, where the aspects attach and do nothing — check audio.supported. Headless tests record the plays without placing them in space.

At a glance

TypeScript
const main = async () => {
  const hum = await Sound.load(asset('./sfx/hum.wav'))       // mono: what a source spatializes
  const knock = await Sound.load(asset('./sfx/click.wav'))

  const scene = new Scene()
  scene.camera.position = [0, 1.6, 4]

  const generator = new Node().aspect(AudioSource, { minDistance: 2, maxDistance: 40 })
  generator.position = [0, 1, -6]
  const hall = new Node().aspect(AudioZone, { box: [8, 4, 8], reverb: 'hall', blend: 2 })
  scene.add(generator, hall)
  scene.open()

  const loop = generator.audio.play(hum, { loop: true })   // follows the node, heard from the camera
  loop.volume = 0.8
  audio.playAt(knock, [3, 0, -2], { maxDistance: 25 })     // a one-shot at a world point
}
main()

How 3D audio fits together

An AudioSource aspect makes a node an emitter the engine follows every frame; audio.playAt fires a one-shot at a point; an AudioZone aspect is a reverb volume; and scene.audio (the SceneAudio every Scene carries) chooses the listener — the active camera unless you pick a node. The clips, voices and buses are game audio's, and so is the host gate: without audio.supported the aspects attach and do nothing (play returns a voice that is already over).

A sound that comes from an object: AudioSource

TypeScript
node.aspect(AudioSource, {
  minDistance?: number     // metres of full volume around the node; default 1
  maxDistance?: number     // metres beyond which it gets no quieter; default 50
  rolloff?: Rolloff        // 'inverse' (default) | 'linear' | 'exponential' | 'none'
  cone?: { inner: number, outer: number, outerGain?: number } | null   // degrees; default null (all around)
  doppler?: number         // 0 (off, default) … 1
  spread?: number          // 0 pin-point panning (default) … 1 the same in both ears
  occlusion?: boolean      // muffle it when a physics body is in the way; default false
  bus?: string             // default bus of its voices; default 'sfx'
})
node.audio.play(sound: Sound, options?: PlaySoundOptions): Voice   // the options of audio.play
node.audio.stopAll(fade?: number)                                  // its voices; fade in seconds
node.audio.voices                                                  // how many are playing

Every option is also a live property (node.audio.maxDistance = 80) that reaches the voices already playing — except bus, which picks the bus of later plays. The engine reads the node's world pose every frame, after the late phase — parented, animated and physics-driven nodes just work; forward is −Z.

Note

the accessor is node.audio — a different thing from the audio global and from scene.audio.

The engine-side source exists while the aspect is attached: detaching it or destroying the node stops its voices (a 50 ms fade).

Quieter with distance: minDistance, maxDistance, rolloff

Distance falls off between minDistance and maxDistance (full volume inside minDistance, no further change past maxDistance):

  • inverse — gain = minDistance / distance; with the defaults 0.02 at 50 m.
  • linear — from 1 at minDistance straight down to silence at maxDistance.
  • exponential — (distance / minDistance)^-1: with the engine's fixed rolloff factor of 1 this is the same curve as inverse.
  • none — no distance attenuation.

Louder in front: cone, and the pitch of a passing car: doppler

cone makes a speaker, a siren, a voice facing away: inner and outer are full angles in degrees around the node's −Z; inside inner the volume is 1, past outer it is outerGain (default 0.25), between them it blends.

doppler needs the node to actually move: its velocity is measured from its motion each frame (capped at 60 m/s, so a teleport does not whoosh); scene.audio.dopplerFactor scales every source's doppler.

Muffled behind a wall: occlusion

occlusion casts three rays each frame from the source toward the listener (at its centre and 0.5 m to either side) — up to 8 flagged sources per frame, in turns — and muffles the voices by how many are blocked, down to a low-pass at 800 Hz and −12 dB when all three are, eased over about 0.1 s. What blocks is a physics body, static or moving (a Shape with Physics) — not a Trigger, not a Shape alone, and never the source node's own body or its children's. It needs the host's 3D physics (Physics.supported).

Play an explosion or impact at a point: audio.playAt

TypeScript
audio.playAt(sound: Sound, position: Vec3Like, options?: PlaySoundOptions & {
  minDistance?: number     // default 1
  maxDistance?: number     // default 50
  rolloff?: Rolloff        // default 'inverse'
}): Voice

Impacts, ricochets, debris: a source of its own at a fixed world position (metres), freed when the voice ends. bus defaults to sfx; there is no cone, doppler or occlusion, and pan does nothing.

TypeScript
const main = async () => {
  const boom = await Sound.load(asset('./sfx/boom.wav'))
  const scene = new Scene()
  scene.open()

  const explodeAt = (where: Vec3) =>
    audio.playAt(boom, where, { maxDistance: 80 })   // its own source, freed when the sound ends
  explodeAt(new Vec3(4, 0, -10))
}
main()

Echo when the player walks into a cave, room or hall: AudioZone

A reverb volume: while the listener is inside it, every sound on its bus echoes like that place.

TypeScript
node.aspect(AudioZone, {
  box?: Vec3Like                          // half-extents, in the node's local units
  sphere?: number                         // radius (setting one clears the other)
  reverb?: ReverbPreset | ReverbParams    // default 'room'
  blend?: number                          // metres of crossfade inside the border; default 1, 0 = a hard edge
  bus?: string                            // the bus its reverb goes on; default 'sfx'
})
node.audioZone.id                         // what audio.stats.listenerZone reports while the listener is inside

While the listener is inside, the zone's reverb replaces the bus's own reverb (see bus effects), fading in over the first blend metres past the border; outside every zone the bus's own applies again. Zones nest: the smallest one containing the listener wins. The volume follows the node — its position, rotation and scale.

With neither box nor sphere, the zone takes the node's Shape sphere or box (read when the zone attaches — attach the Shape first), else half-extents of 1 (a 2 m cube).

TypeScript
const corridor = new Node().aspect(Shape, { box: [1, 1.5, 8] }).aspect(AudioZone, { reverb: 'bathroom' })

audio.stats.listenerZone / zoneBlend show which zone the listener is in and how far (0 … 1).

Hear from the character instead of the camera: scene.audio.listener {#the-listener--sceneaudio}

TypeScript
scene.audio.listener: Node | null   // default null: the camera the scene renders with
scene.audio.dopplerFactor: number   // multiplies every source's doppler; default 1, 0 = off everywhere

The listener hears from its world position, facing −Z with +Y up. By default that is the camera — a follow rig or a camera switch changes nothing. Set a node to listen from somewhere else: the character's head in a third-person game, so sounds pan around the character, not the camera.

TypeScript
const scene = new Scene()
const hero = new Node()
const head = new Node()
head.position = [0, 1.7, 0]
hero.add(head)
scene.add(hero)
scene.audio.listener = head                    // sounds pan around the character, not the camera
scene.open()
Note

the engine has one listener. A node set on one scene stays the listener after another scene opens — whose scene.audio.listener still reads null — until you set listener = null or the node is destroyed; then the rendering camera listens again.

3D sound in headphones: audio.hrtf

TypeScript
audio.hrtf = true          // binaural rendering; default false
audio.hrtfVoices = 16      // how many of the nearest 3D voices get it; default 16
audio.stats.hrtfVoices     // how many are rendered binaurally right now

With hrtf on, the nearest 3D voices are filtered per ear (the MIT KEMAR set), so a sound gets a real up, down and behind — in headphones; on speakers it only smears the image, hence off by default. Each such voice costs more CPU; the rest keep plain panning, and 2D voices are never binaural. A host built without the filters (the iOS App Clip, the 2d and core iOS shells) keeps every voice panned: audio.stats.hrtfVoices stays 0 there, whatever audio.hrtf reads.

See also

  • Game audio — Sound, Voice, buses, the voice pool, audio.supported
  • Aspects — node.aspect(Class, opts) and detaching
  • Physics — the bodies that occlude
  • Scene — the camera that listens by default