Files
Project-M/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs
T
kronic e0c59ad663 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>
2026-08-13 23:02:50 -07:00

175 lines
8.8 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
/// <summary>
/// Client-only BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> — sibling of
/// <see cref="EnemyDangerTelegraphSystem"/>, never mutates the sim. Reads each geyser's replicated
/// <see cref="Geyser.NextEruptTick"/> against the client's PREDICTED <c>NetworkTime.ServerTick</c> and derives
/// everything from that ABSOLUTE tick (never an edge on the field — so it survives snapshot loss + relevancy
/// re-entry; the design-review-hardened contract, review wf_900e9965-8f0 H1):
/// <list type="bullet">
/// <item>a growing/pulsing warning DISC at <see cref="Tuning.GeyserEruptRadius"/> over the last
/// <see cref="Tuning.GeyserTelegraphTicks"/> before the eruption (the absolute-tick countdown, exactly the
/// enemy-telegraph idiom);</item>
/// <item>the erupt BURST fired when the countdown CROSSES <c>&gt;0 → &lt;=0</c>, LATCHED once per
/// <c>NextEruptTick</c> value + an ARM-guard (only if a prior frame saw it counting down) — immune to the
/// <c>0→stamp</c> phantom, relevancy re-entry mid-period, and snapshot loss.</item>
/// </list>
/// Erupt VFX reuse the shipped statics — <see cref="FeedbackFx"/> burst + <see cref="DynamicLightSystem.RequestFlash"/>
/// + <see cref="ScorchDecalSystem.RequestScorch"/> (bundle 2, built for this). Pooled disc GameObjects under a
/// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class GeyserTelegraphSystem : SystemBase
{
static readonly Color DiscColor = new Color(2.6f, 0.35f, 2.4f); // hot blight magenta-purple (danger + biome id)
static readonly Color EruptColor = new Color(2.9f, 0.5f, 3.2f); // brighter burst
static readonly int ColorId = Shader.PropertyToID("_Color");
Transform _fxRoot;
Material _discMat;
Material _burstMat;
Mesh _discMesh;
ParticleSystem _eruptFx;
AudioClip _eruptClip;
MaterialPropertyBlock _mpb;
readonly Dictionary<Entity, GameObject> _discs = new();
readonly Dictionary<Entity, uint> _armed = new(); // NextEruptTick we've seen counting down for
readonly Dictionary<Entity, uint> _lastFired = new(); // NextEruptTick we last fired the burst for (latch)
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
protected override void OnCreate()
{
_eruptClip = MakeClip("geyser_erupt", 90f, 300f, 0.30f, 0.55f, noise: true, decay: 5f);
_mpb = new MaterialPropertyBlock();
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~GeyserTelegraphFX").transform;
_discMat = MakeParticleMaterial("GeyserDisc");
_discMat.color = DiscColor;
_discMesh = BuildDisc(40);
_burstMat = MakeParticleMaterial("GeyserErupt");
_eruptFx = MakeBurst(_fxRoot, "GeyserErupt", _burstMat, EruptColor, 0.18f, 8f, 0.5f, 256, -0.15f, 0.25f, 0.25f);
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
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()
{
if (_fxRoot == null || _discMat == null) return;
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
var serverTick = nt.ServerTick;
EntityManager.CompleteDependencyBeforeRO<Geyser>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
_seen.Clear();
foreach (var (geyser, xf, e) in
SystemAPI.Query<RefRO<Geyser>, RefRO<LocalTransform>>().WithEntityAccess())
{
uint next = geyser.ValueRO.NextEruptTick;
if (next == 0u) continue; // unstamped -> no telegraph yet (born-correct pending)
var untilTick = new NetworkTick(next);
if (!untilTick.IsValid) continue;
_seen.Add(e);
int lead = untilTick.TicksSince(serverTick); // >0 = erupt in the future, <=0 = erupt now/past
float3 pos = xf.ValueRO.Position;
if (lead > 0)
{
_armed[e] = next; // arm this eruption while it counts down
}
else // lead <= 0: the erupt tick has arrived / passed
{
bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
if (armedForThis && !alreadyFired)
{
// fire ONCE per eruption, on the ServerTick crossing (co-located with the disc completion)
EmitTinted(_eruptFx, (Vector3)pos + Vector3.up * 0.3f, 44, EruptColor);
DynamicLightSystem.RequestFlash((Vector3)pos, new Color(0.85f, 0.35f, 1f), 1.4f);
ScorchDecalSystem.RequestScorch((Vector3)pos, Tuning.GeyserEruptRadius);
PlayClip(_eruptClip, (Vector3)pos, 0.7f);
_lastFired[e] = next;
}
}
// Warning disc: grow + pulse over the telegraph window; hidden when dormant or erupted.
bool showing = lead > 0 && lead <= Tuning.GeyserTelegraphTicks;
if (showing)
{
float charge = 1f - lead / (float)Tuning.GeyserTelegraphTicks; // 0 at window open -> 1 at erupt
if (!_discs.TryGetValue(e, out var go) || go == null)
{
go = new GameObject("GeyserWarning");
go.transform.SetParent(_fxRoot, false);
go.AddComponent<MeshFilter>().sharedMesh = _discMesh;
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _discMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_discs[e] = go;
}
if (!go.activeSelf) go.SetActive(true);
float radius = Tuning.GeyserEruptRadius * (0.55f + 0.45f * charge);
go.transform.position = new Vector3(pos.x, 0.055f, pos.z);
go.transform.localScale = new Vector3(radius, 1f, radius);
float alpha = 0.14f + 0.5f * charge + 0.12f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 10f));
_mpb.SetColor(ColorId, new Color(DiscColor.r, DiscColor.g, DiscColor.b, alpha));
go.GetComponent<MeshRenderer>().SetPropertyBlock(_mpb);
}
else if (_discs.TryGetValue(e, out var go) && go != null && go.activeSelf)
{
go.SetActive(false); // dormant (lead > window) or just erupted (lead <= 0)
}
}
// Prune despawned geysers (teardown / relevancy drop) — destroy the disc, drop tracking, emit nothing.
if (_discs.Count > 0)
{
_stale.Clear();
foreach (var kv in _discs) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
if (_discs[_stale[i]] != null) Object.Destroy(_discs[_stale[i]]);
_discs.Remove(_stale[i]);
}
}
PruneMap(_armed);
PruneMap(_lastFired);
}
void PruneMap(Dictionary<Entity, uint> dict)
{
if (dict.Count == 0) return;
_stale.Clear();
foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
}
}
}