From e0c59ad663bc11a10ac28cb9e0cdaa0aa07deed9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 13 Aug 2026 23:02:50 -0700 Subject: [PATCH] Perf: pool one-shot SFX + authored VFX, cut per-frame presentation allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Client/Presentation/AbilityBarSystem.cs | 32 +- .../Client/Presentation/AmbientAudioSystem.cs | 6 + .../Client/Presentation/AmbientLifeSystem.cs | 1 + .../Presentation/CombatFeedbackSystem.cs | 324 +++++++++++++----- .../EnemyDangerTelegraphSystem.cs | 52 ++- .../Presentation/EnemyHealthBarSystem.cs | 20 +- .../Presentation/EnemyHitFlashSystem.cs | 13 +- .../Scripts/Client/Presentation/FeedbackFx.cs | 18 +- .../Presentation/GeyserTelegraphSystem.cs | 1 + .../Scripts/Client/Presentation/HudSystem.cs | 34 +- .../Client/Presentation/MusicSystem.cs | 15 + .../Client/Presentation/NodeFeedbackSystem.cs | 4 +- .../Client/Presentation/OneShotAudioPool.cs | 186 ++++++++++ .../Presentation/OneShotAudioPool.cs.meta | 2 + .../Presentation/WorldFeedbackSystem.cs | 4 + 15 files changed, 603 insertions(+), 109 deletions(-) create mode 100644 Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs create mode 100644 Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs.meta diff --git a/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs index e5626644f..5feccc1d2 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AbilityBarSystem.cs @@ -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); diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs index 0fde265e7..ef90fa29b 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -58,6 +58,12 @@ namespace ProjectM.Client protected override void OnDestroy() { 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() diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs index 8d6391f6c..d834323a9 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientLifeSystem.cs @@ -68,6 +68,7 @@ namespace ProjectM.Client if (_cloudMesh != null) Object.Destroy(_cloudMesh); if (_critterMat != null) Object.Destroy(_critterMat); if (_cloudMat != null) Object.Destroy(_cloudMat); + FeedbackFx.DestroyClip(ref _thunderClip); // not owned by _root — see FeedbackFx.DestroyClip } protected override void OnUpdate() diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 5d5862eaa..22100558f 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -46,7 +46,7 @@ namespace ProjectM.Client // Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig). readonly List _activeVfx = new(); - readonly Dictionary _projTrails = new(); + readonly Dictionary _projTrails = new(); readonly HashSet _projSeen = new(); readonly List _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 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) + // 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) int _pendingConnectStep; 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 int _stressBeat; #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 // 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 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> _vfxPool = new(); + readonly Dictionary _vfxLifetime = new(); + readonly Dictionary _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() { @@ -161,6 +191,10 @@ namespace ProjectM.Client 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) Object.Destroy(_fxRoot.gameObject); if (_slashMesh != null) Object.Destroy(_slashMesh); @@ -168,6 +202,18 @@ namespace ProjectM.Client if (_smearMesh != null) Object.Destroy(_smearMesh); 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) { if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh); @@ -178,6 +224,8 @@ namespace ProjectM.Client } + + protected override void OnUpdate() { float dt = SystemAPI.Time.DeltaTime; @@ -246,7 +294,6 @@ namespace ProjectM.Client PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume); 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 && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam) if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec); @@ -271,7 +318,6 @@ namespace ProjectM.Client PlayClip(_deathClip, (Vector3)p, FeelConfig.KillSfxVolume); PrototypeCameraRig.AddShake(FeelConfig.KillShake); PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs); - TryHold(); // C4: kill crunch (throttled) EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec); @@ -338,7 +384,6 @@ namespace ProjectM.Client PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume); PrototypeCameraRig.AddShake(FeelConfig.KillShake); 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 if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1) 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) { if (prefab == null || _fxRoot == null) return; - if (_activeVfx.Count >= MaxActiveVfx) return; // saturated: drop (cheap) rather than thrash GC - var go = Object.Instantiate(prefab, pos, rot, _fxRoot); - go.transform.position = pos; - StripCosmetic(go); - var systems = go.GetComponentsInChildren(); - for (int i = 0; i < systems.Length; i++) systems[i].Play(); - _activeVfx.Add(new TimedVfx { Go = go, Kill = SystemAPI.Time.ElapsedTime + VfxLifetime(go) }); + if (_activeVfx.Count >= MaxActiveVfx) return; // in-flight cap: unchanged, this bounds live particles too + var inst = RentVfx(prefab, pos, rot); + if (inst == null) return; + _activeVfx.Add(new TimedVfx { Inst = inst, Kill = SystemAPI.Time.ElapsedTime + VfxLifetimeFor(prefab) }); // Phase 1.5 lighting: authored VFX impacts flash too (a==0 -> config default colour). DynamicLightSystem.RequestFlash(pos, new Color(0f, 0f, 0f, 0f), 1f); } + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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(true), + Trails = go.GetComponentsInChildren(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; + } + + /// + /// 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. + /// + 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(); _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() { double now = SystemAPI.Time.ElapsedTime; for (int i = _activeVfx.Count - 1; i >= 0; i--) { 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); } } @@ -722,10 +867,11 @@ namespace ProjectM.Client { 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) { - foreach (var kv in _projTrails) if (kv.Value != null) Object.Destroy(kv.Value); + foreach (var kv in _projTrails) ReturnVfx(kv.Value); _projTrails.Clear(); } return; @@ -739,15 +885,12 @@ namespace ProjectM.Client Vector3 wp = (Vector3)xf.ValueRO.Position; 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 { - var go = Object.Instantiate(cfg.ProjectileTrail, wp, Quaternion.identity, _fxRoot); - StripCosmetic(go); // GA "projectile" prefabs ship a Rigidbody + mover; keep particles only - var systems = go.GetComponentsInChildren(); - for (int i = 0; i < systems.Length; i++) systems[i].Play(); - _projTrails[entity] = go; + var inst = RentVfx(cfg.ProjectileTrail, wp, Quaternion.identity); + if (inst != null) _projTrails[entity] = inst; } } @@ -757,7 +900,7 @@ namespace ProjectM.Client if (!_projSeen.Contains(kv.Key)) _projStale.Add(kv.Key); 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]); } } @@ -767,27 +910,31 @@ namespace ProjectM.Client // effects on contact — strip all of that so our per-frame reposition is authoritative and nothing leaks. static void StripCosmetic(GameObject go) { - foreach (var rb in go.GetComponentsInChildren(true)) Object.Destroy(rb); - foreach (var col in go.GetComponentsInChildren(true)) Object.Destroy(col); + // DestroyImmediate, not Destroy: a deferred Destroy is only applied at end-of-frame, so an instance + // 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(true)) Object.DestroyImmediate(rb); + foreach (var col in go.GetComponentsInChildren(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(true)) - { - 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; - } + if (mb != null) mb.enabled = false; } - // Real effect duration from the longest child ParticleSystem (clamped), so we don't force-kill early - // or hold a finished GameObject around on a blanket TTL. - static double VfxLifetime(GameObject go) + // Real effect duration from the longest ParticleSystem (clamped), so we don't force-kill early or hold a + // finished instance out of the pool on a blanket TTL. Takes the per-instance cache so the old + // GetComponentsInChildren-per-spawn is gone; the RESULT is per-prefab and memoised in _vfxLifetime. + static double VfxLifetime(ParticleSystem[] systems) { float longest = 0f; - foreach (var ps in go.GetComponentsInChildren(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; if (d > longest) longest = d; } @@ -796,6 +943,8 @@ namespace ProjectM.Client // ---- Floating damage numbers (pooled, billboarded TextMesh) ---- + const int AlphaSteps = 12; // Track B: fade quantisation for the floating numbers (see AnimateNumbers) + class FloatingNumber { public TextMesh Tm; @@ -805,6 +954,7 @@ namespace ProjectM.Client public Vector3 Vel; public Color BaseColor; public bool Active; + public int ShownAlphaStep; // Track B: quantised fade step last written to Tm.color (see AnimateNumbers) } FloatingNumber CreateNumber() @@ -836,6 +986,7 @@ namespace ProjectM.Client 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.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.Vel = new Vector3(0f, 2.2f, 0f); fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag); @@ -862,9 +1013,17 @@ namespace ProjectM.Client fn.Tr.position += fn.Vel * dt; if (cam != null) fn.Tr.rotation = cam.transform.rotation; - var c = fn.BaseColor; - c.a = 1f - (fn.Age / fn.Life); - fn.Tm.color = c; + // 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; + c.a = step / (float)AlphaSteps; + fn.Tm.color = c; + } } } @@ -914,7 +1073,7 @@ namespace ProjectM.Client void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign, 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 r0 = r1 * innerFrac; float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge @@ -923,47 +1082,53 @@ namespace ProjectM.Client float aBase = aStart; 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); - var verts = new Vector3[(seg + 1) * 2]; - var cols = new Color[(seg + 1) * 2]; - var uvs = new Vector2[(seg + 1) * 2]; - var tris = new int[seg * 6]; + + // UVs and triangles do not depend on ANY argument, so they are built once for the whole system + // instead of being regenerated (and re-uploaded) on every call. + if (!_arcStaticsBuilt) + { + for (int i = 0; i <= seg; i++) + { + _arcUvs[i * 2] = new Vector2(0.5f, 0.5f); + _arcUvs[i * 2 + 1] = new Vector2(0.5f, 0.5f); + } + for (int i = 0; i < seg; i++) + { + int b = i * 2; + _arcTris[i * 6 + 0] = b; _arcTris[i * 6 + 1] = b + 1; _arcTris[i * 6 + 2] = b + 2; + _arcTris[i * 6 + 3] = b + 1; _arcTris[i * 6 + 4] = b + 3; _arcTris[i * 6 + 5] = b + 2; + } + _arcStaticsBuilt = true; + } + for (int i = 0; i <= seg; i++) { float a = Mathf.Lerp(aBase, aEnd, i / (float)seg); float sx = Mathf.Sin(a), cz = Mathf.Cos(a); - verts[i * 2] = new Vector3(sx * r0, y, cz * r0); - verts[i * 2 + 1] = new Vector3(sx * r1, y, cz * r1); + _arcVerts[i * 2] = new Vector3(sx * r0, y, cz * r0); + _arcVerts[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); + _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 } - for (int i = 0; i < seg; i++) - { - int b = i * 2; - tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2; - tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2; - } - mesh.Clear(); - 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 - // 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; - double now = SystemAPI.Time.ElapsedTime; - if (now - _lastHoldTime < 0.22) return; - _lastHoldTime = now; - PrototypeCameraRig.Hold(frames > 0 ? frames : FeelConfig.HitStopMaxFrames); + // 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 @@ -1002,7 +1167,6 @@ namespace ProjectM.Client if (finisher) { 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 } - -void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f) + // Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS + // 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; bool finisher = step >= comboLen; diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs index ac2d2ae71..b939adabc 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyDangerTelegraphSystem.cs @@ -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(); 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". diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs index b789fbfcc..93be135c7 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHealthBarSystem.cs @@ -30,6 +30,10 @@ namespace ProjectM.Client public GameObject CanvasGo; public UnityEngine.UI.Image Fill; public UnityEngine.UI.Image Bg; public float ShowTimer; public bool Visible; 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; @@ -151,7 +155,8 @@ namespace ProjectM.Client var entry = new HealthBarEntry { 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; return entry; @@ -209,8 +214,17 @@ namespace ProjectM.Client } float alpha = (!alwaysOn && entry.ShowTimer < 0f) ? 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); } - if (entry.Bg != null) { var c = entry.Bg.color; c.a = 0.82f * alpha; entry.Bg.color = c; } + // Track B: these ran unconditionally for every visible bar every frame. Writing + // 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; } diff --git a/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs index 6258f9717..a055cedf0 100644 --- a/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/EnemyHitFlashSystem.cs @@ -49,7 +49,10 @@ namespace ProjectM.Client // Pass 1: discover enemies, ensure each is tracked + its render children carry the override component. _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 SystemAPI.Query>().WithAny().WithAll().WithEntityAccess()) { @@ -64,13 +67,17 @@ namespace ProjectM.Client if (!EntityManager.Exists(c) || !EntityManager.HasComponent(c)) continue; entry.RenderKids.Add(c); if (!EntityManager.HasComponent(c)) + { + if (!hasEcb) { ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp); hasEcb = true; } 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). if (entry.RenderKids.Count > 0) _tracked[entity] = entry; } - ecb.Playback(EntityManager); - ecb.Dispose(); + // Only pay the structural-change sync point on the frames that actually recorded something + // (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. var bc = FeelConfig.BodyFlashColor; diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs index bb3e38735..f31a2eec0 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs @@ -139,7 +139,23 @@ namespace ProjectM.Client public static void PlayClip(AudioClip clip, Vector3 pos, float vol) { 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); + } + + /// + /// Destroy a procedurally-built clip on world teardown. An AudioClip.Created 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. + /// + 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) ---- diff --git a/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs index 909bc42f7..39f6d0bce 100644 --- a/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs @@ -73,6 +73,7 @@ namespace ProjectM.Client if (_discMat != null) Object.Destroy(_discMat); if (_burstMat != null) Object.Destroy(_burstMat); if (_discMesh != null) Object.Destroy(_discMesh); + FeedbackFx.DestroyClip(ref _eruptClip); // not owned by _fxRoot — see FeedbackFx.DestroyClip } protected override void OnUpdate() diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 3a76942b5..147994eb1 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -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>().WithAll()) { 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 diff --git a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs index cc000d825..796b478df 100644 --- a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs @@ -57,9 +57,24 @@ namespace ProjectM.Client 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); } + static void DestroySourceClip(AudioSource src) + { + if (src == null) return; + var clip = src.clip; + src.clip = null; + FeedbackFx.DestroyClip(ref clip); + } + AudioSource MakeSource(AudioClip clip) { var src = _root.AddComponent(); diff --git a/Assets/_Project/Scripts/Client/Presentation/NodeFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/NodeFeedbackSystem.cs index 7a23e8a9c..f0025d34b 100644 --- a/Assets/_Project/Scripts/Client/Presentation/NodeFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/NodeFeedbackSystem.cs @@ -53,7 +53,9 @@ namespace ProjectM.Client foreach (var (clut, entity) in SystemAPI.Query>().WithEntityAccess()) 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(); // Prune despawned (depleted/shattered) nodes — their PostTransformMatrix dies with the ghost. diff --git a/Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs b/Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs new file mode 100644 index 000000000..cf3304351 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs @@ -0,0 +1,186 @@ +using UnityEngine; + +namespace ProjectM.Client +{ + /// + /// Pooled 3D one-shot SFX voices — the allocation-free replacement for + /// AudioSource.PlayClipAtPoint that sits behind . + /// + /// PlayClipAtPoint allocates a fresh GameObject + AudioSource per call and + /// schedules a delayed Destroy. Every one-shot in the project funnels through + /// (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. + /// + /// + /// PARITY with PlayClipAtPoint matters — 20 call sites are re-levelled at once by any deviation. + /// PlayClipAtPoint sets spatialBlend = 1 explicitly (a fresh AudioSource defaults to 0 = + /// fully 2D) and leaves everything else at the stock defaults, so those are all re-stated in + /// . Two DELIBERATE divergences, both forced by the voices being long-lived + /// rather than per-event: playOnAwake = false (a pooled source would otherwise self-start on + /// any enable, replaying whatever clip is still assigned) and dopplerLevel = 0 (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). + /// + /// + /// Lifetime: the root is DontDestroyOnLoad because WorldLauncher does + /// LoadScene(..., Single) 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 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 + /// MissingReferenceException). Both the root and each voice are additionally rebuilt on + /// fake-null, so anything that does destroy them heals instead of going quiet. + /// + /// Main-thread only (every caller is a PresentationSystemGroup SystemBase) — no locking. + /// Asset-free: built from new GameObject + AddComponent, never a prefab or Resources.Load. + /// + public static class OneShotAudioPool + { + /// + /// 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. + /// + /// + 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 + + /// + /// 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. + /// + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() + { + s_root = null; + s_voices = null; + s_freeAt = null; + } + + /// + /// Fire a positional one-shot. is the FINAL level — the caller + /// () has already applied the GameVolume.Sfx 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. + /// + 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(); + } + + /// + /// 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). + /// + 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; + } + } + + /// + /// First idle voice, else steal the one nearest to finishing (smallest deadline). Deadlines come + /// from the actual clip.length — 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. Time.unscaledTime because this project never touches Time.timeScale + /// (hit-stop is a camera punch by house rule). + /// + 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]; + } + + /// Idempotent: builds the root and any missing voice, and heals fake-null entries. + 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; } + } + + /// + /// 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. + /// + static AudioSource MakeVoice(int index) + { + var go = new GameObject("Voice" + index); + go.transform.SetParent(s_root.transform, false); + var src = go.AddComponent(); + + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs.meta b/Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs.meta new file mode 100644 index 000000000..9f9b0328b --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/OneShotAudioPool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bba17743370546d4e85c057b60b43653 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs index f03dc7194..d84b670ee 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs @@ -68,6 +68,10 @@ namespace ProjectM.Client if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject); // fuse rings are children -> die with it if (_fuseMat != null) Object.Destroy(_fuseMat); 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()