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
@@ -41,6 +41,14 @@ namespace ProjectM.Client
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
readonly int[] _prevRemaining = new int[SlotCount];
readonly float[] _flashUntil = new float[SlotCount];
// Track B: last countdown value rendered per slot, in tenths of a second (-1 = blank). Gates the
// float formatting in UpdateSlotCooldown, which otherwise ran 5x per frame.
// Seeded to int.MinValue, not the default 0: 0 is a REAL value (a cooldown under ~1/20 s rounds to
// 0 tenths), and a default-0 array would silently skip that first render.
readonly int[] _shownDeci = FilledWith(SlotCount, int.MinValue);
readonly bool[] _shownWhole = new bool[SlotCount]; // which countdown FORMAT is currently rendered per slot
static int[] FilledWith(int n, int v) { var a = new int[n]; for (int i = 0; i < n; i++) a[i] = v; return a; }
static readonly Color EmptyCol = new(1f, 1f, 1f, 0.22f);
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
@@ -125,9 +133,27 @@ namespace ProjectM.Client
{
float frac = math.saturate(remaining / (float)total);
_cdOverlay[slot].style.height = Length.Percent(frac * 100f);
_countdown[slot].text = remaining > 0
? (remaining < 597 ? (remaining / 60f).ToString("0.0") : Mathf.CeilToInt(remaining / 60f).ToString())
: "";
// Track B: this ran for all 5 slots EVERY frame and formatted a float each time. The readout only
// has a tenth-of-a-second resolution (6 ticks), so quantise first and only format on a real change.
//
// The rendered text is derived from `deci`, NEVER re-derived from `remaining`. Deriving it twice is a
// trap: Mathf.RoundToInt rounds half-to-EVEN while ToString("0.0") rounds half-AWAY-from-zero, so the
// gate and the string disagreed at midpoints and the label latched a stale, too-high reading and
// skipped a tenth on every cooldown. CeilToInt also means the readout never reads LOWER than the true
// remainder. `whole` is cached alongside deci because the two branches can produce the same deci
// (596 ticks -> 100 -> "9.9" vs 597 ticks -> 100 -> "10") and the format must still switch.
bool whole = remaining >= 597;
int deci = remaining > 0
? (whole ? Mathf.CeilToInt(remaining / 60f) * 10 : Mathf.CeilToInt(remaining / 6f))
: -1;
if (deci != _shownDeci[slot] || whole != _shownWhole[slot])
{
_shownDeci[slot] = deci;
_shownWhole[slot] = whole;
_countdown[slot].text = deci < 0 ? "" : (whole ? (deci / 10).ToString() : (deci * 0.1f).ToString("0.0"));
}
bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);