Guidance / Manual
Occlusion Mixer Slot Pool
The system for muffling SFX voices behind walls, geometry, and (via propagation) through doorways. Ships alongside the propagation subsystem from chapter 13 and composes with it cleanly — propagation handles “rooms and openings,” this chapter handles “stuff in the way between the listener and the emitter.”
14.1 Why Mixer Slots, Not Per-Source Filters
The obvious way to muffle a 3D sound is to attach an AudioLowPassFilter to its AudioSource and modulate the cutoff. We tried that first. It clicks.
The cause: AudioLowPassFilter carries internal biquad filter state — the running coefficients of the IIR. When the host AudioSource is stopped and re-played (which happens every time the voice pool reuses an AudioSource), Unity resets the biquad to default state at the next sample boundary. Whatever signal is in the source’s playback buffer hits an uninitialised filter and produces a sample-boundary discontinuity audible as a per-acquire click. Pool-driven SFX, especially anything firing fast like footsteps or weapon shots, surface this as a rhythmic ticking layered under the actual sound.
Mixer effects don’t have this problem. AudioMixerEffectController instances live on AudioMixerGroups — they don’t reset when an AudioSource starts and stops. If we author N mixer groups per logical bus, each with a Lowpass effect whose cutoff is an exposed mixer parameter, voices can be routed through one of those groups for the duration of their playback. The voice’s own AudioSource has no filter component at all. Muffling becomes a mixer write to a stable, long-lived effect — no biquad reset, no click.
That’s the slot pool. One mixer group per slot, N slots per bus. A pre-built pool of routing destinations that voices borrow and return.
Filter curves are different. A container’s Low Pass and High Pass Parameter Curves (§4.1) do put an
AudioLowPassFilter/AudioHighPassFilteron the voices that need them, switched off when fully open. They are meant for long sounds that move slowly through a filter (an engine loop dulling with load, wind opening with speed), where a reset at the voice’s start doesn’t matter. Occlusion still uses the slots described here, and short, fast-firing sounds should stay away from filter curves.
14.2 Mental Model
┌────────────────────────────────────────────────────────┐
│ Voice Mixer (Unity AudioMixer asset) │
│ │
│ Master │
│ ├── Footsteps ← logical bus │
│ │ └── Footsteps_OcclusionLayer │
│ │ ├── Footsteps_Slot_00 ←─┐ │
│ │ ├── Footsteps_Slot_01 │ voices borrow │
│ │ ├── Footsteps_Slot_02 │ ONE of these │
│ │ ├── Footsteps_Slot_03 │ at acquire │
│ │ ├── Footsteps_Slot_04 │ time │
│ │ └── Footsteps_Slot_05 ←─┘ │
│ │ │
│ ├── Weapons ← another logical bus │
│ │ └── Weapons_OcclusionLayer │
│ │ ├── Weapons_Slot_00 │
│ │ … │
│ ⋮ │
└────────────────────────────────────────────────────────┘
- Slots are pre-built: the mixer groups, their Lowpass effects, and the exposed cutoff parameters are all authored at edit time. The runtime never adds or removes groups.
- Voices acquire at play time: when a container with
Use Occlusionplays,AudioManager.GetVoice(container)callsAcquireOcclusionSlot(bus)to lease one of the free slots. The voice’soutputAudioMixerGroupis set to the slot’s mixer group for its entire lifetime. - Voices release on completion:
ReturnVoicepushes the slot back to the free queue. The released slot’s cutoff is reset to 22 kHz (transparent) so the next voice that grabs it starts unmuffled. - Muffling is a mixer write: every frame,
OcclusionPerf.LateUpdatewrites the smoothed cutoff target to the held slot’s exposed parameter viavoiceMixer.SetFloat(slot.CutoffParam, hz). No filter component on the voice itself.
14.3 Authoring the Slot Layout
Open Window ▸ Audio System ▸ Occlusion Layout to author the slot hierarchy:
- Voice Mixer — assign the project’s voice mixer asset.
- Occlusion Layout — assign or create an
OcclusionLayoutScriptableObject. This is where the per-bus slot map gets serialized; the runtime reads it at startup. - Reverb Send Bus Registry (optional, covered in chapter 15) — when assigned, each generated slot also receives one Send effect per registered reverb bus.
- Slots Per Bus — how many concurrent occluded voices each bus can host (default 6).
- Scan, Auto-Create, Refresh — walks the project for
AudioContainerassets withUse Occlusion,Allow Reverb SendorUse Propagationturned on (any of the three takes a slot at play time, see §14.4) andAmbientSourcecomponents withUse Occlusion = truein the open scenes, collects the unique mixer groups they play through (a layer withUses Parent Outputcounts its Blend’s or Switch’s group), and authors one<BusName>_OcclusionLayerchild group per bus, each containing N slot groups (<BusName>_Slot_00,_Slot_01, …) with a Lowpass effect and exposed cutoff parameter on each.
After Generate, the OcclusionLayout asset holds the layout. Drop it into AudioManager ▸ Occlusion Layout, and the runtime builds its slot pools from it at Awake.
The “Scan only” button runs the same scan without mutating the mixer — useful for sanity-checking which buses would be authored without committing changes.
14.4 Container Opt-In Flags
A voice acquires a slot when its container opts into any of three features:
| Container flag | What it does | Requires slot? |
|---|---|---|
Use Occlusion |
Raycast occlusion: muffle voices behind walls via listener→source linecast. | For the low-pass (the volume drop works without one) |
Use Propagation |
Propagation cutoff: muffle voices through portals via the propagation graph. | For the low-pass (the volume drop works without one) |
Allow Reverb Send |
Per-zone reverb sends (chapter 15). | Yes |
Static Emitter |
Hint that this container’s voices don’t move. Speeds up the reverb-send driver. | No (modifier) |
Explicit Reverb Bus |
Override the zone-driven reverb send routing for this container. | No (modifier) |
A container with none of the three feature flags routes voices directly to its bus (no slot). A container with at least one feature flag tries to acquire a slot when it plays; the AcquireOcclusionSlot call is upfront, before the AudioSource’s Play(), because mid-playback outputAudioMixerGroup reassignment is click-prone in Unity.
Pool exhaustion: if all slots on the bus are in use when a new voice tries to acquire, AcquireOcclusionSlot returns null and the voice routes directly to the bus (no low-pass, no reverb sends; occlusion’s and propagation’s volume drop still applies, since it’s written to the voice, not the mixer). A throttled warning is logged once per second per bus — bump Slots Per Bus on the layout asset if you see this consistently.
14.5 Runtime Lifecycle
The slot lifecycle per voice:
container.Play(position)
↓
AudioManager.GetVoice(container)
↓
if (UseOcclusion || UsePropagation || AllowReverbSend)
AcquireOcclusionSlot(container.MixerGroup) → slot
↓
ConfigureAudioSource(voice)
↓
source.outputAudioMixerGroup = slot.MixerGroup (or container.MixerGroup if no slot)
↓
source.Play()
↓
... [voice plays] ...
↓
Every OcclusionUpdate tick (default 5 Hz):
OcclusionRaycastGain = ... ← raycast target
OcclusionRaycastCutoff = ... ← raycast cutoff target
↓
Every frame, LateUpdate:
Read propGain / propCutoff from PropagationManager cache (if UsePropagation)
Compose: gainTarget = raycastGain × propGain
Compose: cutoffTarget = min(raycastCutoff, propCutoff)
Exponentially smooth voice.GainStack.OcclusionGain toward gainTarget
voiceMixer.SetFloat(slot.CutoffParam, smoothedCutoff)
↓
source.Stop() or natural completion
↓
AudioManager.ReturnVoice(voice)
↓
ReleaseOcclusionSlot(slot)
↓
voiceMixer.SetFloat(slot.CutoffParam, 22000) ← reset cutoff for next voice
voiceMixer.SetFloat(slot.SendLevelParams[*], -80) ← silence reverb sends
pool.freeIndices.Enqueue(slot.IndexInPool)
Two key invariants: the slot is acquired before the first audio sample plays, and the slot is reset before the next voice borrows it. Both are click-prevention guarantees.
14.6 The Raycast Path (Use Occlusion)
For voices with Use Occlusion = true, the OcclusionUpdate coroutine fires a fan of rays from the listener toward the source on each tick (default 5 Hz, configurable via Occlusion Update Interval).
Single ray (Occlusion Ray Count = 1): the original behavior. A single linecast from listener to source. If blocked, the voice clamps to raycastGain = 0.3 and raycastCutoff ≈ 6.95 kHz (the cutoff follows the gain: Lerp(22000, 500, 1 − gain), so it never reaches 500 Hz from rays alone); if clear, both stay at unity / 22 kHz. Binary step.
Multi-ray partial occlusion (Occlusion Ray Count > 1, default 3): fans N rays toward N points spread perpendicular to the listener→source axis at the source end, with spread width Occlusion Ray Spread Meters (default 0.5 m). Counts how many of the N rays are blocked, computes a fraction, and lerps gain/cutoff targets by that fraction. With RayCount = 3 you get four discrete blocked levels (0/3, 1/3, 2/3, 3/3) which the per-frame smoother rides over for perceptually continuous edge transitions — walking past a wall edge produces a gradient instead of a 10 dB step.
This is edge diffraction approximated: when the listener is behind a wall whose far edge is near the source, the wall-side ray is blocked but the edge-side ray clears, producing a partial occlusion fraction that crossfades the source up as you walk into line of sight. It’s not a true geometric diffraction solver, but it sidesteps the worst artefact of binary occlusion (the “sudden snap” as the listener crosses an edge) at the cost of N raycasts instead of 1.
Raycasts run with QueryTriggerInteraction.Ignore so AudioZone and AudioPortal trigger volumes don’t act as occluders. The source’s own collider hierarchy and the listener’s collider hierarchy are filtered out, so the emitter’s primitive and the player’s capsule don’t self-occlude the line.
14.7 Composition With Propagation (Use Propagation)
For voices with Use Propagation = true, the LateUpdate smoother also reads the propagation subsystem’s per-zone cache:
propGain=PropagationManager.GetSourceAudibility(sourceZone)— the chain-of-portals linear amplitude weight from listener to source.propCutoff=PropagationManager.GetSourcePropagationCutoff(sourceZone)— the minimum cutoff across all portals on the path.
These compose with the raycast targets:
gainTarget = raycastGain × propGain (multiplicative)
cutoffTarget = min(raycastCutoff, propCutoff) (most-muffled-wins)
Why min, not multiply, for cutoff? Cascaded low-pass filters are dominated by the tightest one — later filters can’t restore frequencies an earlier one removed. The solver chains cutoffs the same way; raycast and propagation compose the same way; everything stays consistent.
Outside-the-graph handling: when the listener or source falls outside every registered AudioZone, propagation has no opinion. PropagationManager.SilenceOutsideGraph picks:
- Off (default):
propGain = 1,propCutoff = 22 kHz. Voices outside the graph play at full audibility. Use when AudioZones are a whitelist for bespoke acoustics and “outside” means “the open world plays normally.” - On:
propGain = 0,propCutoff = 500 Hz. Voices outside the graph fall silent and maximally muffled. Use when every walkable space is meant to be inside some zone, and “outside” means “this is a level-design escape — surface the bug.”
The default is Off because surprise silence is harder to debug than surprise audibility.
14.8 Per-Frame Smoothing
The slow OcclusionUpdate tick (5 Hz default = 200 ms) is too coarse for perceptual continuity — a 0.3→1.0 gain step at the tick boundary surfaces as a click. The fix: slow tick stores step targets, per-frame LateUpdate smooths toward them.
Two inspector-tunable time constants on AudioManager:
| Field | Default | What it controls |
|---|---|---|
Occlusion Gain Smoothing Seconds |
0.06 | Tau for the gain (volume) axis. Settles to 95% in ~3τ. |
Occlusion Cutoff Smoothing Seconds |
0.04 | Tau for the lowpass cutoff axis. |
Smoothing is framerate-independent: k = 1 - exp(-dt / tau), then current = lerp(current, target, k). At 60 fps with tau = 0.06, k ≈ 0.24 per frame.
Keep the two taus close to each other. Large mismatches read as “the tone opens before the volume” (or vice versa) across an occlusion edge. The defaults (0.06 gain, 0.04 cutoff) are deliberately close.
Unity’s own AudioSource.volume ramp: Unity applies its own ~40 ms ramp on every volume write at default DSP buffer size. That sets a floor on how snappy occlusion gain can feel regardless of our smoother. Set Project Settings ▸ Audio ▸ DSP Buffer Size to “Best Latency” to drop the floor to ~10 ms; designers chasing sub-50 ms perceived occlusion will need this.
14.9 Performance & Scaling
- OcclusionUpdate cost:
realVoiceCount × occlusionRayCountraycasts per tick. At default settings (5 Hz × 3 rays × 20 voices = 300 rays/sec ≈ 5 raycasts per frame at 60 fps), this is negligible. Raycasts are microseconds each. - LateUpdate cost: O(realVoiceCount) per frame for the smoother. One mixer SetFloat per voice with a slot. Tens of voices is free; hundreds is still cheap.
- Pool sizing:
defaultOcclusionSlotsPerBus(default 6) means up to 6 concurrent occluded voices per bus. A scene firing more than 6 footsteps simultaneously on one bus will see pool exhaustion warnings — bump to 8–12 if needed. The cost per extra slot is a mixer group + one Lowpass effect + one exposed parameter, negligible at runtime. - Allocation-free in the hot path: slot acquisition is queue dequeue (O(1), no allocation); cutoff writes are direct mixer calls.
14.10 Design Limits
- Slot exhaustion → no low-pass fallback. When the pool is empty, new voices play through the bus directly (no slot, no low-pass; they still get quieter behind walls). This is intentional — graceful degradation beats failing to play. Watch the console for the throttled exhaustion warning and bump slots per bus accordingly.
- Multi-ray is not true diffraction. It approximates edge softening for the most common case (wall with a near edge) but doesn’t handle wraparound, multiple reflections, or frequency-dependent diffraction.
- Mixer is the cutoff source of truth. There’s no per-voice cached “current cutoff” —
LateUpdatereads back viavoiceMixer.GetFloatand lerps. Other code that writes to the same exposed parameter (a custom inspector slider during play, an editor script) will interleave with the smoother. - One layout per project. The slot pool architecture assumes a single voice mixer. Multi-mixer projects need separate AudioManagers (rarely needed).
14.11 Troubleshooting
Voices play unmuffled even though Use Occlusion is on.
- If they still get quieter behind walls, the rays work and only the slot is missing: the volume drop doesn’t need one, the low-pass does.
- Is
Voice Mixerassigned on AudioManager? Without it, slot pools never build and the warning atAwakewill tell you so. - Is
Occlusion Layoutassigned? Same diagnosis path. - Did you re-run the Occlusion Layout Builder after adding the container? Containers added since the last Generate aren’t in the layout yet.
- Inspect
AudioManager.OcclusionSlotPoolsReadyat play time — false means slot pools didn’t build (mixer-layout mismatch, empty layout, etc.).
Console says Occlusion slot pool exhausted on bus 'X'.
- Self-explanatory: more concurrent occluded voices on that bus than slots configured. Increase
Slots Per Buson the layout asset and re-run Generate. The default 6 is sized for typical SFX density; weapon-heavy or footstep-dense scenes can want 10–16.
Console says ... on bus 'X', which has no slots in the Occlusion Layout.
- A container with
Use Occlusion,Allow Reverb SendorUse Propagationplayed through a bus the layout has no slots for (AudioManager.GetVoice, once per bus, only when a layout is assigned). The layout predates that bus needing slots: re-run Scan, Auto-Create, Refresh (§14.3).
Sound clicks on the first frame of playback.
- If the voice has a slot, the click is almost never from the system — check clip authoring (leading sample boundary, encoder pre-roll). If you can repro on a slotless container by toggling
Use Occlusion, file a bug.
Bus routing looks wrong in the mixer.
- After Generate, each slot’s mixer group should be a child of
<BusName>_OcclusionLayer, which is a child of the user-authored bus. If you see slots floating at the master root or under a different bus, the layout asset is stale relative to the current mixer — re-run Generate.
A specific container’s voices ignore occlusion entirely.
- Confirm the container has
Use Occlusion = true. Confirm itsMixer Groupis set to a bus that appears in the layout asset’s bus list (containers whose bus isn’t in the layout will be skipped silently at runtime, since there’s no pool for that bus).
From the Pepper.SFX 3.1 guides (USER_GUIDE.md). Something wrong or unclear? Tell me.