Files
Project-M/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.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

284 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Shared asset-free procedural-FX toolkit for the client presentation FEEDBACK systems (Combat / Structure /
/// World / Ambient), which previously each carried a near-identical private copy. Pure UnityEngine (no ECS), so
/// it stays a plain static helper. MakeBurst takes the owning FX-root transform + the per-use gravity / shape
/// radius / size-tail that were the ONLY real differences between the old copies (Combat 0/0.06/0.2,
/// Structure 0.3/0.18/0.15, World 0.25/0.10/0.2). MakeClip folds in the noise + envelope-decay knobs that were
/// the only differences between the CombatFeedback MakeClip and the AmbientAudio MakeSting.
/// </summary>
public static class FeedbackFx
{
public static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol, bool noise = false, float decay = 5f)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create(name, len, 1, rate, false);
var data = new float[len];
var rng = new System.Random(name.Length * 9973 + 7);
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-decay * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
float s = noise ? (float)(rng.NextDouble() * 2.0 - 1.0) : Mathf.Sin(phase);
data[i] = s * env * vol;
}
clip.SetData(data, 0);
return clip;
}
static Texture2D s_softDot;
/// <summary>
/// Sprites/Default with NO texture renders hard SQUARES — which is what the ambient drift was painting
/// across the seabed (found at the 2026-08-07 A0 gate). Build the dot procedurally instead of referencing
/// an asset: runtime Resources.Load is stripped from player builds, and asset-free presentation is the
/// house rule. Self-healing across fast-enter-playmode — a destroyed texture compares == null and rebuilds.
/// </summary>
public static Texture2D SoftDot()
{
if (s_softDot != null) return s_softDot;
const int N = 32;
var t = new Texture2D(N, N, TextureFormat.RGBA32, false)
{
name = "T_SoftDotProc",
wrapMode = TextureWrapMode.Clamp
};
for (int y = 0; y < N; y++)
{
for (int x = 0; x < N; x++)
{
float dx = (x + 0.5f) / N * 2f - 1f;
float dy = (y + 0.5f) / N * 2f - 1f;
float a = Mathf.Clamp01(1f - Mathf.Sqrt(dx * dx + dy * dy));
t.SetPixel(x, y, new Color(1f, 1f, 1f, a * a)); // squared = softer falloff
}
}
t.Apply();
s_softDot = t;
return t;
}
public static Material MakeParticleMaterial(string name = "FeedbackParticle")
{
// Sprites/Default is an always-included transparent vertex-coloured shader (reliable billboarded sparks;
// HDR start colours still push past the bloom threshold); fall back through URP unlit / Unlit/Color.
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = name, mainTexture = SoftDot() };
}
public static ParticleSystem MakeBurst(Transform parent, string name, Material mat, Color color,
float size, float speed, float life, int max,
float gravity = 0f, float shapeRadius = 0.06f, float sizeTail = 0.2f)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.loop = false;
main.playOnAwake = false;
main.startLifetime = life;
main.startSpeed = speed;
main.startSize = size;
main.startColor = color;
main.maxParticles = max;
main.gravityModifier = gravity;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = false; // manual Emit(count)
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = shapeRadius;
var colOverLife = ps.colorOverLifetime;
colOverLife.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
var sizeOverLife = ps.sizeOverLifetime;
sizeOverLife.enabled = true;
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, sizeTail));
var renderer = ps.GetComponent<ParticleSystemRenderer>();
renderer.material = mat;
renderer.renderMode = ParticleSystemRenderMode.Billboard;
return ps;
}
public static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
{
if (ps == null) return;
var main = ps.main;
main.startColor = tint;
ps.transform.position = pos;
ps.Emit(count);
}
public static void EmitAt(ParticleSystem ps, Vector3 pos, int count)
{
if (ps == null) return;
ps.transform.position = pos;
ps.Emit(count);
}
public static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
// Pooled 3D voices, NOT AudioSource.PlayClipAtPoint: that allocates a GameObject +
// AudioSource per call and schedules a delayed Destroy, ~20-33 times a second in combat.
// GameVolume.Sfx is read HERE (at play time) so the bus trim applies per cue, as before.
OneShotAudioPool.Play(clip, pos, vol * GameVolume.Sfx);
}
/// <summary>
/// Destroy a procedurally-built clip on world teardown. An <c>AudioClip.Create</c>d clip is a standalone
/// UnityEngine.Object — destroying the system's FX-root GameObject does NOT take it with it, so every
/// presentation system that builds clips leaked its native audio buffer on each client-world teardown
/// (unbounded growth across menu -> game -> menu cycles). Nulls the reference so a re-created system
/// rebuilds rather than holding a destroyed clip.
/// </summary>
public static void DestroyClip(ref AudioClip clip)
{
if (clip != null) Object.Destroy(clip);
clip = null;
}
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
// Transparent unlit ground-decal material: Sprites/Default honours vertex colour × the per-renderer _Color
// MPB, blends alpha, is double-sided (Cull Off) and writes no depth (ZWrite Off) — so flat, overlapping
// ground decals never z-fight and never cast/receive shadows. Same shader family as the fuse disc.
public static Material MakeDecalMaterial(string name = "GroundDecal")
{
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Transparent");
return new Material(sh) { name = name, renderQueue = 3000 }; // Transparent
}
// Irregular soft radial disc on the XZ plane (already flat — scale x/z, spin about Y): vertex alpha 1 at the
// centre fading to 0 at a JITTERED rim, so it reads as an organic scorch blob with NO hard rectangle edge
// (the feathered rim is what keeps it from ghosting over fog the way an opaque quad did). White vertex colour
// so the caller's material/MPB tint (dark char) shows through.
public static Mesh BuildScorchMesh(int segments = 24, float jitter = 0.3f, int seed = 1)
{
if (segments < 6) segments = 6;
var rng = new System.Random(seed * 6151 + 13);
var m = new Mesh { name = "ScorchDecal" };
var v = new Vector3[segments + 1];
var col = new Color[segments + 1];
var tris = new int[segments * 3];
v[0] = Vector3.zero;
col[0] = new Color(1f, 1f, 1f, 1f);
for (int i = 0; i < segments; i++)
{
float a = i / (float)segments * Mathf.PI * 2f;
float r = 1f - jitter * (float)rng.NextDouble();
v[i + 1] = new Vector3(Mathf.Cos(a) * r, 0f, Mathf.Sin(a) * r);
col[i + 1] = new Color(1f, 1f, 1f, 0f); // transparent feathered rim
int n = (i + 1) % segments;
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
}
m.vertices = v; m.colors = col; m.triangles = tris;
m.RecalculateBounds();
return m;
}
// A jagged crack star on the XZ plane: `spokes` thin tapered triangles radiating from the centre at random
// angles/lengths, opaque at the base and feathered to nothing at the tip. Reads as surface cracking, visually
// distinct from the scorch blob. White vertex colour; caller tints dark and scales it to the piece.
public static Mesh BuildCrackMesh(int spokes = 3, int seed = 1)
{
if (spokes < 1) spokes = 1;
var rng = new System.Random(seed * 9277 + 5);
var m = new Mesh { name = "CrackDecal" };
var v = new Vector3[spokes * 3];
var col = new Color[spokes * 3];
var tris = new int[spokes * 3];
for (int s = 0; s < spokes; s++)
{
float a = (s / (float)spokes) * Mathf.PI * 2f + (float)(rng.NextDouble() - 0.5) * 1.2f;
float len = 0.6f + 0.4f * (float)rng.NextDouble();
float w = 0.06f + 0.05f * (float)rng.NextDouble();
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
var perp = new Vector3(-dir.z, 0f, dir.x) * w;
int b = s * 3;
v[b] = perp; v[b + 1] = -perp; v[b + 2] = dir * len;
col[b] = new Color(1f, 1f, 1f, 1f);
col[b + 1] = new Color(1f, 1f, 1f, 1f);
col[b + 2] = new Color(1f, 1f, 1f, 0f); // feathered tip
tris[b] = b; tris[b + 1] = b + 2; tris[b + 2] = b + 1;
}
m.vertices = v; m.colors = col; m.triangles = tris;
m.RecalculateBounds();
return m;
}
// Unit-radius upward-facing disc fan on the XZ plane (centre vertex + rim) for ground telegraph / blast-radius
// rings — scale x/z to the radius. No vertex colours (white default) so the material or a per-renderer _Color
// MPB fully tints + fades it. Promoted from WorldFeedbackSystem so the barrel fuse ring + the geyser telegraph
// share ONE primitive (review M6).
public static Mesh BuildDisc(int segments)
{
if (segments < 6) segments = 6;
var m = new Mesh { name = "Disc" };
var v = new Vector3[segments + 1];
var tris = new int[segments * 3];
v[0] = Vector3.zero;
for (int i = 0; i < segments; i++)
{
float a = i / (float)segments * Mathf.PI * 2f;
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
int n = (i + 1) % segments;
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
}
m.vertices = v;
m.triangles = tris;
m.RecalculateBounds();
return m;
}
// Unit-radius thin RING (annulus) on the XZ plane — the always-on rim of a zone telegraph (rim = the TRUE
// damage radius, guidelines G2) while a separate fill disc grows inside it. Scale x/z to the radius; inner
// edge fixed at innerFrac of the outer. No vertex colours (white) so an MPB _Color fully tints + fades it.
public static Mesh BuildRing(int segments, float innerFrac = 0.92f)
{
if (segments < 6) segments = 6;
innerFrac = Mathf.Clamp(innerFrac, 0.05f, 0.98f);
var m = new Mesh { name = "Ring" };
var v = new Vector3[segments * 2];
var tris = new int[segments * 6];
for (int i = 0; i < segments; i++)
{
float a = i / (float)segments * Mathf.PI * 2f;
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
v[i * 2] = dir * innerFrac;
v[i * 2 + 1] = dir;
int n = (i + 1) % segments;
int b = i * 6;
tris[b] = i * 2; tris[b + 1] = n * 2 + 1; tris[b + 2] = i * 2 + 1;
tris[b + 3] = i * 2; tris[b + 4] = n * 2; tris[b + 5] = n * 2 + 1;
}
m.vertices = v;
m.triangles = tris;
m.RecalculateBounds();
return m;
}
}
}