Every sound in a game world comes from somewhere — a torch crackling on a wall, a monster growling behind a locked door, footsteps closing in from an alley you cannot see into. Making a sound feel like it truly comes from that place — quieter as you walk away, leaning into your left or right ear, muffled when a wall gets in the way — is called spatial audio, also called 3D audio. This chapter builds that system in Unity and C#, and it leans hard on the vector, dot product, and cross product tools from chapter 2.1: a sound source and a listener are just two points and a direction in space, exactly like the ones you already worked with there.
As always, each idea is shown as a small runnable program with its real printed output. A few ideas only make sense as a Unity component with no console to print to — for those we trace what happens frame by frame instead, then explain it in plain words.
Most beginner audio code plays a sound at the same volume everywhere, coming equally out of both speakers, no matter where the player is standing. That is called a 2D sound (flat, non-positional) — correct for a UI click or background music, wrong for almost everything else. A 3D sound (positional / spatial sound) has an actual location in the game world, and three things about it change depending on where the listener is standing relative to that location:
Unity's audio system always has exactly one active AudioListener component in a scene — almost always attached to the main camera or the player — and any number of AudioSource components, each one a single sound with its own position (its Transform). Every one of the effects above is computed from just two pieces of information: the listener's position/orientation, and the source's position. That is precisely a point, a point, and the vector between them — the exact pattern from chapter 2.1: point - point = vector.
using System;
// a plain C# console program (no Unity here) -- just to see the underlying math clearly.
// The Unity-specific versions that follow reuse this exact same math through UnityEngine.Vector3.
struct Vec3
{
public float x, y, z;
public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
}
class Program
{
static Vec3 Sub(Vec3 a, Vec3 b) => new Vec3(a.x - b.x, a.y - b.y, a.z - b.z);
static float Length(Vec3 v) => MathF.Sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
static void Main()
{
Vec3 listenerPos = new Vec3(0f, 0f, 0f); // the player / camera
Vec3 sourcePos = new Vec3(3f, 0f, 4f); // a torch crackling somewhere in the world
Vec3 toSource = Sub(sourcePos, listenerPos); // point - point = vector (chapter 2.1)
float distance = Length(toSource);
Console.WriteLine($"vector to source = ({toSource.x}, {toSource.y}, {toSource.z})");
Console.WriteLine($"distance = {distance}");
}
}
Output:
vector to source = (3, 0, 4)
distance = 5
That is the same 3-4-5 triangle from chapter 2.1's section on vector length — recognizing it here is the point. The whole rest of this chapter is "what do we do with that distance, and what do we do with that direction."
Attenuation just means "getting quieter." Distance attenuation is the rule that turns a distance number into a volume number, and it needs two reference distances:
Between those two distances, volume drops off following one of two common shapes:
using System;
class Program
{
const float MinDistance = 2f;
const float MaxDistance = 20f;
// straight-line fade: 100% at MinDistance, exactly 0% at MaxDistance
static float LinearAttenuation(float distance)
{
if (distance <= MinDistance) return 1f;
if (distance >= MaxDistance) return 0f;
return 1f - (distance - MinDistance) / (MaxDistance - MinDistance);
}
// real-world-like fade: drops FAST close up, then trails off slowly, never quite hitting 0
static float LogarithmicAttenuation(float distance)
{
if (distance <= MinDistance) return 1f;
return MinDistance / distance;
}
static void Main()
{
float[] distances = { 2f, 5f, 10f, 15f, 20f, 25f };
foreach (float d in distances)
{
float lin = LinearAttenuation(d);
float log = LogarithmicAttenuation(d);
Console.WriteLine($"distance={d,5:F1} linear={lin:F4} logarithmic={log:F4}");
}
}
}
Output:
distance= 2.0 linear=1.0000 logarithmic=1.0000
distance= 5.0 linear=0.8333 logarithmic=0.4000
distance= 10.0 linear=0.5556 logarithmic=0.2000
distance= 15.0 linear=0.2778 logarithmic=0.1333
distance= 20.0 linear=0.0000 logarithmic=0.1000
distance= 25.0 linear=0.0000 logarithmic=0.0800
Notice the two curves tell very different stories about the far edge: LinearAttenuation reaches a clean, exact 0.0000 at distance 20 and stays there, because the formula is a straight line that is defined to bottom out at MaxDistance. LogarithmicAttenuation is still at 0.1000 at distance 20 and 0.0800 at distance 25 — it never actually reaches zero, it just keeps getting quieter more and more slowly. That is genuinely closer to how sound behaves in the real world (intensity falls off with roughly the inverse of distance), which is exactly why Unity calls this option "Logarithmic Rolloff" and recommends it as the more natural-sounding default for most 3D sounds.
minDistance means "the sound turns on at this distance." It does not — a 3D sound is audible at any distance, right down to standing on top of it. minDistance only controls where the volume stops climbing any higher. Set it too small (like 0) and a nearby explosion or footstep can sound unnaturally loud and harsh; a typical footstep might use a minDistance of around 1–3 units so it does not spike in volume the instant the camera gets close.You will not usually hand-roll these formulas in a shipped game — Unity's AudioSource component already computes distance attenuation for you, every frame, for every 3D sound. What you are actually doing in the Inspector (or in code, as below) is choosing the shape and the two reference distances from section 2:
using UnityEngine;
public class TorchAudio : MonoBehaviour
{
[SerializeField] AudioSource source;
void Awake()
{
source.spatialBlend = 1f; // 0 = flat 2D, 1 = fully positional 3D
source.rolloffMode = AudioRolloffMode.Custom; // use our own curve, not a built-in shape
source.minDistance = 2f; // full volume anywhere inside 2 units
source.maxDistance = 20f; // silent (for Custom/Linear) past 20 units
AnimationCurve curve = AnimationCurve.Linear(2f, 1f, 20f, 0f);
source.SetCustomCurve(AudioSourceCurveType.CustomRolloff, curve);
}
}
spatialBlend is the field beginners miss most often: it is a 0-to-1 slider, not an on/off switch, where 0 means a completely flat 2D sound (no distance attenuation, no panning, plays identically everywhere) and 1 means fully 3D. You can blend between the two — useful for something like a radio the player carries, which should mostly sound "in your head" (close to 2D) but still shift slightly with orientation.
minDistance/maxDistance carefully, hearing zero difference as the player walks away, and assuming the math is broken. Almost always the real bug is that spatialBlend was left at its default of 0 (2D) — a 2D sound completely ignores distance and rolloff settings, no matter what they are set to. Check spatialBlend first whenever a "3D" sound is not behaving in 3D.Panning decides how much of a sound comes out of the left speaker/ear versus the right one. Distance (section 2-3) only needs the length of the vector from listener to source; panning needs its direction, compared against which way the listener is facing. This is where the dot product and cross product from chapter 2.1 come back directly.
The listener has a forward vector (which way it is looking) and an up vector (which way is "up" for it, usually just world up). From those two we can build a third vector, right, using the cross product — recall from chapter 2.1 that cross(a, b) produces a new vector perpendicular to both inputs, following the right-hand rule:
using System;
struct Vec3
{
public float x, y, z;
public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
}
class Program
{
static Vec3 Sub(Vec3 a, Vec3 b) => new Vec3(a.x - b.x, a.y - b.y, a.z - b.z);
static float Dot(Vec3 a, Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z;
static Vec3 Cross(Vec3 a, Vec3 b) => new Vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x);
static float Length(Vec3 v) => MathF.Sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
static Vec3 Normalize(Vec3 v)
{
float len = Length(v);
return new Vec3(v.x / len, v.y / len, v.z / len);
}
static void Main()
{
Vec3 listenerPos = new Vec3(0f, 0f, 0f);
Vec3 forward = new Vec3(0f, 0f, 1f); // the way the listener is facing
Vec3 up = new Vec3(0f, 1f, 0f);
Vec3 right = Cross(up, forward); // chapter 2.1's cross product, reused here
Console.WriteLine($"right = ({right.x}, {right.y}, {right.z})");
(string name, Vec3 pos)[] sources =
{
("A", new Vec3( 5f, 0f, 0f)),
("B", new Vec3(-5f, 0f, 0f)),
("C", new Vec3( 0f, 0f, 5f)),
("D", new Vec3( 0f, 0f, -5f)),
("E", new Vec3( 3f, 0f, 3f)),
};
foreach (var (name, pos) in sources)
{
Vec3 dir = Normalize(Sub(pos, listenerPos));
float pan = Dot(dir, right); // -1 = full left, +1 = full right
float front = Dot(dir, forward); // +1 = straight ahead, -1 = straight behind
Console.WriteLine($"{name}: pan={pan:F4} front={front:F4}");
}
}
}
Output:
right = (1, 0, 0)
A: pan=1.0000 front=0.0000
B: pan=-1.0000 front=0.0000
C: pan=0.0000 front=1.0000
D: pan=0.0000 front=-1.0000
E: pan=0.7071 front=0.7071
This is the exact same dot-product trick as "is it in front of me?" from chapter 2.1, just aimed at a different axis. Dot(dir, right) answers "how far toward my right ear does this sound sit" — source A sits fully along right, giving a clean +1 (full right speaker); B is the mirror image, -1 (full left). Dot(dir, forward) answers the original front/behind question from chapter 2.1: C is dead ahead (+1), D is directly behind (-1). Source E sits exactly between "right" and "ahead," and its numbers show it: 0.7071 on both, the same sqrt(2)/2 you have already seen for a 45-degree angle.
Transform already exposes transform.right, transform.up, and transform.forward directly, and transform.right really is exactly Cross(up, forward) under the hood. Knowing that is what turns "some built-in property" into something you actually understand.using UnityEngine;
public class ManualPan : MonoBehaviour
{
public Transform listener;
public AudioSource source;
void Update()
{
Vector3 toSource = transform.position - listener.position;
Vector3 flatDir = new Vector3(toSource.x, 0f, toSource.z).normalized;
float pan = Vector3.Dot(flatDir, listener.right);
source.panStereo = pan; // -1 = full left speaker, +1 = full right speaker
}
}
Unity already computes panning automatically for any AudioSource with spatialBlend set to 1, using this same math internally. panStereo is exposed directly for the rare case where you want to fake a left/right lean on a sound that should not also get quieter with distance (for example, a UI compass ping that should lean toward off-screen danger without changing volume).
pan=0.00 — dead center — even though one is straight ahead and the other is straight behind. Plain stereo panning has no way to tell them apart; it only has one axis (left/right) to work with. Keep that gap in mind — section 8 explains how real hearing resolves it.You already know this sound from real life: an ambulance siren sounds higher-pitched as it races toward you, dips the instant it passes, and sounds lower-pitched as it drives away. That pitch shift is the Doppler effect, and it depends on how fast the source is closing the distance to the listener — which, once again, is a dot product: take the source's velocity and dot it with the direction toward the listener.
using System;
struct Vec3
{
public float x, y, z;
public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
}
class Program
{
const float SpeedOfSound = 343f; // meters per second, in air
static readonly Vec3 ListenerPos = new Vec3(0f, 0f, 0f);
static readonly Vec3 SourceVelocity = new Vec3(20f, 0f, 0f); // a car driving along +x
static Vec3 Sub(Vec3 a, Vec3 b) => new Vec3(a.x - b.x, a.y - b.y, a.z - b.z);
static float Dot(Vec3 a, Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z;
static Vec3 Normalize(Vec3 v)
{
float len = MathF.Sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
return new Vec3(v.x / len, v.y / len, v.z / len);
}
static float PitchFactor(Vec3 sourcePos)
{
Vec3 dirToListener = Normalize(Sub(ListenerPos, sourcePos));
float approachSpeed = Dot(SourceVelocity, dirToListener); // + closing in, - pulling away
return SpeedOfSound / (SpeedOfSound - approachSpeed);
}
static void Main()
{
Console.WriteLine($"approaching (x=-50): {PitchFactor(new Vec3(-50f, 0f, 0f)):F4}");
Console.WriteLine($"alongside (z=30): {PitchFactor(new Vec3(0f, 0f, 30f)):F4}");
Console.WriteLine($"receding (x=50): {PitchFactor(new Vec3(50f, 0f, 0f)):F4}");
}
}
Output:
approaching (x=-50): 1.0619
alongside (z=30): 1.0000
receding (x=50): 0.9449
When the car is far to the left and driving toward the listener (x=-50, velocity +x), it is closing the distance, so the factor is above 1 and the pitch sounds slightly higher. Directly beside the listener (z=30, still moving along x), the car's velocity is perpendicular to the line connecting it to the listener — the distance is not changing at that exact instant, so the dot product is 0 and the factor is exactly 1.0, the classic moment a passing siren briefly sounds "normal." Once it is past and driving away, the factor drops below 1.
Unity exposes this as AudioSource.dopplerLevel: 0 turns the effect off entirely, 1 is roughly this physically-based amount, and values above 1 exaggerate it for a more stylized "whoosh." Unity computes source and listener velocity automatically between frames and applies the shift to pitch for you — you will rarely call a formula like this by hand.
dopplerLevel to 0 for sounds attached to the camera itself, or for UI-ish 3D sounds. A camera that snaps or spins quickly moves "fast" relative to every sound in the scene for a single frame, and without turning Doppler off that shows up as a jarring, meaningless pitch warble that has nothing to do with anything actually moving in the world.Distance and panning both assume a clear, straight line between source and listener. Real levels have walls. Occlusion is what happens when something solid sits directly between the source and the listener, blocking both the direct sound and the reflections that would normally sneak around it — the result should sound noticeably quieter and duller. Obstruction is the lighter cousin: something is in the direct path, but sound can still reach the listener indirectly (around a doorway, over a low wall), so it should be muffled less.
The detection step is the easy part: fire a ray from the source toward the listener (or vice versa) and see if it hits anything solid along the way — exactly the same Physics.Raycast you already know from collision and shooting code. The muffling itself is done with a low-pass filter: a filter that lets low frequencies (bass) through mostly unchanged but cuts high frequencies. That matches how a real wall behaves — deep bass carries through walls easily, while voices and high notes get blocked far more, which is exactly why a muffled sound through a wall sounds "boomy" rather than just quieter.
using UnityEngine;
[RequireComponent(typeof(AudioLowPassFilter))]
public class AudioOcclusion : MonoBehaviour
{
public AudioSource source;
public Transform listener;
public LayerMask occluderMask; // walls and doors only -- NOT the player or other sounds
const float OpenCutoff = 22000f; // effectively "no filtering" (top of human hearing)
const float OccludedCutoff = 800f; // muffled: only bass gets through
AudioLowPassFilter lowPass;
void Awake()
{
lowPass = GetComponent<AudioLowPassFilter>();
}
void Update()
{
Vector3 toListener = listener.position - transform.position;
bool occluded = Physics.Raycast(transform.position, toListener.normalized,
toListener.magnitude, occluderMask);
float target = occluded ? OccludedCutoff : OpenCutoff;
// ease toward the target instead of snapping -- an instant jump sounds like a click
lowPass.cutoffFrequency = Mathf.Lerp(lowPass.cutoffFrequency, target, Time.deltaTime * 8f);
}
}
Worked trace, over several frames as the player walks behind a wall and back out again:
Mathf.Lerp(current, target, Time.deltaTime * 8f) moves the cutoff frequency a fraction of the way to its target every frame instead of snapping instantly, which is why the table shows it gradually closing in rather than jumping straight to 800 or 22000. Without that easing, every time the player crossed the wall's edge the filter would snap on and off in a single frame — audibly a sharp, ugly click rather than a smooth muffle.
occluderMask as "Everything." The raycast will then happily hit the player's own collider, the sound source's own collider, ragdoll limbs, and pickup items — none of which should ever muffle audio — and the sound will occlude itself constantly for no visible reason. Put walls and large solid geometry on their own layer (something like Occluders) and point the mask at only that layer.Reverb is sound bouncing off nearby surfaces and arriving back at your ears a moment after the original — it is what makes a shout in a stone cave sound completely different from the same shout in a small carpeted room, even with the exact same source and listener distance. A small room has hard, close surfaces close together: reverb arrives back almost immediately and dies out fast (well under a second). A cave has huge, hard, far-apart surfaces: reverb takes noticeably longer to arrive and rumbles on for several seconds before fading, often with distinct, audible echoes rather than a smooth wash.
Unity's AudioReverbZone is a world-space volume: any AudioListener that walks inside it gets that reverb effect applied to everything it hears, blending in smoothly between minDistance (full effect) and maxDistance (no effect) from the zone's center — the same min/max idea as section 2-3, just applied to a reverb amount instead of a volume.
using UnityEngine;
public class CaveReverbZone : MonoBehaviour
{
void Awake()
{
AudioReverbZone zone = gameObject.AddComponent<AudioReverbZone>();
zone.minDistance = 5f; // full reverb starts fading in here
zone.maxDistance = 30f; // outside this radius, no reverb at all
zone.reverbPreset = AudioReverbPreset.Cave; // long, boomy decay with audible echo
}
}
A "Room" preset would use the same component with much smaller decay values baked into the preset — short, tight, close-sounding reverb instead of Cave's long, boomy one. Both presets are just pre-tuned bundles of the same handful of numbers: decay time, how strong the early reflections are, and how much of the high end gets absorbed on the way back.
AudioReverbZone to a sound source, expecting it to add reverb to that one sound. Reverb zones trigger off the listener's position, not any source's position, and once triggered they affect every sound the listener hears, not just one chosen source. If you actually want a separate reverb effect on one specific AudioSource regardless of where the listener is standing, that is a different component entirely: AudioReverbFilter, attached directly to the source.Section 4 ended on a gap: plain stereo panning cannot tell "straight ahead" from "straight behind," because both land at pan = 0 — dead center on the only axis panning has. Real human hearing does not have this problem, and it is worth knowing roughly why, even though the fix lives mostly inside engine and headphone code you will not write yourself.
Your ears mainly use two simple cues to tell left from right: a tiny difference in arrival time between your two ears (called ITD, interaural time difference — a sound from your right reaches your right ear a fraction of a millisecond before your left) and a difference in loudness between your two ears (ILD, interaural level difference — your own head partially blocks sound from reaching the far ear). Both of those are symmetric front-to-back: a sound directly ahead and a sound directly behind produce almost the same ITD and ILD, which is exactly the ambiguity you saw with C and D in section 4. Audio engineers call the whole ring of directions that share the same ITD/ILD the cone of confusion.
What actually breaks the tie is the shape of your own outer ear (the pinna): its folds and ridges reflect and absorb different frequencies differently depending on the exact angle a sound arrives from, especially for height and front/back. Your brain has spent a lifetime learning what "this specific pattern of boosted and cut frequencies" means for direction. A measured (or modeled) version of that filtering is called an HRTF — a Head-Related Transfer Function — and audio processed through one to trick your ears this way is called binaural audio. It works best over headphones, because headphones deliver each ear's channel in isolation; loudspeakers leak some of each channel into both ears and partially wash the trick out.
In Unity this shows up as a spatializer plugin (Oculus Spatializer, Microsoft Spatial Sound, Steam Audio, Resonance Audio, and others), selected once for the whole project and then enabled per source with a Spatialize checkbox on the AudioSource. It replaces the plain pan-and-attenuate math from sections 2-4 with real HRTF filtering, at a real CPU cost — which is exactly why it is usually reserved for a handful of important, close-up sounds (footsteps around a VR player, a monster circling behind you) rather than applied to every single 3D sound in a busy scene.
Spatialize checkbox with no spatializer plugin selected in Project Settings > Audio silently does nothing — the checkbox exists on every AudioSource regardless of whether a plugin is installed. If HRTF processing does not seem to be having any effect, check the project-wide plugin setting before assuming your code is wrong.A busy scene can easily have hundreds of things that could be making a sound at once — footsteps, impacts, ambience loops, monster growls. Every active voice (one currently-playing sound) costs real CPU time: its distance attenuation, panning, and any filters all get recalculated every frame. Beyond a certain count, most of those voices are also so quiet or so far away that nobody could hear them individually anyway — they would just add up into an inaudible wash. The fix is to explicitly rank sounds by how much they matter right now, and only let the top few actually play.
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
public class VoiceLimiter : MonoBehaviour
{
public int maxActiveVoices = 16;
public List<AudioSource> allSources = new List<AudioSource>();
Transform listener;
void Awake() { listener = Camera.main.transform; }
void Update()
{
// score = how much this voice matters right now: louder AND closer wins
var ranked = allSources
.Where(s => s.isPlaying)
.OrderByDescending(s => Score(s))
.ToList();
for (int i = 0; i < ranked.Count; i++)
{
bool shouldBeAudible = i < maxActiveVoices;
ranked[i].mute = !shouldBeAudible; // keep simulating position, just silence it
}
}
float Score(AudioSource s)
{
float distance = Vector3.Distance(s.transform.position, listener.position);
return s.volume / Mathf.Max(distance, 0.01f);
}
}
Worked example, with maxActiveVoices = 3:
The footstep beats the much louder explosion's and the enemy growl's raw volume because it is so close — score rewards both being loud and being near, which matches what a listener would actually notice. Sorting a list like this every single frame is fine for a scene with hundreds of sources, but for tens of thousands you would re-rank on a timer (say, every 0.2 seconds) instead of every frame, since a sound's rank rarely needs to change that often.
AudioSource.priority (0 = never dropped, 255 = dropped first). It "virtualizes" the lowest-ranked sources by fully skipping their DSP processing rather than just muting them, which is cheaper than the hand-rolled version above. Reach for that first, and only build a custom scorer like this one when you need rules Unity's generic priority number cannot express — for example, always keeping a boss's dialogue audible no matter how quiet or far away it is, by giving it an automatic top rank before falling back to the distance/volume score for everything else.AudioSource: 0 is flat 2D (no distance or panning effects at all), 1 is fully 3D.AudioSource uses minDistance = 3 and maxDistance = 15. A listener is standing exactly 9 units away. Compute the volume under linear rolloff, and separately under logarithmic rolloff, by hand.Linear: 1 - (distance - minDistance) / (maxDistance - minDistance) = 1 - (9-3)/(15-3) = 1 - 6/12 = 1 - 0.5 = 0.5.
Logarithmic: minDistance / distance = 3 / 9 = 0.3333.
The logarithmic result is quieter at this particular distance because it drops off faster near the source and only gently trails afterward — at exactly the halfway point between min and max, linear is always exactly 0.5, while logarithmic's value depends entirely on the ratio minDistance/distance, not on where you sit between min and max.
forward = (0, 0, 1) with up = (0, 1, 0) (so, as in section 4, right = (1, 0, 0)). A sound source sits at (-4, 0, 4). Compute the normalized direction to the source, then its pan value (dot with right) and its front value (dot with forward). Describe in plain words roughly where the sound is coming from.toSource = (-4, 0, 4), length = sqrt(16 + 0 + 16) = sqrt(32) ≈ 5.6569.
dir ≈ (-0.7071, 0, 0.7071).
pan = Dot(dir, right) = -0.7071. front = Dot(dir, forward) = 0.7071.
A negative pan means the sound leans toward the left speaker, and a positive front value means it is roughly ahead of the listener. In plain words: the sound is coming from ahead and to the left, at roughly a 45-degree angle off center — the mirror image of source E from section 4.
VoiceLimiter scoring rule from section 9 (score = volume / distance), rank the following four sources and say which two stay audible if maxActiveVoices = 2: Wind ambience (volume 0.4, distance 100), Sword clang (volume 0.9, distance 3), Distant thunder (volume 1.0, distance 200), Player heartbeat (volume 0.2, distance 0.5).Player heartbeat: 0.2 / 0.5 = 0.4000.
Sword clang: 0.9 / 3 = 0.3000.
Distant thunder: 1.0 / 200 = 0.0050.
Wind ambience: 0.4 / 100 = 0.0040.
Ranked: Player heartbeat (0.4000), Sword clang (0.3000), Distant thunder (0.0050), Wind ambience (0.0040). With maxActiveVoices = 2, the heartbeat and the sword clang stay audible; the thunder and the wind get muted, even though thunder has the single highest raw volume of the four — sitting 200 units away drags its score below even the quiet, nearby heartbeat.
A sound's position in the world boils down to the same tools as everything else in this curriculum's math chapters: two points, and the vector between them. Its length drives distance attenuation, fading volume in a straight line (linear) or the faster-then-flatter real-world shape (logarithmic). Its direction, dotted against the listener's right and forward vectors, drives left/right panning and foreshadows the front/back ambiguity that only true HRTF filtering can resolve. A dot product against relative velocity gives the Doppler pitch shift; a raycast between source and listener, feeding a low-pass filter, gives occlusion; a world-space volume around the listener gives reverb its sense of a space's size. None of it is free, which is why the last piece is always the same lesson as everywhere else in engine work: rank what matters, and only spend CPU time on that.