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
@@ -46,7 +46,7 @@ namespace ProjectM.Client
// Authored-VFX lifetime tracking (GabrielAguiar prefabs spawned via VFXConfig).
readonly List<TimedVfx> _activeVfx = new();
readonly Dictionary<Entity, GameObject> _projTrails = new();
readonly Dictionary<Entity, VfxInstance> _projTrails = new();
readonly HashSet<Entity> _projSeen = 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
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<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()
{
@@ -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<ParticleSystem>();
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);
}
/// <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()
{
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<ParticleSystem>();
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<Rigidbody>(true)) Object.Destroy(rb);
foreach (var col in go.GetComponentsInChildren<Collider>(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<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))
{
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<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;
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;