Files
sbox-public/engine/Sandbox.Engine/Scene/GameObjectSystems/SoundOcclusionSystem.cs
Lorenz Junglas 9d072e6d89 Audio Optimizations (#3847)
- Auto-cleanup idle voice handles: Voice sound handles are now killed when player hasn't been talking for a while
- Audio Occlusion refactor: Moved occlusion calculation to a dedicated SoundOcclusionSystem GameObjectSystem for better organization
  - This now performs really well, so we could look into improving our occlusion calculation: proper damping when sound is transferred via wall, path tracing for occlusion etc. (will open a separate issue)
- Fix mixer early return bug: Fixed issue where mixer could return early, potentially skipping sounds
- Voice Component lipsync toggle: Added option to enable/disable lipsync processing on VoiceComponent
- Cheaper HRTF for voice audio: Disabled expensive bilinear interpolation for voice and certain other sounds in Steam Audio
- Editor MixerDetail perf: Skip frame updates on MixerDetail widget when not visible
- Reduced allocations in audio systems: Optimized away List and LINQ allocations in SoundOcclusionSystem, Listener, and SoundHandle.Source
- MP3 decoder buffer optimization: Improved buffer handling in native CAudioMixerWaveMP3 to reduce overhead

Depending on scenario can reduces audio frame time by up to 30%.
This should also drastically improve performance for games that use VOIP.
2026-01-20 18:48:09 +01:00

164 lines
5.2 KiB
C#

namespace Sandbox;
/// <summary>
/// Updates sound occlusion in parallel during StartFixedUpdate.
/// This moves the expensive physics traces out of the main sound tick loop
/// and parallelizes them across all sounds rather than per-sound.
/// </summary>
internal sealed class SoundOcclusionSystem : GameObjectSystem
{
/// <summary>
/// Cached data for parallel occlusion updates.
/// We cache listener positions on the main thread because Listener.Position
/// has a main thread assertion and cannot be accessed from worker threads.
/// </summary>
record struct PendingOcclusionUpdate( SoundHandle Handle, Audio.SteamAudioSource Source, Vector3 ListenerPosition );
// Static lists to avoid allocations each frame
static readonly List<SoundHandle> _tempHandles = new();
static readonly List<PendingOcclusionUpdate> _pendingUpdates = new();
static readonly List<Audio.Listener> _sceneListeners = new();
static readonly Dictionary<Audio.Mixer, int> _voiceCountByMixer = new();
public SoundOcclusionSystem( Scene scene ) : base( scene )
{
Listen( Stage.StartFixedUpdate, -50, UpdateSoundOcclusion, "SoundOcclusion" );
}
void UpdateSoundOcclusion()
{
using var _ = PerformanceStats.Timings.Audio.Scope();
var world = Scene.PhysicsWorld;
if ( !world.IsValid() ) return;
// Get listener positions for this scene - filter directly from ActiveList
_sceneListeners.Clear();
var scene = Scene;
foreach ( var listener in Audio.Listener.ActiveList )
{
if ( listener.Scene == scene )
{
_sceneListeners.Add( listener );
}
}
if ( _sceneListeners.Count == 0 ) return;
// Collect all pending updates on the main thread.
// We must cache listener positions here because Listener.Position
// requires main thread access (ThreadSafe.AssertIsMainThread).
_pendingUpdates.Clear();
GetPendingUpdates( _sceneListeners );
if ( _pendingUpdates.Count == 0 ) return;
// Process all updates in parallel - each does its own sequential ray traces
Sandbox.Utility.Parallel.ForEach( _pendingUpdates, update =>
{
var handle = update.Handle;
var targetMixer = handle.TargetMixer ?? Audio.Mixer.Default;
var position = handle.Transform.Position;
var occlusionSize = handle.OcclusionRadius;
var listenerPos = update.ListenerPosition;
var occlusion = ComputeOcclusion( position, listenerPos, occlusionSize, targetMixer, world );
update.Source.SetTargetOcclusion( occlusion );
// Schedule next update based on distance - close sounds update more often
// Distance threshold: 8192 units (~208 meters)
// Close sounds: ~7 Hz, Far sounds: ~0.33 Hz
var distance = Vector3.DistanceBetween( position, listenerPos ).Remap( 0, 8192, 1, 0 ).Clamp( 0, 1 );
update.Source.TimeUntilNextOcclusionCalc = distance.Remap( 3.0f, 0.15f ) * Random.Shared.Float( 0.9f, 1.1f );
} );
}
/// <summary>
/// Compute how occluded a sound is. Returns 0 if fully occluded, 1 if not occluded.
/// </summary>
static float ComputeOcclusion( Vector3 position, Vector3 listener, float occlusionSize, Audio.Mixer targetMixer, PhysicsWorld world )
{
var distance = Vector3.DistanceBetween( position, listener ).Remap( 0, 4096, 1, 0 );
int iRays = (occlusionSize.Remap( 0, 64, 1, 32 ) * distance).CeilToInt().Clamp( 1, 32 );
int iHits = 0;
var tags = targetMixer.GetOcclusionTags();
// tags are defined, but are empty, means hit nothing - so 0% occluded
// if it is null, then we just use the "world" tag
if ( tags is not null && tags.Count == 0 ) return 1.0f;
for ( int i = 0; i < iRays; i++ )
{
var startPos = position + Vector3.Random * occlusionSize * 0.5f;
var tq = world.Trace.FromTo( startPos, listener );
if ( tags is null )
{
tq = tq.WithTag( "world" );
}
else
{
tq = tq.WithAnyTags( tags );
}
var tr = tq.Run();
if ( tr.Hit )
{
iHits++;
}
}
return 1 - (iHits / (float)iRays);
}
/// <summary>
/// Collect all handle/listener pairs that need occlusion updates this frame.
/// Must be called on main thread to access Listener.Position.
/// </summary>
void GetPendingUpdates( List<Audio.Listener> listeners )
{
_tempHandles.Clear();
SoundHandle.GetActive( _tempHandles );
// Sort by creation time descending (newest first) to match mixer priority
_tempHandles.Sort( ( a, b ) => b._CreatedTime.CompareTo( a._CreatedTime ) );
// Track voice count per mixer to respect MaxVoices limits
_voiceCountByMixer.Clear();
foreach ( var handle in _tempHandles )
{
if ( handle.Scene != Scene ) continue;
if ( !handle.Occlusion ) continue;
if ( handle.ListenLocal ) continue;
// Use shared CanBeMixed check (same as Mixer.ShouldPlay)
if ( !handle.CanBeMixed() ) continue;
var mixer = handle.GetEffectiveMixer();
if ( mixer is null ) continue;
// Check if we've exceeded MaxVoices for this mixer
_voiceCountByMixer.TryGetValue( mixer, out var count );
if ( count >= mixer.MaxVoices ) continue;
_voiceCountByMixer[mixer] = count + 1;
foreach ( var listener in listeners )
{
var source = handle.GetSource( listener );
if ( source is null ) continue;
if ( source.TimeUntilNextOcclusionCalc <= 0 )
{
_pendingUpdates.Add( new PendingOcclusionUpdate( handle, source, listener.Position ) );
}
}
}
}
}