Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation

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>
This commit is contained in:
2026-08-13 23:02:50 -07:00
parent bdeee3c51a
commit e0c59ad663
15 changed files with 603 additions and 109 deletions
@@ -0,0 +1,186 @@
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;
}
}
}