Skip to content

Pathing not working without audio middleware in UE 5.8 #568

Description

@chefsvgames-star

NOTE: This was written with Claude Opus 5. I'm not an audio guy by any means, however this was an issue that was bugging me, and used Claude Opus 5 with Ultracode to fix the problem (at least it appears so in my testing), or at least get somewhere to the point I can raise an issue that may lead to a potential fix.

The short version is that you cannot use baked pathing without using middleware like FMOD because of an apparent bug within Unreal Engine (according to the documentation). The following is what Claude Opus 5 gave me when prompted on how it managed to fix it:

[UE] Pathing is silenced by the occlusion plugin — the three Unreal plugins run serially over one shared buffer

  • Steam Audio version: 4.8.1
  • Unreal Engine version: 5.8.0
  • Audio engine: Unreal's built-in audio engine (no FMOD Studio, no Wwise)
  • Operating System and version: Windows 11 24H2
  • CPU architecture: x86-64

Issue Description

With Unreal's built-in audio engine, iplPathEffectApply in FSteamAudioSpatializationPlugin::ProcessAudio
is fed a signal that FSteamAudioOcclusionPlugin::ProcessAudio has already attenuated.

So in the exact situation pathing exists for — an occluded source whose sound should reach the listener
through a doorway — the direct path drives the plugin input to ~0, and the pathing send is therefore also
~0. Pathing appears to do nothing at all. Nothing errors, nothing is logged.

This is, I believe, the actual reason pathing is so widely reported as "only working with middleware".

Mechanism

Unreal exposes three independent plugin slots — IAudioReverb, IAudioOcclusion, IAudioSpatialization
and FMixerSourceManager::ComputePluginAudio runs them one after another over the same
SourceInfo.SourceBuffer. From Engine/Source/Runtime/AudioMixer/Private/AudioMixerSourceManager.cpp (5.8):

:3210  float* PostDistanceAttenBufferPtr = SourceInfo.SourceBuffer.GetData();

:3220  AudioPluginInputData.AudioBuffer = &SourceInfo.SourceBuffer;   // reverb    <- DRY
:3227  MixerDevice->ReverbPluginInterface->ProcessSourceAudio(...);

:3252  AudioPluginInputData.AudioBuffer = &SourceInfo.SourceBuffer;   // occlusion <- still DRY
:3260  MixerDevice->OcclusionInterface->ProcessAudio(...);

       // occlusion output is written BACK INTO SourceBuffer:
:3266  if (bShouldMixInReverb)
:3268      Audio::ArraySum(ReverbPluginOutputBufferView, AudioPluginOutputDataView, PostDistanceAttenBufferView);
:3270  else
:3272      FMemory::Memcpy(PostDistanceAttenBufferPtr, SourceInfo.AudioPluginOutputData.AudioBuffer.GetData(), ...);

:3290  AudioPluginInputData.AudioBuffer = &SourceInfo.SourceBuffer;   // spatializer <- NOW OCCLUDED
:3304  SpatialInterfaceInfo.SpatializationPlugin->ProcessAudio(...);

Steam Audio's Unreal integration splits its three signal paths across those three slots:

Steam Audio path Unreal plugin slot Runs Input it receives
Direct (iplDirectEffectApply) Occlusion 2nd dry ✔
Reflections (iplReflectionEffectApply) Reverb 1st dry ✔
Pathing (iplPathEffectApply) Spatialization 3rd post-occlusion ✘

Because the spatialization plugin runs last, SteamAudioSpatialization.cpp is building its pathing send
out of a buffer the direct path has already killed:

for (int i = 0; i < InBuffer.numSamples; ++i)
{
    Source.PathingInputBuffer.data[0][i] = Source.PathingMixLevel * InBuffer.data[0][i];  // InBuffer is occluded
}

Reflections escape this purely by accident of ordering — the reverb plugin happens to run first, on the
still-dry buffer. That makes the bug easy to misdiagnose: you can hear sound "getting around" a wall via
reflections and reasonably conclude pathing is working.

One extra wrinkle: neither Steam Audio plugin overrides IsExternalSend(), so when the reverb plugin is
also enabled, line 3268 sums the reverb output into the buffer. The spatializer's input is then
occluded direct + reflections, and the pathing send carries the reflection tail as well.

For FMOD Studio and Wwise, Steam Audio ships a single spatializer effect that branches direct,
reflections and pathing off one dry input, so none of this arises. The problem is specific to the
three-slot Unreal integration.

Steps To Reproduce

  1. UE project using the built-in audio engine. Enable the Steam Audio plugin, and in
    Project Settings → Platforms → Windows → Audio set Spatialization, Occlusion and Reverb plugins to Steam Audio.
  2. A room with a solid wall and one doorway. Tag it with Steam Audio Geometry, export static geometry.
  3. Add a Steam Audio Probe Volume, generate probes, and run Bake Pathing from its Details panel.
  4. Place a looping sound behind the wall, with a Steam Audio Source component: Simulate Pathing on,
    Pathing Probe Batch assigned.
  5. On its attenuation asset, add Steam Audio Occlusion settings (Apply Occlusion on) and
    Steam Audio Spatialization settings (Apply Pathing on).
  6. Stand where there is no line of sight to the source. Expected: sound arrives through the doorway.
    Actual: near silence.
  7. The tell: turn Apply Occlusion off and pathing becomes clearly audible. Pathing's audibility is
    inversely proportional to how well occlusion is working.

