Tutorial 119: 3D Positional Audio

CNA Tutorials  ·  Audio

ℹ

What you’ll learn

  • Positioning a sound in the world with AudioListener, AudioEmitter and Apply3D.
  • The four global knobs — distance scale, Doppler scale, speed of sound, master volume.
  • The exact attenuation curve CNA uses, and why sounds are at full volume nearby.
  • Several listeners (the old one-listener limit is gone), and the mode rule that decides whether Apply3D or Pan governs an instance.

Before you start — Tutorial 14: Playing Sound Effects covers SoundEffectInstance, and Tutorial 34: 3D Cameras gives you the world-space position and orientation the listener needs.

⚠

Build requirement: the audible path on this page requires a mixer-backed audio implementation: CNA_AUDIO_PLATFORM=SDL3 (the default) or CNA_AUDIO_PLATFORM=ALSA (native Linux, CNA’s own mixer). The positioning and Doppler mathematics remain source code in other builds, but NULL does not enable the mixer-backed XNA playback engine.

3D audio in XNA is deliberately small: you describe where the ear is, you describe where the sound is, and you call one method. CNA implements the same three pieces on top of the selected mixer (SDL3_mixer, or CNA’s own mixer under ALSA), with the Doppler mathematics carried over exactly and the spatial part — distance and stereo placement — approximated. This page covers both what works and where the approximation shows, because the difference matters when you are tuning a mix.

Listener and emitter

Both classes are plain state holders with the usual getXProperty()/setXProperty() accessors. Update the listener from your camera once per frame:

#include "Microsoft/Xna/Framework/Audio/AudioListener.hpp"
#include "Microsoft/Xna/Framework/Audio/AudioEmitter.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Audio;

AudioListener listener;   // defaults: Position Zero, Forward -Z, Up +Y, Velocity Zero
AudioEmitter  emitter;

void UpdateAudio(const Camera& camera, const Entity& source, float dt)
{
    listener.setPositionProperty(camera.Position);
    listener.setForwardProperty(camera.Forward);
    listener.setUpProperty(camera.Up);
    listener.setVelocityProperty((camera.Position - previousCameraPosition_) / dt);

    emitter.setPositionProperty(source.Position);
    emitter.setVelocityProperty(source.Velocity);

    engineSound_.Apply3D(listener, emitter);

    previousCameraPosition_ = camera.Position;
}

Velocity is units per second, not per-frame displacement. Dividing by the frame delta, as above, is the whole difference between a Doppler shift that behaves and one that flutters with the frame rate.

✓

AudioEmitter also carries a per-emitter getDopplerScaleProperty()/setDopplerScaleProperty(float), which multiplies into the global scale. It rejects negative values with System::ArgumentOutOfRangeException.

Apply3D

SoundEffectInstance exposes three overloads:

void Apply3D(const AudioListener& listener, const AudioEmitter& emitter);
void Apply3D(const AudioListener* listeners, int listenerCount, const AudioEmitter& emitter);
void Apply3D(const std::vector<AudioListener>& listeners, const AudioEmitter& emitter);  // new in this snapshot

Call it every frame the emitter or listener moves. It is cheap, and CNA persists the values it derives — attenuation, Doppler factor and spatial pan are stored on the instance and recomposed on every later Play(), volume or pitch write, so a Volume change no longer erases your spatial state. Calling Apply3D before the sound has ever played is not just fine, it is the rule for a spatial sound: the values are applied when it starts, and the mode rule below means an instance that was already playing without 3D refuses Apply3D.

⚠

Exactly one listener is heard. Several are accepted: the array and std::vector overloads take any positive listener count. Earlier builds threw System::NotSupportedException("Only one listener is supported.") for anything but one; that limit is gone. Every listener is evaluated and the dominant one — the nearest to the emitter, the one that hears it loudest — decides the attenuation, pan and Doppler that are applied. That is an approximation, because the mixer has a single stereo gain pair rather than per-listener output matrices: a split-screen game gets one sensible mix from all of its viewports, not a mix per viewport. A null array throws System::ArgumentNullException; a zero or negative count (or an empty vector) throws System::ArgumentOutOfRangeException.

The rule is deliberately not “use listeners[0]”: moving a second, closer listener changes the result. If you would rather choose the ear yourself — the midpoint between two players, say — pass exactly one listener you built.

The attenuation curve

Distance falloff is governed by one global on SoundEffect:

SoundEffect::setDistanceScaleProperty(20.0f);   // default is 1.0f

The curve is the same one XNA and FAudio use when no custom volume curve is authored, and it surprises people the first time:

Distance from listenerAttenuation
Anywhere within DistanceScaleNone at all — full volume
Beyond DistanceScaleInverse-distance: DistanceScale / distance, clamped to [0, 1]

So DistanceScale is not a “how fast does it fade” dial — it is the radius of the zone inside which nothing fades at all, and the point where falloff begins. If everything in your game sounds equally loud, your DistanceScale is larger than your play space. If sounds vanish two steps away, it is smaller than your world unit.

