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 byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
readonly int[] _prevRemaining = new int[SlotCount]; readonly int[] _prevRemaining = new int[SlotCount];
readonly float[] _flashUntil = new float[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 EmptyCol = new(1f, 1f, 1f, 0.22f);
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f); 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); float frac = math.saturate(remaining / (float)total);
_cdOverlay[slot].style.height = Length.Percent(frac * 100f); _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; bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol); _glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
@@ -58,6 +58,12 @@ namespace ProjectM.Client
protected override void OnDestroy() protected override void OnDestroy()
{ {
if (_root != null) Object.Destroy(_root); if (_root != null) Object.Destroy(_root);
// The AudioSources die with _root, but the clips they played do NOT — see FeedbackFx.DestroyClip.
// _ambientClip alone is ~1.4 MB of native audio per client-world teardown.
FeedbackFx.DestroyClip(ref _ambientClip);
FeedbackFx.DestroyClip(ref _groanClip);
FeedbackFx.DestroyClip(ref _stingBeep);
FeedbackFx.DestroyClip(ref _stingRoar);
} }
protected override void OnUpdate() protected override void OnUpdate()
@@ -68,6 +68,7 @@ namespace ProjectM.Client
if (_cloudMesh != null) Object.Destroy(_cloudMesh); if (_cloudMesh != null) Object.Destroy(_cloudMesh);
if (_critterMat != null) Object.Destroy(_critterMat); if (_critterMat != null) Object.Destroy(_critterMat);
if (_cloudMat != null) Object.Destroy(_cloudMat); if (_cloudMat != null) Object.Destroy(_cloudMat);
FeedbackFx.DestroyClip(ref _thunderClip); // not owned by _root — see FeedbackFx.DestroyClip
} }
protected override void OnUpdate() protected override void OnUpdate()
@@ -46,7 +46,7 @@ namespace ProjectM.Client
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig). // Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
readonly List<TimedVfx> _activeVfx = new(); readonly List<TimedVfx> _activeVfx = new();
readonly Dictionary<Entity, GameObject> _projTrails = new(); readonly Dictionary<Entity, VfxInstance> _projTrails = new();
readonly HashSet<Entity> _projSeen = new(); readonly HashSet<Entity> _projSeen = new();
readonly List<Entity> _projStale = new(); readonly List<Entity> _projStale = new();
@@ -65,6 +65,16 @@ namespace ProjectM.Client
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes) int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height) Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
// Track B: BuildSlashInto used to allocate four arrays (~1.7 KB) on EVERY call, and it runs twice a
// frame for the local arc + smear plus once per live remote swing. The segment count is a compile-time
// constant, so the sizes never vary — fill these in place instead. UVs/triangles are argument-independent
// (built once, guarded by _arcStaticsBuilt) and are uploaded to each mesh only on its first fill.
const int ArcSeg = 16;
readonly Vector3[] _arcVerts = new Vector3[(ArcSeg + 1) * 2];
readonly Color[] _arcCols = new Color[(ArcSeg + 1) * 2];
readonly Vector2[] _arcUvs = new Vector2[(ArcSeg + 1) * 2];
readonly int[] _arcTris = new int[ArcSeg * 6];
bool _arcStaticsBuilt;
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none) uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
int _pendingConnectStep; int _pendingConnectStep;
uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none) uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none)
@@ -75,7 +85,6 @@ namespace ProjectM.Client
float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on
int _stressBeat; int _stressBeat;
#endif #endif
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote // Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr). // player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
@@ -116,7 +125,28 @@ namespace ProjectM.Client
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal) EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal)
struct TimedVfx { public GameObject Go; public double Kill; } // Track B: a pooled VFX instance. Component arrays are cached PER INSTANCE — component references are
// instance-scoped, so arrays captured off the prefab ASSET would drive the asset, not the clone.
class VfxInstance
{
public GameObject Go;
public Transform Tr;
public ParticleSystem[] Systems;
public TrailRenderer[] Trails;
public GameObject Prefab; // the stack this instance returns to; never re-read from VFXConfig
public bool Rented; // at-most-once guard: a double Return would alias one instance to two callers
}
struct TimedVfx { public VfxInstance Inst; public double Kill; }
// Per-prefab pool of inactive instances, plus the two values that ARE legitimately per-prefab. Instance
// fields, never static: the Kill deadlines come from the per-world SystemAPI.Time.ElapsedTime, which
// restarts at 0 for each session world.
readonly Dictionary<GameObject, Stack<VfxInstance>> _vfxPool = new();
readonly Dictionary<GameObject, double> _vfxLifetime = new();
readonly Dictionary<GameObject, Vector3> _vfxPrefabScale = new();
Transform _vfxFillRoot; // INACTIVE parent: instances fill here so Awake/Start never run
const int VfxPerPrefabRetain = 10; // retained inactive instances per prefab; destroy beyond it
protected override void OnCreate() protected override void OnCreate()
{ {
@@ -161,6 +191,10 @@ namespace ProjectM.Client
protected override void OnDestroy() protected override void OnDestroy()
{ {
// The SFX ring is DontDestroyOnLoad (it must outlive LoadScene(Single)), so a world teardown
// would otherwise bleed in-flight combat cues straight into the main menu.
OneShotAudioPool.SilenceAll();
if (_fxRoot != null) if (_fxRoot != null)
Object.Destroy(_fxRoot.gameObject); Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh); if (_slashMesh != null) Object.Destroy(_slashMesh);
@@ -168,6 +202,18 @@ namespace ProjectM.Client
if (_smearMesh != null) Object.Destroy(_smearMesh); if (_smearMesh != null) Object.Destroy(_smearMesh);
if (_smearMat != null) Object.Destroy(_smearMat); if (_smearMat != null) Object.Destroy(_smearMat);
// See FeedbackFx.DestroyClip: an AudioClip.Create'd clip is not owned by _fxRoot, so all ten leaked
// on every client-world teardown. The four sibling clip-owning systems do the same in their OnDestroy.
FeedbackFx.DestroyClip(ref _hitClip);
FeedbackFx.DestroyClip(ref _deathClip);
FeedbackFx.DestroyClip(ref _fireClip);
FeedbackFx.DestroyClip(ref _telegraphClip);
FeedbackFx.DestroyClip(ref _dashClip);
FeedbackFx.DestroyClip(ref _swingClip);
FeedbackFx.DestroyClip(ref _meleeConnectClip);
for (int i = 0; i < _footstepClips.Length; i++) FeedbackFx.DestroyClip(ref _footstepClips[i]);
foreach (var kv in _remoteSlashes) foreach (var kv in _remoteSlashes)
{ {
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh); if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
@@ -178,6 +224,8 @@ namespace ProjectM.Client
} }
protected override void OnUpdate() protected override void OnUpdate()
{ {
float dt = SystemAPI.Time.DeltaTime; float dt = SystemAPI.Time.DeltaTime;
@@ -246,7 +294,6 @@ namespace ProjectM.Client
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs); if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
@@ -271,7 +318,6 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume); PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor);
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
@@ -338,7 +384,6 @@ namespace ProjectM.Client
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
TryHold(); // C4: kill crunch (throttled)
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
@@ -695,24 +740,124 @@ namespace ProjectM.Client
void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot) void SpawnVfx(GameObject prefab, Vector3 pos, Quaternion rot)
{ {
if (prefab == null || _fxRoot == null) return; if (prefab == null || _fxRoot == null) return;
if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC if (_activeVfx.Count >= MaxActiveVfx) return; // in-flight cap: unchanged, this bounds live particles too
var go = Object.Instantiate(prefab, pos, rot, _fxRoot); var inst = RentVfx(prefab, pos, rot);
go.transform.position = pos; if (inst == null) return;
StripCosmetic(go); _activeVfx.Add(new TimedVfx { Inst = inst, Kill = SystemAPI.Time.ElapsedTime + VfxLifetimeFor(prefab) });
var systems = go.GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) });
// Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour). // Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour).
DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f); DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f);
} }
/// <summary>
/// Track B: take an instance from the per-prefab pool (or fill a new one) instead of Instantiating.
/// Order matters — transform BEFORE the particle restart, because a world-space ParticleSystem would
/// otherwise re-show the previous burst's particles at their OLD positions for a frame.
/// </summary>
VfxInstance RentVfx(GameObject prefab, Vector3 pos, Quaternion rot)
{
VfxInstance inst = null;
if (_vfxPool.TryGetValue(prefab, out var stack))
{
while (stack.Count > 0) // null-skip: a pooled entry destroyed out from under us is discarded
{
var cand = stack.Pop();
if (cand != null && cand.Go != null) { inst = cand; break; }
}
}
inst ??= FillVfx(prefab);
if (inst == null) return null;
var tr = inst.Tr;
tr.SetParent(_fxRoot, false);
tr.SetPositionAndRotation(pos, rot);
tr.localScale = _vfxPrefabScale.TryGetValue(prefab, out var s) ? s : Vector3.one; // never inherit the last rent's scale
inst.Go.SetActive(true);
for (int i = 0; i < inst.Trails.Length; i++)
if (inst.Trails[i] != null) inst.Trails[i].Clear(); // else a streak draws from the previous despawn point
for (int i = 0; i < inst.Systems.Length; i++)
{
var ps = inst.Systems[i];
if (ps == null) continue;
ps.Clear(true);
ps.Play(true);
}
inst.Rented = true;
return inst;
}
/// <summary>
/// Build one pooled instance. Instantiated under an INACTIVE root so Awake/Start never run, which is
/// also what makes the DestroyImmediate in StripCosmetic safe: a deferred Destroy would hand out an
/// instance still carrying a live Rigidbody + Collider for one frame if it were rented the same frame.
/// Component arrays are cached PER INSTANCE — component references are instance-scoped, so caching them
/// off the prefab asset would drive the asset instead.
/// </summary>
VfxInstance FillVfx(GameObject prefab)
{
if (_vfxFillRoot == null)
{
var fillGo = new GameObject("~VfxPool");
fillGo.transform.SetParent(_fxRoot, false);
fillGo.SetActive(false);
_vfxFillRoot = fillGo.transform;
}
var go = Object.Instantiate(prefab, _vfxFillRoot);
StripCosmetic(go);
var inst = new VfxInstance
{
Go = go,
Tr = go.transform,
Systems = go.GetComponentsInChildren<ParticleSystem>(true),
Trails = go.GetComponentsInChildren<TrailRenderer>(true),
Prefab = prefab,
};
for (int i = 0; i < inst.Systems.Length; i++)
{
// A prefab whose stopAction is Destroy/Disable would silently destroy the POOLED instance when
// the effect finishes, draining the pool and pushing dead objects onto the stack.
var main = inst.Systems[i].main;
main.stopAction = ParticleSystemStopAction.None;
}
if (!_vfxPrefabScale.ContainsKey(prefab)) _vfxPrefabScale[prefab] = prefab.transform.localScale;
if (!_vfxLifetime.ContainsKey(prefab)) _vfxLifetime[prefab] = VfxLifetime(inst.Systems);
return inst;
}
/// <summary>
/// Park an instance back on its OWN prefab's stack (keyed off the record, never a re-read of VFXConfig —
/// swapping a config field mid-play would otherwise file it under the wrong effect). The Rented flag is
/// the at-most-once guard: a double return would hand one instance to two callers.
/// </summary>
void ReturnVfx(VfxInstance inst)
{
if (inst == null || !inst.Rented) return;
inst.Rented = false;
if (inst.Go == null) return; // destroyed out from under us: drop it rather than pool a dead object
for (int i = 0; i < inst.Systems.Length; i++)
if (inst.Systems[i] != null) inst.Systems[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
for (int i = 0; i < inst.Trails.Length; i++)
if (inst.Trails[i] != null) inst.Trails[i].Clear();
inst.Go.SetActive(false);
if (_vfxFillRoot != null) inst.Tr.SetParent(_vfxFillRoot, false);
if (!_vfxPool.TryGetValue(inst.Prefab, out var stack)) { stack = new Stack<VfxInstance>(); _vfxPool[inst.Prefab] = stack; }
if (stack.Count >= VfxPerPrefabRetain) { Object.Destroy(inst.Go); return; } // bound the retained set after a burst
stack.Push(inst);
}
double VfxLifetimeFor(GameObject prefab) => _vfxLifetime.TryGetValue(prefab, out var d) ? d : 1.0;
void PruneVfx() void PruneVfx()
{ {
double now = SystemAPI.Time.ElapsedTime; double now = SystemAPI.Time.ElapsedTime;
for (int i = _activeVfx.Count - 1; i >= 0; i--) for (int i = _activeVfx.Count - 1; i >= 0; i--)
{ {
if (now < _activeVfx[i].Kill) continue; if (now < _activeVfx[i].Kill) continue;
if (_activeVfx[i].Go != null) Object.Destroy(_activeVfx[i].Go); ReturnVfx(_activeVfx[i].Inst); // pooled, not destroyed
_activeVfx.RemoveAt(i); _activeVfx.RemoveAt(i);
} }
} }
@@ -722,10 +867,11 @@ namespace ProjectM.Client
{ {
if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null) if (cfg == null || cfg.ProjectileTrail == null || _fxRoot == null)
{ {
// Config cleared mid-run: drop any orphaned trails so they don't linger. // Config cleared mid-run: RETURN the orphans rather than destroying them, or the pool's
// bookkeeping under-counts and the instances leak out of it.
if (_projTrails.Count > 0) if (_projTrails.Count > 0)
{ {
foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value); foreach (var kv in _projTrails) ReturnVfx(kv.Value);
_projTrails.Clear(); _projTrails.Clear();
} }
return; return;
@@ -739,15 +885,12 @@ namespace ProjectM.Client
Vector3 wp = (Vector3)xf.ValueRO.Position; Vector3 wp = (Vector3)xf.ValueRO.Position;
if (_projTrails.TryGetValue(entity, out var trail)) if (_projTrails.TryGetValue(entity, out var trail))
{ {
if (trail != null) trail.transform.position = wp; if (trail != null && trail.Tr != null) trail.Tr.position = wp;
} }
else else
{ {
var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot); var inst = RentVfx(cfg.ProjectileTrail, wp, Quaternion.identity);
StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only if (inst != null) _projTrails[entity] = inst;
var systems = go.GetComponentsInChildren<ParticleSystem>();
for (int i = 0; i < systems.Length; i++) systems[i].Play();
_projTrails[entity] = go;
} }
} }
@@ -757,7 +900,7 @@ namespace ProjectM.Client
if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key); if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key);
for (int i = 0; i < _projStale.Count; i++) for (int i = 0; i < _projStale.Count; i++)
{ {
if (_projTrails[_projStale[i]] != null) Object.Destroy(_projTrails[_projStale[i]]); ReturnVfx(_projTrails[_projStale[i]]);
_projTrails.Remove(_projStale[i]); _projTrails.Remove(_projStale[i]);
} }
} }
@@ -767,27 +910,31 @@ namespace ProjectM.Client
// effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks. // effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks.
static void StripCosmetic(GameObject go) static void StripCosmetic(GameObject go)
{ {
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb); // DestroyImmediate, not Destroy: a deferred Destroy is only applied at end-of-frame, so an instance
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col); // filled and rented in the SAME frame would still carry a live Rigidbody + Collider — exactly the
// self-propelling / secondary-spawn behaviour this strip exists to prevent. Legal here because the
// target is a freshly-instantiated runtime instance under an inactive root, never a prefab asset.
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.DestroyImmediate(rb);
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.DestroyImmediate(col);
// Cosmetic VFX must be particles ONLY. This used to disable by type-name substring ("Projectile" /
// "Move"), which let any other authored helper (auto-destroy timers, effect settings, light flicker)
// survive — harmless when the object was destroyed after one use, but a pooled instance re-runs
// OnEnable on EVERY rent, so a survivor would re-arm each time and could Destroy the pooled object.
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true)) foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true))
{ if (mb != null) mb.enabled = false;
if (mb == null) continue;
string n = mb.GetType().Name;
// Disable (not destroy) BEFORE Start runs so the mover's Start-spawned muzzle never fires.
if (n.IndexOf("Projectile", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
n.IndexOf("Move", System.StringComparison.OrdinalIgnoreCase) >= 0)
mb.enabled = false;
}
} }
// Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early // Real effect duration from the longest ParticleSystem (clamped), so we don't force-kill early or hold a
// or hold a finished GameObject around on a blanket TTL. // finished instance out of the pool on a blanket TTL. Takes the per-instance cache so the old
static double VfxLifetime(GameObject go) // GetComponentsInChildren-per-spawn is gone; the RESULT is per-prefab and memoised in _vfxLifetime.
static double VfxLifetime(ParticleSystem[] systems)
{ {
float longest = 0f; float longest = 0f;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) for (int i = 0; i < systems.Length; i++)
{ {
var main = ps.main; if (systems[i] == null) continue;
var main = systems[i].main;
float d = main.duration + main.startLifetime.constantMax; float d = main.duration + main.startLifetime.constantMax;
if (d > longest) longest = d; if (d > longest) longest = d;
} }
@@ -796,6 +943,8 @@ namespace ProjectM.Client
// ---- Floating damage numbers (pooled, billboarded TextMesh) ---- // ---- Floating damage numbers (pooled, billboarded TextMesh) ----
const int AlphaSteps = 12; // Track B: fade quantisation for the floating numbers (see AnimateNumbers)
class FloatingNumber class FloatingNumber
{ {
public TextMesh Tm; public TextMesh Tm;
@@ -805,6 +954,7 @@ namespace ProjectM.Client
public Vector3 Vel; public Vector3 Vel;
public Color BaseColor; public Color BaseColor;
public bool Active; public bool Active;
public int ShownAlphaStep; // Track B: quantised fade step last written to Tm.color (see AnimateNumbers)
} }
FloatingNumber CreateNumber() FloatingNumber CreateNumber()
@@ -836,6 +986,7 @@ namespace ProjectM.Client
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString(); fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit) fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
fn.Tm.color = fn.BaseColor; fn.Tm.color = fn.BaseColor;
fn.ShownAlphaStep = AlphaSteps; // BaseColor is fully opaque, i.e. the top fade step — keeps AnimateNumbers from re-writing on frame 1
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f); fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
fn.Vel = new Vector3(0f, 2.2f, 0f); fn.Vel = new Vector3(0f, 2.2f, 0f);
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag); fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
@@ -862,11 +1013,19 @@ namespace ProjectM.Client
fn.Tr.position += fn.Vel * dt; fn.Tr.position += fn.Vel * dt;
if (cam != null) fn.Tr.rotation = cam.transform.rotation; if (cam != null) fn.Tr.rotation = cam.transform.rotation;
// Track B: legacy TextMesh bakes colour into VERTEX colours, so every colour write forces a
// text-mesh rebuild — up to 32 rebuilds a frame with a full pool. Quantising the fade to 12
// steps turns ~50 rebuilds per number into 12, with no visible difference over its <1 s life.
int step = (int)((1f - fn.Age / fn.Life) * AlphaSteps);
if (step != fn.ShownAlphaStep)
{
fn.ShownAlphaStep = step;
var c = fn.BaseColor; var c = fn.BaseColor;
c.a = 1f - (fn.Age / fn.Life); c.a = step / (float)AlphaSteps;
fn.Tm.color = c; fn.Tm.color = c;
} }
} }
}
// ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ---- // ---- Procedural SFX + pooled particle bursts (fallback when no authored prefab) ----
@@ -914,7 +1073,7 @@ namespace ProjectM.Client
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign, void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign,
float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f) float innerFrac = 0.45f, float y = 0f, float angularWindowRad = 0f)
{ {
const int seg = 16; const int seg = ArcSeg;
float r1 = Mathf.Max(0.4f, range); float r1 = Mathf.Max(0.4f, range);
float r0 = r1 * innerFrac; float r0 = r1 * innerFrac;
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
@@ -923,47 +1082,53 @@ namespace ProjectM.Client
float aBase = aStart; float aBase = aStart;
if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge if (angularWindowRad > 0f) // smear mode: only the trailing band behind the leading edge
aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad); aBase = sweepSign >= 0 ? Mathf.Max(aStart, aEnd - angularWindowRad) : Mathf.Min(aStart, aEnd + angularWindowRad);
var verts = new Vector3[(seg + 1) * 2];
var cols = new Color[(seg + 1) * 2]; // UVs and triangles do not depend on ANY argument, so they are built once for the whole system
var uvs = new Vector2[(seg + 1) * 2]; // instead of being regenerated (and re-uploaded) on every call.
var tris = new int[seg * 6]; if (!_arcStaticsBuilt)
{
for (int i = 0; i <= seg; i++) for (int i = 0; i <= seg; i++)
{ {
float a = Mathf.Lerp(aBase, aEnd, i / (float)seg); _arcUvs[i * 2] = new Vector2(0.5f, 0.5f);
float sx = Mathf.Sin(a), cz = Mathf.Cos(a); _arcUvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
verts[i * 2] = new Vector3(sx * r0, y, cz * r0);
verts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
cols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
uvs[i * 2] = new Vector2(0.5f, 0.5f);
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
} }
for (int i = 0; i < seg; i++) for (int i = 0; i < seg; i++)
{ {
int b = i * 2; int b = i * 2;
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2; _arcTris[i * 6 + 0] = b; _arcTris[i * 6 + 1] = b + 1; _arcTris[i * 6 + 2] = b + 2;
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2; _arcTris[i * 6 + 3] = b + 1; _arcTris[i * 6 + 4] = b + 3; _arcTris[i * 6 + 5] = b + 2;
} }
mesh.Clear(); _arcStaticsBuilt = true;
mesh.vertices = verts;
mesh.colors = cols;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateBounds();
} }
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS for (int i = 0; i <= seg; i++)
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
void TryHold(int frames = 0) // 07-20 G5 (review C17): ONE hold path, per-verb frames (0 = the light default)
{ {
if (!FeelConfig.HitStopFreezeEnabled) return; float a = Mathf.Lerp(aBase, aEnd, i / (float)seg);
double now = SystemAPI.Time.ElapsedTime; float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
if (now - _lastHoldTime < 0.22) return; _arcVerts[i * 2] = new Vector3(sx * r0, y, cz * r0);
_lastHoldTime = now; _arcVerts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1);
PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames); float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
_arcCols[i * 2] = new Color(1f, 1f, 1f, 0.35f * (0.2f + 0.8f * lead)); // 07-19 retone: a wake, not a laser // inner, brightest at the leading edge
_arcCols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
}
// First fill for THIS mesh — _slashMesh, _smearMesh and every remote arc each hit it once.
// Vertices must be uploaded before triangles or index validation fails on an empty mesh.
// Afterwards only the two channels that actually change are re-uploaded.
if (mesh.vertexCount != _arcVerts.Length)
{
mesh.Clear();
mesh.vertices = _arcVerts;
mesh.colors = _arcCols;
mesh.uv = _arcUvs;
mesh.triangles = _arcTris;
}
else
{
mesh.vertices = _arcVerts;
mesh.colors = _arcCols;
}
mesh.RecalculateBounds();
} }
// 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes // 07-20 G2.1/G5 (review C14): the melee CONNECT package, fired when the blade actually LANDS. Recomputes
@@ -1002,7 +1167,6 @@ namespace ProjectM.Client
if (finisher) if (finisher)
{ {
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs); PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
TryHold(FeelConfig.FinisherHoldFrames); // G5: the heavy payoff beat, at contact
} }
} }
@@ -1042,8 +1206,10 @@ namespace ProjectM.Client
if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing if (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing
} }
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f) // the range telegraph (MC-4 clarity) AND SWEEPS across + ramps per combo step so the swing reads as a
// directional, escalating cleave rather than a static flash.
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
{ {
if (_slashMr == null || _slashMat == null) return; if (_slashMr == null || _slashMat == null) return;
bool finisher = step >= comboLen; bool finisher = step >= comboLen;
@@ -85,6 +85,10 @@ namespace ProjectM.Client
if (_kindMats != null) if (_kindMats != null)
for (int i = 0; i < _kindMats.Length; i++) for (int i = 0; i < _kindMats.Length; i++)
if (_kindMats[i] != null) Object.Destroy(_kindMats[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) 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); } 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`. // 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) static void BuildDangerMesh(Mesh mesh, float range, float halfAngle, float intensity)
{ {
const int seg = 14; const int seg = WedgeSeg;
var verts = new Vector3[seg + 2]; if (!s_wedgeStaticsBuilt)
var cols = new Color[seg + 2]; {
var uvs = new Vector2[seg + 2]; s_wedgeUvs[0] = new Vector2(0.5f, 0.5f);
var tris = new int[seg * 3]; 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; 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++) for (int i = 0; i <= seg; i++)
{ {
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg); float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
verts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range); s_wedgeVerts[i + 1] = new Vector3(Mathf.Sin(a) * range, 0f, Mathf.Cos(a) * range);
cols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f); s_wedgeCols[i + 1] = new Color(1f, 1f, 1f, aCenter * 0.22f);
uvs[i + 1] = new Vector2(0.5f, 0.5f);
} }
for (int i = 0; i < seg; i++) { tris[i * 3] = 0; tris[i * 3 + 1] = i + 1; tris[i * 3 + 2] = i + 2; }
if (mesh.vertexCount != s_wedgeVerts.Length)
{
mesh.Clear(); mesh.Clear();
mesh.vertices = verts; mesh.colors = cols; mesh.uv = uvs; mesh.triangles = tris; 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;
}
mesh.RecalculateBounds(); 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 // 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 // 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". // already rotated to the enemy facing, so +Z is "toward the locked target".
@@ -30,6 +30,10 @@ namespace ProjectM.Client
public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg;
public float ShowTimer; public bool Visible; public float ShowTimer; public bool Visible;
public float LastHp; public float MaxHp; public float3 Pos; public float LastHp; public float MaxHp; public float3 Pos;
// Track B: last values actually pushed to uGUI, so a bar that is merely showing pushes nothing.
// CreateHealthBar seeds these to -1 (a struct would otherwise default them to 0, which is a
// legitimate frac and would skip the first render of a fully-drained bar).
public float LastFrac; public float LastAlpha;
} }
const int HealthBarPoolLimit = 24; const int HealthBarPoolLimit = 24;
@@ -151,7 +155,8 @@ namespace ProjectM.Client
var entry = new HealthBarEntry var entry = new HealthBarEntry
{ {
CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false, CanvasGo = go, Fill = fillImg, Bg = bgImg, ShowTimer = 0f, Visible = false,
LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos LastHp = prev.LastHp, MaxHp = prev.MaxHp, Pos = prev.Pos,
LastFrac = -1f, LastAlpha = -1f // force the first visual push (a real frac/alpha is never negative)
}; };
_healthBars[entity] = entry; _healthBars[entity] = entry;
return entry; return entry;
@@ -209,8 +214,17 @@ namespace ProjectM.Client
} }
float alpha = (!alwaysOn && entry.ShowTimer < 0f) float alpha = (!alwaysOn && entry.ShowTimer < 0f)
? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f; ? 1f - math.saturate(-entry.ShowTimer / HealthBarFadeDuration) : 1f;
if (entry.Fill != null) { var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c; entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f); } // Track B: these ran unconditionally for every visible bar every frame. Writing
if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; } // anchorMax triggers OnRectTransformDimensionsChange and an Image.color write dirties the
// canvas — so a bar that is merely SHOWING (not changing) used to keep re-laying-out uGUI.
// Epsilon-gated: a bar only pushes when its fill or fade actually moved.
if (entry.Fill != null && (math.abs(frac - entry.LastFrac) > 0.002f || math.abs(alpha - entry.LastAlpha) > 0.002f))
{
var c = entry.Fill.color; c.a = alpha; entry.Fill.color = c;
entry.Fill.rectTransform.anchorMax = new Vector2(frac, 1f);
if (entry.Bg != null) { var bgc = entry.Bg.color; bgc.a = 0.82f * alpha; entry.Bg.color = bgc; }
entry.LastFrac = frac; entry.LastAlpha = alpha;
}
} }
else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; } else if (entry.Visible) { entry.CanvasGo.SetActive(false); entry.Visible = false; }
@@ -49,7 +49,10 @@ namespace ProjectM.Client
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component. // Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
_seen.Clear(); _seen.Clear();
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); // Track B: created lazily — it only ever records on a newly-seen enemy, but it used to be built
// and played back (a sync point) every single frame.
EntityCommandBuffer ecb = default;
bool hasEcb = false;
foreach (var (health, entity) in foreach (var (health, entity) in
SystemAPI.Query<RefRO<Health>>().WithAny<EnemyTag, PlayerTag>().WithAll<LinkedEntityGroup>().WithEntityAccess()) SystemAPI.Query<RefRO<Health>>().WithAny<EnemyTag, PlayerTag>().WithAll<LinkedEntityGroup>().WithEntityAccess())
{ {
@@ -64,13 +67,17 @@ namespace ProjectM.Client
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue; if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
entry.RenderKids.Add(c); entry.RenderKids.Add(c);
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c)) if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
{
if (!hasEcb) { ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); hasEcb = true; }
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White }); ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
} }
}
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame). // Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
if (entry.RenderKids.Count > 0) _tracked[entity] = entry; if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
} }
ecb.Playback(EntityManager); // Only pay the structural-change sync point on the frames that actually recorded something
ecb.Dispose(); // (i.e. a newly-seen enemy) instead of every frame.
if (hasEcb) { ecb.Playback(EntityManager); ecb.Dispose(); }
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children. // Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
var bc = FeelConfig.BodyFlashColor; var bc = FeelConfig.BodyFlashColor;
@@ -139,7 +139,23 @@ namespace ProjectM.Client
public static void PlayClip(AudioClip clip, Vector3 pos, float vol) public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{ {
if (clip == null) return; if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); // Pooled 3D voices, NOT AudioSource.PlayClipAtPoint: that allocates a GameObject +
// AudioSource per call and schedules a delayed Destroy, ~20-33 times a second in combat.
// GameVolume.Sfx is read HERE (at play time) so the bus trim applies per cue, as before.
OneShotAudioPool.Play(clip, pos, vol * GameVolume.Sfx);
}
/// <summary>
/// Destroy a procedurally-built clip on world teardown. An <c>AudioClip.Create</c>d clip is a standalone
/// UnityEngine.Object — destroying the system's FX-root GameObject does NOT take it with it, so every
/// presentation system that builds clips leaked its native audio buffer on each client-world teardown
/// (unbounded growth across menu -> game -> menu cycles). Nulls the reference so a re-created system
/// rebuilds rather than holding a destroyed clip.
/// </summary>
public static void DestroyClip(ref AudioClip clip)
{
if (clip != null) Object.Destroy(clip);
clip = null;
} }
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ---- // ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
@@ -73,6 +73,7 @@ namespace ProjectM.Client
if (_discMat != null) Object.Destroy(_discMat); if (_discMat != null) Object.Destroy(_discMat);
if (_burstMat != null) Object.Destroy(_burstMat); if (_burstMat != null) Object.Destroy(_burstMat);
if (_discMesh != null) Object.Destroy(_discMesh); if (_discMesh != null) Object.Destroy(_discMesh);
FeedbackFx.DestroyClip(ref _eruptClip); // not owned by _fxRoot — see FeedbackFx.DestroyClip
} }
protected override void OnUpdate() protected override void OnUpdate()
@@ -90,6 +90,12 @@ namespace ProjectM.Client
float _downedSince = -1f; float _downedSince = -1f;
float _prevHp, _flash; float _prevHp, _flash;
bool _haveHp; 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) // personal inventory panel (read-only; toggled with I)
VisualElement _invPanel, _invList, _equipList; VisualElement _invPanel, _invList, _equipList;
bool _invOpen; bool _invOpen;
@@ -161,9 +167,10 @@ namespace ProjectM.Client
else if (en.ItemId == ResourceId.Biomass) bio = en.Count; else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
} }
} }
_aetherNum.text = aether.ToString(); // Track B: three int.ToString()s every frame, for counts that change a few times a minute.
_oreNum.text = ore.ToString(); if (aether != _shownAether) { _shownAether = aether; _aetherNum.text = aether.ToString(); }
_bioNum.text = bio.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); float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity); 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; _threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc; _threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, PanelDark); RetintPanel(_threatPanel, PanelDark);
@@ -225,7 +232,13 @@ namespace ProjectM.Client
_healthFill.style.backgroundColor = shielded _healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f) ? 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); : 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; _shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
if (dead) if (dead)
@@ -237,9 +250,16 @@ namespace ProjectM.Client
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>()) foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; } { delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince); 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; _downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
} }
else else
@@ -57,9 +57,24 @@ namespace ProjectM.Client
protected override void OnDestroy() protected override void OnDestroy()
{ {
// Read the clips off the sources BEFORE destroying _root: the AudioSources die with it, but the four
// AudioClip.Create'd loops are standalone objects that do not (~6.8 MB of native audio, leaked on
// every client-world teardown). See FeedbackFx.DestroyClip.
DestroySourceClip(_bass);
DestroySourceClip(_pad);
DestroySourceClip(_arp);
DestroySourceClip(_pulse);
if (_root != null) Object.Destroy(_root); if (_root != null) Object.Destroy(_root);
} }
static void DestroySourceClip(AudioSource src)
{
if (src == null) return;
var clip = src.clip;
src.clip = null;
FeedbackFx.DestroyClip(ref clip);
}
AudioSource MakeSource(AudioClip clip) AudioSource MakeSource(AudioClip clip)
{ {
var src = _root.AddComponent<AudioSource>(); var src = _root.AddComponent<AudioSource>();
@@ -53,7 +53,9 @@ namespace ProjectM.Client
foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess()) foreach (var (clut, entity) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
Drive(entity, clut.ValueRO.Remaining, popDecay, minScale, popAmt, ecb); Drive(entity, clut.ValueRO.Remaining, popDecay, minScale, popAmt, ecb);
ecb.Playback(EntityManager); // Track B: this only ever records on a node's FIRST sighting, but Playback is a structural-change
// sync point and used to run every frame regardless.
if (!ecb.IsEmpty) ecb.Playback(EntityManager);
ecb.Dispose(); ecb.Dispose();
// Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost. // Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost.
@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bba17743370546d4e85c057b60b43653
@@ -68,6 +68,10 @@ namespace ProjectM.Client
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it
if (_fuseMat != null) Object.Destroy(_fuseMat); if (_fuseMat != null) Object.Destroy(_fuseMat);
if (_fuseDiscMesh != null) Object.Destroy(_fuseDiscMesh); if (_fuseDiscMesh != null) Object.Destroy(_fuseDiscMesh);
// Procedural clips are NOT children of _fxRoot — see FeedbackFx.DestroyClip.
FeedbackFx.DestroyClip(ref _chipClip);
FeedbackFx.DestroyClip(ref _clearClip);
FeedbackFx.DestroyClip(ref _boomClip);
} }
protected override void OnUpdate() protected override void OnUpdate()