e0c59ad663
Track B. All 21 one-shot cues funnelled through FeedbackFx.PlayClip -> AudioSource.PlayClipAtPoint, which allocates a GameObject + AudioSource per call and schedules a delayed Destroy — ~20-33 times a second in light combat. New OneShotAudioPool is a 32-voice 3D ring behind an UNCHANGED PlayClip signature, so all 20 consuming call sites are untouched. Parity is the whole game here: PlayClipAtPoint sets spatialBlend = 1 explicitly (a fresh AudioSource is 2D) and leaves the rest at stock defaults. Two deliberate divergences, both forced by the voices being long-lived: playOnAwake = false, and dopplerLevel = 0 because a pooled voice TELEPORTS between events and would otherwise pitch-bend. Root is DontDestroyOnLoad (WorldLauncher does LoadScene(Single) while the client world is alive) with a SubsystemRegistration reset, or session two rents destroyed voices. Authored impact VFX are pooled per prefab instead of Instantiate/Destroy per hit: components cached per INSTANCE (refs are instance-scoped), main.stopAction forced to None (a prefab set to Destroy silently drains the pool), instances filled under an inactive root so Awake/Start never run — which is what makes the DestroyImmediate in StripCosmetic safe — ps.Clear before Play, TrailRenderer.Clear after the reposition, and a Rented flag as the at-most-once guard against a double Return aliasing one instance to two callers. Per-frame allocation: the slash-arc and enemy-wedge mesh builders each allocated four arrays on every call (up to twice a frame, and once per winding enemy); HUD and ability-bar labels rebuilt their strings every frame; damage-number fades rewrote TextMesh vertex colours every frame; health bars pushed uGUI writes unconditionally; two systems played back an empty EntityCommandBuffer (a structural-change sync point) every frame. Also closes an AudioClip leak across all seven clip-owning systems: an AudioClip.Create'd clip is a standalone UnityEngine.Object, so destroying a system's FX root left it alive (MusicSystem ~6.8 MB, AmbientAudioSystem ~2 MB per client-world teardown). CombatFeedbackSystem's TryHold call sites go with this commit because they share the file; the camera-side removal lands in the next one. Verified live: PlayClipAtPoint's "One shot audio" GameObject never appears again across 270 frames of combat with kills; the VFX pool fills to its retain cap and stabilises; real cues route through the ring. 304/304 EditMode green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
9.1 KiB
C#
187 lines
9.1 KiB
C#
using UnityEngine;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Pooled 3D one-shot SFX voices — the allocation-free replacement for
|
|
/// <c>AudioSource.PlayClipAtPoint</c> that sits behind <see cref="FeedbackFx.PlayClip"/>.
|
|
/// <para>
|
|
/// <c>PlayClipAtPoint</c> allocates a fresh <c>GameObject</c> + <c>AudioSource</c> per call and
|
|
/// schedules a delayed <c>Destroy</c>. Every one-shot in the project funnels through
|
|
/// <see cref="FeedbackFx.PlayClip"/> (footsteps, hits, kills, telegraphs, growls, strike beeps,
|
|
/// swings, connects, socket fire, dash), measured at ~20-33 calls/s in light combat — i.e. hundreds
|
|
/// of create/destroy pairs per ten seconds of play. This ring replaces that with a fixed set of
|
|
/// long-lived voices, so a one-shot costs zero managed allocation.
|
|
/// </para>
|
|
/// <para>
|
|
/// PARITY with PlayClipAtPoint matters — 20 call sites are re-levelled at once by any deviation.
|
|
/// PlayClipAtPoint sets <c>spatialBlend = 1</c> explicitly (a fresh AudioSource defaults to 0 =
|
|
/// fully 2D) and leaves everything else at the stock defaults, so those are all re-stated in
|
|
/// <see cref="MakeVoice"/>. Two DELIBERATE divergences, both forced by the voices being long-lived
|
|
/// rather than per-event: <c>playOnAwake = false</c> (a pooled source would otherwise self-start on
|
|
/// any enable, replaying whatever clip is still assigned) and <c>dopplerLevel = 0</c> (a pooled
|
|
/// voice TELEPORTS between events — at the stock dopplerLevel of 1 a 20 m jump pitch-bends the clip,
|
|
/// a bug that cannot exist when the source is created at the position and never moves).
|
|
/// </para>
|
|
/// <para>
|
|
/// Lifetime: the root is <c>DontDestroyOnLoad</c> because <c>WorldLauncher</c> does
|
|
/// <c>LoadScene(..., Single)</c> while the client world is alive — a scene-parented pool would be
|
|
/// destroyed mid-session and every SFX would go silently dead. Statics survive fast-enter-playmode
|
|
/// domain reloads, so <see cref="ResetStatics"/> nulls them on play-enter (the house rule; without
|
|
/// it the second play session holds an array of destroyed objects and the first cue throws
|
|
/// <c>MissingReferenceException</c>). Both the root and each voice are additionally rebuilt on
|
|
/// fake-null, so anything that does destroy them heals instead of going quiet.
|
|
/// </para>
|
|
/// Main-thread only (every caller is a <c>PresentationSystemGroup</c> SystemBase) — no locking.
|
|
/// Asset-free: built from <c>new GameObject</c> + <c>AddComponent</c>, never a prefab or Resources.Load.
|
|
/// </summary>
|
|
public static class OneShotAudioPool
|
|
{
|
|
/// <summary>
|
|
/// Voice count. Observed peak is ~33 starts/s; the live combat cues top out at 0.45 s (the barrel boom),
|
|
/// which is ~15 concurrent voices worst case, so 32 leaves headroom and stealing never bites in practice.
|
|
/// The longest clip that can reach this pool at all is AmbientLifeSystem's 0.9 s thunder, and that sits
|
|
/// behind a currently-unreachable biome branch. Do not drop below 24: PlayClipAtPoint is effectively
|
|
/// unbounded in voice count, and bounded stealing is the one intentional behaviour change here.
|
|
/// </summary>
|
|
/// </summary>
|
|
const int RingSize = 32;
|
|
|
|
static GameObject s_root;
|
|
static AudioSource[] s_voices;
|
|
static float[] s_freeAt; // unscaled time each voice is expected to finish; 0 = idle
|
|
|
|
/// <summary>
|
|
/// Play-enter reset (the static-presentation-bridge rule). Statics survive a fast-enter-playmode
|
|
/// domain reload but the UnityEngine.Objects they point at do NOT survive exiting play mode — so
|
|
/// the array must be dropped, not reused, or session two rents destroyed voices.
|
|
/// </summary>
|
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
|
static void ResetStatics()
|
|
{
|
|
s_root = null;
|
|
s_voices = null;
|
|
s_freeAt = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fire a positional one-shot. <paramref name="volume"/> is the FINAL level — the caller
|
|
/// (<see cref="FeedbackFx.PlayClip"/>) has already applied the <c>GameVolume.Sfx</c> bus trim,
|
|
/// exactly as the PlayClipAtPoint call did. Baked in at play time and never re-applied to a live
|
|
/// voice, so moving the SFX slider cannot retroactively re-level a cue already in flight.
|
|
/// </summary>
|
|
public static void Play(AudioClip clip, Vector3 pos, float volume)
|
|
{
|
|
if (clip == null || !Application.isPlaying) return;
|
|
|
|
var voice = Rent(clip.length);
|
|
if (voice == null) return;
|
|
|
|
// Order matters: position BEFORE Play so the voice is spatialised at the event, not at
|
|
// wherever the previous rent left it (a pooled voice parked at the origin loses 3D panning).
|
|
voice.transform.position = pos;
|
|
voice.clip = clip;
|
|
voice.pitch = 1f; // defensive: a future per-cue pitch jitter must never leak across a reuse
|
|
voice.volume = volume;
|
|
voice.Play();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stop every voice. Called on client-world teardown so in-flight combat SFX do not bleed into
|
|
/// the main menu (the pool outlives the world by design — it is DontDestroyOnLoad).
|
|
/// </summary>
|
|
public static void SilenceAll()
|
|
{
|
|
if (s_voices == null) return;
|
|
for (int i = 0; i < s_voices.Length; i++)
|
|
{
|
|
if (s_voices[i] != null) s_voices[i].Stop();
|
|
s_freeAt[i] = 0f;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// First idle voice, else steal the one nearest to finishing (smallest deadline). Deadlines come
|
|
/// from the actual <c>clip.length</c> — never a fixed constant: the project's SFX span 0.05 s
|
|
/// (strike beep) to 0.45 s (barrel boom), and a fixed recycle window would audibly truncate the
|
|
/// long ones. <c>Time.unscaledTime</c> because this project never touches <c>Time.timeScale</c>
|
|
/// (hit-stop is a camera punch by house rule).
|
|
/// </summary>
|
|
static AudioSource Rent(float clipLength)
|
|
{
|
|
Build();
|
|
if (s_voices == null) return null;
|
|
|
|
float now = Time.unscaledTime;
|
|
int steal = -1;
|
|
float oldest = float.MaxValue;
|
|
|
|
for (int i = 0; i < s_voices.Length; i++)
|
|
{
|
|
if (s_voices[i] == null) continue; // destroyed out from under us; Build() already retried it
|
|
if (s_freeAt[i] <= now)
|
|
{
|
|
s_freeAt[i] = now + Mathf.Max(0.01f, clipLength);
|
|
return s_voices[i];
|
|
}
|
|
if (s_freeAt[i] < oldest) { oldest = s_freeAt[i]; steal = i; }
|
|
}
|
|
|
|
if (steal < 0) return null;
|
|
s_freeAt[steal] = now + Mathf.Max(0.01f, clipLength);
|
|
return s_voices[steal];
|
|
}
|
|
|
|
/// <summary>Idempotent: builds the root and any missing voice, and heals fake-null entries.</summary>
|
|
static void Build()
|
|
{
|
|
if (s_root == null)
|
|
{
|
|
s_root = new GameObject("~OneShotAudioPool") { hideFlags = HideFlags.HideAndDontSave };
|
|
Object.DontDestroyOnLoad(s_root);
|
|
}
|
|
|
|
if (s_voices == null || s_voices.Length != RingSize)
|
|
{
|
|
s_voices = new AudioSource[RingSize];
|
|
s_freeAt = new float[RingSize];
|
|
}
|
|
|
|
for (int i = 0; i < RingSize; i++)
|
|
if (s_voices[i] == null) { s_voices[i] = MakeVoice(i); s_freeAt[i] = 0f; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// One voice, configured for PlayClipAtPoint parity. Every stock default is re-stated explicitly
|
|
/// because a pooled source is long-lived and would otherwise drift with any future edit.
|
|
/// </summary>
|
|
static AudioSource MakeVoice(int index)
|
|
{
|
|
var go = new GameObject("Voice" + index);
|
|
go.transform.SetParent(s_root.transform, false);
|
|
var src = go.AddComponent<AudioSource>();
|
|
|
|
src.playOnAwake = false; // divergence #1: a long-lived source must never self-start
|
|
src.dopplerLevel = 0f; // divergence #2: pooled voices teleport; Doppler would pitch-bend them
|
|
src.spatialBlend = 1f; // the one thing PlayClipAtPoint sets explicitly (default is 2D)
|
|
|
|
src.loop = false;
|
|
src.mute = false;
|
|
src.rolloffMode = AudioRolloffMode.Logarithmic;
|
|
src.minDistance = 1f;
|
|
src.maxDistance = 500f;
|
|
src.spread = 0f;
|
|
src.priority = 128;
|
|
src.panStereo = 0f;
|
|
src.reverbZoneMix = 1f;
|
|
src.bypassEffects = false;
|
|
src.bypassListenerEffects = false;
|
|
src.bypassReverbZones = false;
|
|
src.pitch = 1f;
|
|
src.volume = 1f;
|
|
src.outputAudioMixerGroup = null; // no AudioMixer in this project: straight to the listener
|
|
return src;
|
|
}
|
|
}
|
|
}
|