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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user