You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
:3210float* 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:
:3266if (bShouldMixInReverb)
:3268Audio::ArraySum(ReverbPluginOutputBufferView, AudioPluginOutputDataView, PostDistanceAttenBufferView);
:3270else
:3272FMemory::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
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.
A room with a solid wall and one doorway. Tag it with Steam Audio Geometry, export static geometry.
Add a Steam Audio Probe Volume, generate probes, and run Bake Pathing from its Details panel.
Place a looping sound behind the wall, with a Steam Audio Source component: Simulate Pathing on, Pathing Probe Batch assigned.
On its attenuation asset, add Steam Audio Occlusion settings (Apply Occlusion on) and
Steam Audio Spatialization settings (Apply Pathing on).
Stand where there is no line of sight to the source. Expected: sound arrives through the doorway. Actual: near silence.
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:
+ // consumed unconditionally every block, so a stored buffer can never leak into a later
+ // sound that recycles this SourceId
+ constfloat* DrySignal = DrySignalCache.IsValid()
+ ? DrySignalCache->Consume(InputData.SourceId, AudioSettings.frameSize)
+ : nullptr;
...
+ constfloat* 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
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.
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.
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.
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.
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
Issue Description
With Unreal's built-in audio engine,
iplPathEffectApplyinFSteamAudioSpatializationPlugin::ProcessAudiois fed a signal that
FSteamAudioOcclusionPlugin::ProcessAudiohas 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::ComputePluginAudioruns them one after another over the sameSourceInfo.SourceBuffer. FromEngine/Source/Runtime/AudioMixer/Private/AudioMixerSourceManager.cpp(5.8):Steam Audio's Unreal integration splits its three signal paths across those three slots:
iplDirectEffectApply)iplReflectionEffectApply)iplPathEffectApply)Because the spatialization plugin runs last,
SteamAudioSpatialization.cppis building its pathing sendout of a buffer the direct path has already killed:
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 isalso 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
Project Settings → Platforms → Windows → Audio set Spatialization, Occlusion and Reverb plugins to Steam Audio.
Pathing Probe Batch assigned.
Steam Audio Spatialization settings (Apply Pathing on).
Actual: near silence.
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-SourceIdcachebefore
iplDirectEffectApplyruns; the spatialization plugin consumes it and uses it as the pathing send.InBufferis deliberately left alone, so the binaural/panning stage still renders the occluded direct path.SteamAudioOcclusion.cpp, inProcessAudio, before the direct effect is applied:SteamAudioSpatialization.cpp, inProcessAudio:The cache itself is ~140 lines: a
TArray<FSlot>indexed bySourceId, slots sized inOnInitSourcetoNumChannels * frameSize(the input is interleaved), never resized on the audio render thread, invalidatedin
OnReleaseSource, and held per-FAudioDevicevia aTMap<FAudioDevice*, TWeakPtr<...>>so that twoaudio 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 aseparate 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 Pathingtoggle from the Spatialization settingsasset to the Reverb settings asset, which is a breaking change for existing projects.
Other issues found while tracking this down
FSteamAudioSpatializationPluginFactory::CreateNewSpatializationPluginnever callsRegisterAudioDevice,unlike the occlusion and reverb factories. Registering is what installs
FSteamAudioPluginListener, whichis 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.
Ambisonic order mismatch on the pathing coefficients.
USteamAudioSourceComponent::SetInputssetsInputs.pathingOrder = BakingAmbisonicOrder, but the simulator's pathing output array is sized from thereal-time order (
SteamAudioManager.cpp:518). Symmetrically,FSteamAudioSpatializationPlugin::ProcessAudiodoesMemcpy(Source.PathingCoeffs.GetData(), Outputs.pathing.shCoeffs, Source.PathingCoeffs.Num() * sizeof(float))where
PathingCoeffsis real-time-sized (SteamAudioSpatialization.cpp:226) andshCoeffsisbaking-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.
Unguarded
GetOwner()on the audio render thread. BothSteamAudioOcclusion.cppandSteamAudioReverb.cppdo: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 nulldereference on the audio render thread.
SteamAudioSpatialization.cpphas the same pattern.The
FIXMEinSteamAudioSpatialization.cppis stale and actively misleading:That was true of 4.27, but Epic fixed it.
AudioMixerSourceManager.cpp:3294assignsAudioComponentIdfor the spatializer exactly as it does for occlusion (
:3255) and reverb (:3223). This comment cost me agood while chasing the wrong root cause — worth deleting.
SimulationUpdateTimeElapsedis never reset inFSteamAudioManager::Tick, so after the first update thesimulation 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.