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
@@ -85,6 +85,10 @@ namespace ProjectM.Client
if (_kindMats != null)
for (int i = 0; i < _kindMats.Length; i++)
if (_kindMats[i] != null) Object.Destroy(_kindMats[i]);
// Procedural clips are not owned by _fxRoot — see FeedbackFx.DestroyClip.
FeedbackFx.DestroyClip(ref _strikeBeepClip);
if (_kindGrowls != null)
for (int i = 0; i < _kindGrowls.Length; i++) FeedbackFx.DestroyClip(ref _kindGrowls[i]);
foreach (var kv in _dangerZones)
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
}
@@ -245,28 +249,52 @@ namespace ProjectM.Client
}
// Filled forward wedge (pizza-slice) from the enemy out to `range`, vertex-alpha ramped by `intensity`.
// Track B: the four arrays used to be allocated on EVERY call — once per winding enemy per frame
// (~840 B a time). seg is a compile-time constant, so they are hoisted to scratch and filled in place;
// UVs/triangles are argument-independent and upload to a given mesh only on its first fill.
static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
{
const int seg = 14;
var verts = new Vector3[seg + 2];
var cols = new Color[seg + 2];
var uvs = new Vector2[seg + 2];
var tris = new int[seg * 3];
const int seg = WedgeSeg;
if (!s_wedgeStaticsBuilt)
{
s_wedgeUvs[0] = new Vector2(0.5f, 0.5f);
for (int i = 0; i <= seg; i++) s_wedgeUvs[i + 1] = new Vector2(0.5f, 0.5f);
for (int i = 0; i < seg; i++) { s_wedgeTris[i * 3] = 0; s_wedgeTris[i * 3 + 1] = i + 1; s_wedgeTris[i * 3 + 2] = i + 2; }
s_wedgeStaticsBuilt = true;
}
float aCenter = 0.18f + 0.62f * intensity;
verts[0] = Vector3.zero; cols[0] = new Color(1f, 1f, 1f, aCenter); uvs[0] = new Vector2(0.5f, 0.5f);
s_wedgeVerts[0] = Vector3.zero;
s_wedgeCols[0] = new Color(1f, 1f, 1f, aCenter);
for (int i = 0; i <= seg; i++)
{
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
uvs[i + 1] = new Vector2(0.5f, 0.5f);
s_wedgeVerts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
s_wedgeCols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
}
if (mesh.vertexCount != s_wedgeVerts.Length)
{
mesh.Clear();
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
mesh.uv = s_wedgeUvs; mesh.triangles = s_wedgeTris;
}
else
{
mesh.vertices = s_wedgeVerts; mesh.colors = s_wedgeCols;
}
for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; }
mesh.Clear();
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris;
mesh.RecalculateBounds();
}
// Wedge scratch (see BuildDangerMesh). Static is safe: presentation systems are main-thread only, and
// the contents are deterministic geometry, so surviving a domain reload leaves them valid.
const int WedgeSeg = 14;
static readonly Vector3[] s_wedgeVerts = new Vector3[WedgeSeg + 2];
static readonly Color[] s_wedgeCols = new Color[WedgeSeg + 2];
static readonly Vector2[] s_wedgeUvs = new Vector2[WedgeSeg + 2];
static readonly int[] s_wedgeTris = new int[WedgeSeg * 3];
static bool s_wedgeStaticsBuilt;
// MC-3: a thin forward LANE (filled quad in local +Z) for a Spitter's ranged aim telegraph, vertex-alpha
// ramped by `intensity` (brightening toward the shot). Built into the same pooled danger mesh; the GO is
// already rotated to the enemy facing, so +Z is "toward the locked target".