Fix

Fixable entirely inside the plugin — no engine changes required.

The occlusion plugin publishes its still-dry input into a small per-FAudioDevice, per-SourceId cache
before iplDirectEffectApply runs; the spatialization plugin consumes it and uses it as the pathing send.
InBuffer is deliberately left alone, so the binaural/panning stage still renders the occluded direct path.

SteamAudioOcclusion.cpp, in ProcessAudio, before the direct effect is applied:

float* InBufferData  = InputData.AudioBuffer->GetData();
float* OutBufferData = OutputData.AudioBuffer.GetData();

+   if (DrySignalCache.IsValid())
+   {
+       DrySignalCache->Store(InputData.SourceId, InBufferData, InputData.AudioBuffer->Num());
+   }

SteamAudioSpatialization.cpp, in ProcessAudio:

+   // consumed unconditionally every block, so a stored buffer can never leak into a later
+   // sound that recycles this SourceId
+   const float* DrySignal = DrySignalCache.IsValid()
+       ? DrySignalCache->Consume(InputData.SourceId, AudioSettings.frameSize)
+       : nullptr;
    ...
+   const float* PathingSrc = DrySignal ? DrySignal : InBuffer.data[0];
    for (int i = 0; i < InBuffer.numSamples; ++i)
    {
-       Source.PathingInputBuffer.data[0][i] = Source.PathingMixLevel * InBuffer.data[0][i];
+       Source.PathingInputBuffer.data[0][i] = Source.PathingMixLevel * PathingSrc[i];
    }

The cache itself is ~140 lines: a TArray<FSlot> indexed by SourceId, slots sized in OnInitSource to
NumChannels * frameSize (the input is interleaved), never resized on the audio render thread, invalidated
in OnReleaseSource, and held per-FAudioDevice via a TMap<FAudioDevice*, TWeakPtr<...>> so that two
audio devices can't collide on the same SourceId.

An alternative worth considering, and possibly cleaner on your side: move the pathing render into the
reverb plugin's ProcessSourceAudio, which already runs on the dry buffer and already writes to a
separate output that the engine mixes back in. Pathing is an indirect path, so it sits naturally next to
reflections. The downside is that it would move the Apply Pathing toggle from the Spatialization settings
asset to the Reverb settings asset, which is a breaking change for existing projects.

Other issues found while tracking this down

  1. FSteamAudioSpatializationPluginFactory::CreateNewSpatializationPlugin never calls RegisterAudioDevice,
    unlike the occlusion and reverb factories. Registering is what installs FSteamAudioPluginListener, which
    is the only thing that ever updates the listener transform. A project that enables Steam Audio
    Spatialization without also enabling Occlusion or Reverb therefore leaves the listener pinned at the world
    origin with a degenerate orientation basis — which silently breaks pathing, since the path effect rotates
    its SH field by that basis. This bites precisely the minimal setup someone would build to test pathing.

  2. Ambisonic order mismatch on the pathing coefficients. USteamAudioSourceComponent::SetInputs sets
    Inputs.pathingOrder = BakingAmbisonicOrder, but the simulator's pathing output array is sized from the
    real-time order (SteamAudioManager.cpp:518). Symmetrically,
    FSteamAudioSpatializationPlugin::ProcessAudio does
    Memcpy(Source.PathingCoeffs.GetData(), Outputs.pathing.shCoeffs, Source.PathingCoeffs.Num() * sizeof(float))
    where PathingCoeffs is real-time-sized (SteamAudioSpatialization.cpp:226) and shCoeffs is
    baking-sized. So whichever of the two orders is larger, something reads or writes past the end of an
    array. Both default to 1, which is why it hasn't shown up much — it only appears once someone raises one
    of them in Project Settings.

  3. Unguarded GetOwner() on the audio render thread. Both SteamAudioOcclusion.cpp and
    SteamAudioReverb.cpp do:

    USteamAudioSourceComponent* C = (AudioComponent) ? AudioComponent->GetOwner()->FindComponentByClass<...>() : nullptr;

    GetOwner() can return null (e.g. an audio component whose owner is mid-teardown), so this is a null
    dereference on the audio render thread. SteamAudioSpatialization.cpp has the same pattern.

  4. The FIXME in SteamAudioSpatialization.cpp is stale and actively misleading:

    // FIXME: Unreal 4.27 does not pass the audio component id correctly to the spatializer plugin. It does this correctly for the occlusion and reverb plugins.

    That was true of 4.27, but Epic fixed it. AudioMixerSourceManager.cpp:3294 assigns AudioComponentId
    for the spatializer exactly as it does for occlusion (:3255) and reverb (:3223). This comment cost me a
    good while chasing the wrong root cause — worth deleting.

  5. SimulationUpdateTimeElapsed is never reset in FSteamAudioManager::Tick, so after the first update the
    simulation runs every frame regardless of SimulationUpdateInterval. Already filed as [Steam Audio UE] BUG: SimulationUpdateInterval NEVER RESETS in SteamAudioManager::Tick() #539.

Possibly related, though a different mechanism (Wwise): #513.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions