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.
  • The one-listener limit, and the two properties Apply3D quietly stops governing.

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

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 SDL3_mixer, 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 two overloads:

void Apply3D(const AudioListener& listener, const AudioEmitter& emitter);
void Apply3D(const AudioListener* listeners, int listenerCount, const AudioEmitter& emitter);

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 fine too; the values are applied when it starts.

Exactly one listener is supported. The array overload calls straight through when listenerCount == 1; for any other count it throws System::NotSupportedException("Only one listener is supported."). A null array throws System::ArgumentNullException. Split-screen games that want a per-viewport mix do not get one from this API — you have to pick a single notional ear (the midpoint between players is the usual choice) and drive the one listener from that.

To be precise about what the limit is not: constructing several AudioListener objects is perfectly legal, and cheap. The restriction bites at the moment you try to spatialise a sound against more than one of them at a time.

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 an SDL3_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 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”.

Setting the global scale to exactly 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

Once Apply3D has been called on an instance, that instance is 3D for the rest of its life. The flag is never cleared.

Concretely, and matching XNA’s own behaviour:

  • setPanProperty() still records the value, and getPanProperty() still reads it back — but it stops affecting the audible output. Apply3D’s own pan is what governs from then on, until the next Apply3D.
  • 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.

If you need a sound that is sometimes spatial and sometimes not, use two instances rather than trying to un-3D one.

The four globals, in one place

Static on SoundEffectDefaultEffect
get/setMasterVolumeProperty1.0fGlobal scale over all sound effects. Passed through unclamped.
get/setDistanceScaleProperty1.0fRadius of the no-attenuation zone; falloff begins here.
get/setDopplerScaleProperty1.0fMultiplies the computed Doppler factor. 0.0f disables it.
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) works exactly like the instance version, and 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

  • One listener. Covered above — this is the headline restriction.
  • 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