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
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
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 playingEvery 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.
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 atminDistancestraight down to silence atmaxDistance.exponential—(distance / minDistance)^-1: with the engine's fixed rolloff factor of 1 this is the same curve asinverse.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
audio.playAt(sound: Sound, position: Vec3Like, options?: PlaySoundOptions & {
minDistance?: number // default 1
maxDistance?: number // default 50
rolloff?: Rolloff // default 'inverse'
}): VoiceImpacts, 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.
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.
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 insideWhile 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).
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}
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 everywhereThe 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.
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()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
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 nowWith 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