namespace Sandbox; /// /// 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. /// internal sealed class SoundOcclusionSystem : GameObjectSystem { /// /// 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. /// record struct PendingOcclusionUpdate( SoundHandle Handle, Audio.SteamAudioSource Source, Vector3 ListenerPosition ); // Static lists to avoid allocations each frame static readonly List _tempHandles = new(); static readonly List _pendingUpdates = new(); static readonly List _sceneListeners = new(); static readonly Dictionary _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 ); } ); } /// /// Compute how occluded a sound is. Returns 0 if fully occluded, 1 if not occluded. /// 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); } /// /// Collect all handle/listener pairs that need occlusion updates this frame. /// Must be called on main thread to access Listener.Position. /// void GetPendingUpdates( List 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 ) ); } } } } }