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
@@ -90,6 +90,12 @@ namespace ProjectM.Client
float _downedSince = -1f;
float _prevHp, _flash;
bool _haveHp;
// Track B: last value pushed to each Label. A UITK text assignment is cheap, but BUILDING the string
// (int.ToString / concat) allocates every frame regardless — these gate the build, not just the write.
// int.MinValue rather than -1 so a legitimately negative or zero first value still renders.
int _shownAether = int.MinValue, _shownOre = int.MinValue, _shownBio = int.MinValue;
int _shownThreat = int.MinValue, _shownHp = int.MinValue, _shownMaxHp = int.MinValue;
int _shownRespawnSecs = int.MinValue;
// personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList, _equipList;
bool _invOpen;
@@ -161,9 +167,10 @@ namespace ProjectM.Client
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
}
}
_aetherNum.text = aether.ToString();
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
// Track B: three int.ToString()s every frame, for counts that change a few times a minute.
if (aether != _shownAether) { _shownAether = aether; _aetherNum.text = aether.ToString(); }
if (ore != _shownOre) { _shownOre = ore; _oreNum.text = ore.ToString(); }
if (bio != _shownBio) { _shownBio = bio; _bioNum.text = bio.ToString(); }
@@ -177,7 +184,7 @@ namespace ProjectM.Client
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
_threatNum.text = huskCount.ToString();
if (huskCount != _shownThreat) { _shownThreat = huskCount; _threatNum.text = huskCount.ToString(); } // Track B: changes on a spawn/kill, not per frame
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, PanelDark);
@@ -225,7 +232,13 @@ namespace ProjectM.Client
_healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
// Track B: `int + " / " + int` allocated a fresh string EVERY frame even at full health.
int hpI = Mathf.CeilToInt(Mathf.Max(0f, hp)), maxI = Mathf.CeilToInt(maxHp);
if (hpI != _shownHp || maxI != _shownMaxHp)
{
_shownHp = hpI; _shownMaxHp = maxI;
_healthText.text = hpI + " / " + maxI;
}
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
if (dead)
@@ -237,9 +250,16 @@ namespace ProjectM.Client
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
// Track B: the countdown only changes ~1x/second, but this ran every frame — the
// concat allocated a fresh string ~80x/second while dead.
int secsLeft = left > 0.05f ? Mathf.CeilToInt(left) : -1;
if (secsLeft != _shownRespawnSecs)
{
_shownRespawnSecs = secsLeft;
_downedText.text = secsLeft > 0 ? "RESPAWNING IN " + secsLeft : "RESPAWNING...";
}
}
else _downedSince = -1f;
else { _downedSince = -1f; _shownRespawnSecs = int.MinValue; } // re-render on the next death
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else