Doppler

Doppler is computed exactly, as a closed-form pitch-shift factor from the relative velocity along the listener-to-emitter axis. It is not an approximation, and it is not a mixer feature — CNA calculates it and applies the result to the track’s frequency ratio.

SoundEffect::setSpeedOfSoundProperty(343.5f);  // default 343.5f, in your world units per second
SoundEffect::setDopplerScaleProperty(1.0f);    // default 1.0f
SoundEffect::setDopplerScaleProperty(0.0f);    // exactly zero disables Doppler entirely

The scale that actually enters the calculation is the emitter’s own DopplerScale multiplied by this global one, evaluated before the pitch ratio is formed — the way Microsoft’s XNA does it. A stationary listener and emitter therefore produce no pitch shift whatever the scales are.

The speed of sound is expressed in your units. If one world unit is a metre, 343.5 is correct; if one unit is a foot, or a tile, the default will make everything sound like it is moving at a large fraction of the speed of sound and pitch-bend wildly. This is the single most common cause of “why does my racing game warble”.

An effective scale of exactly zero — the global scale, or the emitter’s, set to 0.0f — short-circuits the whole calculation to a neutral factor: the cheapest way to turn Doppler off for a scene.

Stereo placement

CNA projects the emitter’s listener-relative position onto the listener’s own right axis — the cross product of Forward and Up, normalised — then divides by distance and clamps to [-1, 1]. Two consequences follow directly:

  • Listener orientation genuinely matters. Turn the camera and the mix rotates with it, because the right axis is derived from Forward and Up rather than assuming world X.
  • Emitter orientation does not. AudioEmitter stores Forward and Up, and Apply3D never reads them. There are no sound cones: a speaker facing away from you is not quieter for it.

An emitter at exactly the listener’s position has no meaningful direction and is centred rather than dividing by zero.

The two properties Apply3D takes over

⚠

An instance is either in 3D mode or in pan mode, and the mode is fixed from its first Play() until Stop(). This mirrors Microsoft’s XNA, which submits the instance’s spatial settings to the audio engine when it starts.

Concretely:

  • Before the first Play() (and again after a Stop()) you can still change your mind: Apply3D puts the instance in 3D mode, and setPanProperty() puts it back in pan mode.
  • While it is playing, calling Apply3D on a pan-mode instance throws System::InvalidOperationException, and calling setPanProperty() on a 3D-mode instance throws it too. The pan value is recorded first, so getPanProperty() reads back what you set, but the audible pan stays the 3D one. Earlier builds silently ignored the write; this snapshot refuses it loudly.
  • Volume and Pan are never modified by Apply3D. They keep reporting exactly what you last set. Attenuation lives alongside Volume and is multiplied in, so do not try to read back the audible loudness from the property — it is not there.

So aim a spatial sound before it plays, and keep calling Apply3D every frame afterwards. If you need a sound that is sometimes spatial and sometimes not, either Stop() it and re-aim it before the next Play(), or use two instances.

The four globals, in one place

Static on SoundEffectDefaultEffect
get/setMasterVolumeProperty1.0fGlobal scale over all sound effects. A value outside 0–1, or NaN, throws System::ArgumentOutOfRangeException, as in XNA.
get/setDistanceScaleProperty1.0fRadius of the no-attenuation zone; falloff begins here.
get/setDopplerScaleProperty1.0fMultiplied with the emitter’s own DopplerScale to form the effective Doppler scale. 0.0f disables Doppler.
get/setSpeedOfSoundProperty343.5fIn world units per second. Match it to your unit scale.

3D cues in XACT

The XACT layer has the same facility. Cue::Apply3D(listener, emitter) takes a single listener (XNA’s own signature; there is no multi-listener cue overload) and forwards the same pan, attenuation and Doppler to every wave the cue is playing. It also writes the cue’s built-in Distance, DopplerPitchScalar and OrientationAngle variables, so an RPC curve authored against them follows the 3D state. SoundBank has a one-shot overload that positions a fire-and-forget cue at the moment it starts:

soundBank->PlayCue("Explosion", listener, emitter);

See Tutorial 120 for the rest of the XACT surface.

Honest limits

  • Several listeners collapse to one. Covered above — the nearest listener decides; there is no per-listener mix.
  • No HRTF, no binaural rendering. Placement is a stereo gain pair, not a head model. Elevation is not audible.
  • No sound cones or directional emitters. Emitter orientation is stored and ignored.
  • No occlusion, obstruction or reverb zones. A wall between you and the sound changes nothing. If you want that, attenuate Volume yourself from your own raycast.
  • No custom volume curves. The inverse-distance law above is the only curve.

None of these are bugs to wait on — they are the shape of the backing library. Games that need more usually implement it a layer up, in their own audio manager, using Volume and a low-pass filter of their own.

Where to go next