Decals: explosion scorch pool + cover damage-cracks + room-arena scars (Phase 1.5b bundle 2)

All procedural, client-only, observe-only in PresentationSystemGroup, zero
netcode surface (read existing replicated state). Shared FeedbackFx decal
primitives (scorch blob + crack star meshes + transparent decal material).
ScorchDecalSystem: static RequestScorch queue -> pooled fading discs
(mirrors DynamicLightSystem), wired from the barrel boom, reusable by the
geyser. CoverDamageSystem: crack accretion keyed on BlightClutter.Remaining
(Variant 4) + proximity-gated shatter-scorch. RoomDressingSystem: persistent
arena scars on a distinct hash sub-stream, torn down with dressing. Knobs in
DecalConfig. Cover cracks use decals (not URPMaterialPropertyBaseColor) because
the Synty prop shader is not Hybrid-Per-Instance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:58:33 -07:00
parent b3ef846fb8
commit 649f656833
9 changed files with 528 additions and 0 deletions
@@ -0,0 +1,177 @@
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 COVER DAMAGE decals (Phase 1.5b bundle 2 — decals). Destructible cover = a
/// <see cref="BlightClutter"/> ghost (Variant 4, Remaining 8 → 0; see the Destructible_Cover_Build_Spec). This
/// observe-only <see cref="PresentationSystemGroup"/> <see cref="SystemBase"/> edge-detects the replicated
/// <see cref="BlightClutter.Remaining"/> on cover ghosts and accretes jagged CRACK decals on the ground around
/// the rock as it is carved (<see cref="WorldFeedbackSystem"/> already fires the chip puff / SFX / camera-punch
/// on the same edge — we only add the persistent cracks). On despawn (the rock shattered NEAR the local player)
/// it drops a small ground scorch via <see cref="ScorchDecalSystem.RequestScorch"/> and releases the piece's
/// cracks. The despawn scorch is PROXIMITY-GATED (mirrors WorldFeedbackSystem) so a room-teardown / region-transit
/// despawn storm — every cover ghost dropped at once, far off-camera — can't spray scorches or exhaust the pool.
/// <para>
/// Pure procedural decal quads — NO material/shader dependency. This is deliberate: cover's Synty prop shader
/// declares <c>_BaseColor</c> as Unity-Per-Material (NOT Hybrid-Per-Instance), so the
/// <c>URPMaterialPropertyBaseColor</c> darken that <see cref="EnemyHitFlashSystem"/> uses on the DOTS-authored
/// AnimatedLitShader would silently no-op here. Decals sidestep that entirely and read as literal damage.
/// </para>
/// No new component / [GhostField], no server work. Knobs live in <see cref="DecalConfig"/>.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CoverDamageSystem : SystemBase
{
const byte CoverVariant = 4;
class Piece
{
public int LastRemaining;
public int MaxRemaining;
public float3 Pos;
public readonly List<GameObject> Cracks = new();
}
readonly Dictionary<Entity, Piece> _tracked = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
GameObject _root;
Material _mat;
Mesh[] _crackMeshes; // pre-built variants, reused across all cracks (no per-crack Mesh alloc/leak)
MaterialPropertyBlock _mpb;
static readonly int ColorId = Shader.PropertyToID("_Color");
protected override void OnCreate()
{
_crackMeshes = new Mesh[6];
for (int i = 0; i < _crackMeshes.Length; i++) _crackMeshes[i] = BuildCrackMesh(2 + (i % 2), i + 1);
_mpb = new MaterialPropertyBlock();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
if (_mat != null) Object.Destroy(_mat);
if (_crackMeshes != null)
for (int i = 0; i < _crackMeshes.Length; i++)
if (_crackMeshes[i] != null) Object.Destroy(_crackMeshes[i]);
}
protected override void OnUpdate()
{
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return;
if (!DecalConfig.Enabled) { if (_tracked.Count > 0) ClearAll(); return; }
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
if (_root == null)
{
_root = new GameObject("~CoverDecals");
Object.DontDestroyOnLoad(_root);
_mat = MakeDecalMaterial("CoverCrackDecal");
}
// Local player position for the shatter-scorch proximity gate (mirrors WorldFeedbackSystem).
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
_seen.Clear();
foreach (var (clutter, xf, e) in
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
{
if (clutter.ValueRO.Variant != CoverVariant) continue; // cover only
_seen.Add(e);
int remaining = clutter.ValueRO.Remaining;
float3 pos = xf.ValueRO.Position;
if (!_tracked.TryGetValue(e, out var piece))
{
// First sight: cache the baseline; a joiner mid-carve keeps its already-damaged look implicitly.
_tracked[e] = new Piece { LastRemaining = remaining, MaxRemaining = math.max(1, remaining), Pos = pos };
continue;
}
piece.Pos = pos;
if (remaining < piece.LastRemaining)
{
// accrete cracks proportional to damage taken; cap at CoverCrackMaxCount.
float dmgFrac = 1f - remaining / (float)piece.MaxRemaining;
int want = math.clamp((int)math.round(DecalConfig.CoverCrackMaxCount * dmgFrac), 0, DecalConfig.CoverCrackMaxCount);
while (piece.Cracks.Count < want) SpawnCrack(piece, piece.Cracks.Count);
piece.LastRemaining = remaining;
}
}
// ---- prune shattered cover ----
if (_tracked.Count != _seen.Count)
{
float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
_stale.Clear();
foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
var piece = _tracked[_stale[i]];
// Only a real shatter NEAR the local player leaves a scorch; a teardown / region-transit despawn
// storm (all cover dropped at once, off-camera) stays silent — no spray, no pool exhaustion.
if (DecalConfig.CoverShatterScorch && haveLocal && math.distancesq(piece.Pos, localPos) <= rangeSq)
ScorchDecalSystem.RequestScorch((Vector3)piece.Pos, 1.6f);
DestroyCracks(piece);
_tracked.Remove(_stale[i]);
}
}
}
void SpawnCrack(Piece piece, int index)
{
uint seed = (uint)math.max(1, index * 131 + (int)(math.abs(piece.Pos.x) * 7f + math.abs(piece.Pos.z) * 13f));
var rng = new Unity.Mathematics.Random(seed);
var go = new GameObject("Crack");
go.transform.SetParent(_root.transform, false);
go.AddComponent<MeshFilter>().sharedMesh = _crackMeshes[(index + (int)(seed % 3u)) % _crackMeshes.Length];
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_mpb.SetColor(ColorId, DecalConfig.CoverCrackColor);
mr.SetPropertyBlock(_mpb);
// scatter as GROUND cracks radiating from the rock's footprint (avoids z-fighting the rock mesh)
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = rng.NextFloat(0.8f, 1.8f);
float scale = rng.NextFloat(0.9f, 1.6f);
go.transform.SetPositionAndRotation(
new Vector3(piece.Pos.x + math.cos(ang) * rad, 0.06f + 0.003f * index, piece.Pos.z + math.sin(ang) * rad),
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
piece.Cracks.Add(go);
}
void DestroyCracks(Piece piece)
{
for (int c = 0; c < piece.Cracks.Count; c++)
if (piece.Cracks[c] != null) Object.Destroy(piece.Cracks[c]);
piece.Cracks.Clear();
}
void ClearAll()
{
foreach (var kv in _tracked) DestroyCracks(kv.Value);
_tracked.Clear();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 63769d219acf0b248b321ef76bdf4189
@@ -0,0 +1,68 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Live-tunable knobs for the client-only DECALS slice (Phase 1.5b bundle 2: explosion scorch pool, cover
/// damage cracks, room-arena scars). A static bridge (mirrors <see cref="WorldFeelConfig"/> / <see cref="FeelConfig"/>)
/// so values can be poked at runtime via MCP <c>execute_code</c> without a recompile
/// (e.g. <c>ProjectM.Client.DecalConfig.ScorchColor = ...;</c>). Read ONLY by the decal presentation systems
/// (<see cref="ScorchDecalSystem"/>, <see cref="CoverDamageSystem"/>) and <see cref="RoomDressingSystem"/> — all
/// managed, main-thread. NEVER read from a [BurstCompile] system (managed-static + Color-in-Burst hazards).
/// <see cref="ResetDefaults"/> re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] because statics survive
/// fast-enter-playmode reloads (else a poked value leaks across play-enters).
/// </summary>
public static class DecalConfig
{
/// <summary>Master gate for every decal (scorch pool + cover cracks + room scars).</summary>
public static bool Enabled;
// ---- explosion scorch pool (ScorchDecalSystem) ----
/// <summary>Scorch disc radius as a multiple of the explosion radius passed to RequestScorch.</summary>
public static float ScorchRadiusMul;
/// <summary>Seconds a fresh scorch holds at full strength before it starts fading.</summary>
public static float ScorchHoldSec;
/// <summary>Seconds a scorch fades from full to gone (then returns to the pool).</summary>
public static float ScorchFadeSec;
/// <summary>Peak scorch tint (dark char); the alpha is the peak opacity it fades from.</summary>
public static Color ScorchColor;
/// <summary>Max live scorch decals; the oldest is recycled past this.</summary>
public static int ScorchPoolCap;
// ---- cover damage cracks (CoverDamageSystem) ----
/// <summary>Crack-decal tint (dark).</summary>
public static Color CoverCrackColor;
/// <summary>Max crack decals accreted around one cover rock as it is carved to 0.</summary>
public static int CoverCrackMaxCount;
/// <summary>Drop a small ground scorch/debris mark where a cover rock shattered.</summary>
public static bool CoverShatterScorch;
// ---- room-arena scars (RoomDressingSystem) ----
/// <summary>Persistent crack/scorch detail decals laid at the room arena centre (0 = off).</summary>
public static int RoomArenaDecalCount;
/// <summary>Room-arena scar tint (dark, subtle).</summary>
public static Color RoomDecalColor;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
Enabled = true;
// scorch pool — slightly inside the blast radius so it reads as the burn, not the danger ring
ScorchRadiusMul = 0.85f;
ScorchHoldSec = 4f;
ScorchFadeSec = 6f;
ScorchColor = new Color(0.05f, 0.04f, 0.03f, 0.85f); // near-black char, mostly opaque at peak
ScorchPoolCap = 24;
// cover cracks
CoverCrackColor = new Color(0.06f, 0.05f, 0.05f, 0.8f);
CoverCrackMaxCount = 3;
CoverShatterScorch = true;
// room-arena scars
RoomArenaDecalCount = 3;
RoomDecalColor = new Color(0.08f, 0.07f, 0.06f, 0.5f); // subtle
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95c95df641b3d1a4cbce24348a716d66
@@ -109,5 +109,76 @@ namespace ProjectM.Client
if (clip == null) return; if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
} }
// ---- 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;
}
} }
} }
@@ -14,6 +14,11 @@ namespace ProjectM.Client
/// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the /// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the
/// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on /// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on
/// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim. /// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim.
/// <para>
/// Bundle 2 (decals): also lays a few persistent feathered crack/scorch DETAIL scars at the arena centre
/// (higher-frequency detail the baked ground splat can't resolve), on its own hash sub-stream, parented to
/// the same dressing root so they tear down together. Base POIs are skipped — Part O baked their wear in.
/// </para>
/// </summary> /// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))] [UpdateInGroup(typeof(PresentationSystemGroup))]
@@ -22,6 +27,13 @@ namespace ProjectM.Client
Transform _root; Transform _root;
uint _activeKey; uint _activeKey;
// Bundle 2 arena-scar decal assets (reused across rebuilds; the scar GameObjects are children of _root and
// torn down each room change, these shared assets persist until OnDestroy).
Material _decalMat;
Mesh[] _decalMeshes;
MaterialPropertyBlock _decalMpb;
static readonly int DecalColorId = Shader.PropertyToID("_Color");
protected override void OnStartRunning() protected override void OnStartRunning()
{ {
if (_root == null) _root = new GameObject("~RoomDressing").transform; if (_root == null) _root = new GameObject("~RoomDressing").transform;
@@ -30,6 +42,10 @@ namespace ProjectM.Client
protected override void OnDestroy() protected override void OnDestroy()
{ {
if (_root != null) Object.Destroy(_root.gameObject); if (_root != null) Object.Destroy(_root.gameObject);
if (_decalMat != null) Object.Destroy(_decalMat);
if (_decalMeshes != null)
for (int i = 0; i < _decalMeshes.Length; i++)
if (_decalMeshes[i] != null) Object.Destroy(_decalMeshes[i]);
} }
protected override void OnUpdate() protected override void OnUpdate()
@@ -106,6 +122,51 @@ namespace ProjectM.Client
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col); foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb); foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
} }
// Bundle 2 arena scars: distinct hash sub-stream (0xD2E56) so they never co-locate with the prop
// scatter (0xD2E55); clustered around the arena centre. Persistent (no fade); torn down with dressing.
if (DecalConfig.Enabled && DecalConfig.RoomArenaDecalCount > 0)
{
EnsureDecalAssets();
var srng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E56u) | 1u);
for (int i = 0; i < DecalConfig.RoomArenaDecalCount; i++)
SpawnArenaScar(origin, i, ref srng);
}
}
void EnsureDecalAssets()
{
if (_decalMat == null) _decalMat = FeedbackFx.MakeDecalMaterial("RoomScarDecal");
if (_decalMpb == null) _decalMpb = new MaterialPropertyBlock();
if (_decalMeshes == null)
{
_decalMeshes = new Mesh[4];
_decalMeshes[0] = FeedbackFx.BuildScorchMesh(20, 0.32f, 11);
_decalMeshes[1] = FeedbackFx.BuildScorchMesh(20, 0.34f, 23);
_decalMeshes[2] = FeedbackFx.BuildCrackMesh(3, 31);
_decalMeshes[3] = FeedbackFx.BuildCrackMesh(2, 47);
}
}
void SpawnArenaScar(float3 origin, int index, ref Unity.Mathematics.Random rng)
{
var go = new GameObject("ArenaScar");
go.transform.SetParent(_root, false);
go.AddComponent<MeshFilter>().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)];
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _decalMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_decalMpb.SetColor(DecalColorId, DecalConfig.RoomDecalColor);
mr.SetPropertyBlock(_decalMpb);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = rng.NextFloat(0f, 4.5f); // clustered around the arena centre where the fight happens
float scale = rng.NextFloat(1.4f, 3.0f);
go.transform.SetPositionAndRotation(
new Vector3(origin.x + math.cos(ang) * rad, 0.05f + 0.003f * index, origin.z + math.sin(ang) * rad),
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
} }
} }
} }
@@ -0,0 +1,144 @@
using System.Collections.Generic;
using Unity.Entities;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
/// <summary>
/// Client-only EXPLOSION SCORCH pool (Phase 1.5b bundle 2 — decals). A fading pool of flat ground scorch discs
/// dropped at boom sites via the static <see cref="RequestScorch"/> queue — the exact request-drain idiom of
/// <see cref="DynamicLightSystem.RequestFlash"/>: <see cref="WorldFeedbackSystem"/> enqueues at a barrel
/// detonation (same thread, same <see cref="PresentationSystemGroup"/>), we drain once per frame. Each decal
/// HOLDS then FADES its alpha through a single reused <see cref="MaterialPropertyBlock"/> (per-renderer copy →
/// independent lifetimes, no shared-material bleed) and returns to the pool. Pooled GameObjects live under a
/// private DontDestroyOnLoad root (never scene-saved), scene-gated to "Game", null-guarded across scene reloads.
/// Observe-only — never touches the sim, no netcode, no [GhostField]. Reusable by the future Blight-geyser hazard.
/// Knobs live in <see cref="DecalConfig"/>.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class ScorchDecalSystem : SystemBase
{
struct ScorchRequest { public Vector3 Pos; public float Radius; }
class Active { public GameObject Go; public MeshRenderer Renderer; public float SpawnTime; public float PeakAlpha; }
// Static request queue: WorldFeedbackSystem (same thread, PresentationSystemGroup) enqueues, we drain.
static readonly List<ScorchRequest> Pending = new List<ScorchRequest>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { Pending.Clear(); }
/// <summary>Queue a ground scorch at a detonation. <paramref name="radius"/> is the explosion radius; the
/// decal is scaled by <see cref="DecalConfig.ScorchRadiusMul"/>.</summary>
public static void RequestScorch(Vector3 pos, float radius)
{
if (Pending.Count < 64) Pending.Add(new ScorchRequest { Pos = pos, Radius = radius });
}
GameObject _root;
Material _mat;
Mesh[] _meshes; // a few pre-jittered variants for organic variety (reused, no per-decal alloc)
MaterialPropertyBlock _mpb;
readonly List<Active> _active = new List<Active>();
readonly Stack<GameObject> _pool = new Stack<GameObject>();
static readonly int ColorId = Shader.PropertyToID("_Color");
protected override void OnCreate()
{
_meshes = new Mesh[4];
for (int i = 0; i < _meshes.Length; i++) _meshes[i] = BuildScorchMesh(24, 0.3f, i + 1);
_mpb = new MaterialPropertyBlock();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
if (_mat != null) Object.Destroy(_mat);
if (_meshes != null)
for (int i = 0; i < _meshes.Length; i++)
if (_meshes[i] != null) Object.Destroy(_meshes[i]);
}
protected override void OnUpdate()
{
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") { Pending.Clear(); return; }
if (!DecalConfig.Enabled)
{
Pending.Clear();
if (_root != null && _root.activeSelf) _root.SetActive(false);
return;
}
if (_root == null)
{
_root = new GameObject("~ScorchDecals");
Object.DontDestroyOnLoad(_root);
_mat = MakeDecalMaterial("ScorchDecal");
}
if (!_root.activeSelf) _root.SetActive(true);
float now = UnityEngine.Time.time;
// ---- drain new scorch requests ----
for (int i = 0; i < Pending.Count; i++)
{
if (_active.Count >= Mathf.Max(1, DecalConfig.ScorchPoolCap)) RecycleOldest();
var req = Pending[i];
var go = Rent();
float scale = Mathf.Max(0.5f, req.Radius * DecalConfig.ScorchRadiusMul);
// small per-decal y-stagger avoids coplanar transparent z-fight between overlapping scorches
float y = 0.045f + 0.002f * (_active.Count % 8);
float yaw = Mathf.Repeat(req.Pos.x * 53f + req.Pos.z * 31f, 360f);
go.transform.SetPositionAndRotation(new Vector3(req.Pos.x, y, req.Pos.z), Quaternion.Euler(0f, yaw, 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
int mi = Mathf.Abs(Mathf.RoundToInt(req.Pos.x + req.Pos.z)) % _meshes.Length;
go.GetComponent<MeshFilter>().sharedMesh = _meshes[mi];
_active.Add(new Active { Go = go, Renderer = go.GetComponent<MeshRenderer>(), SpawnTime = now, PeakAlpha = DecalConfig.ScorchColor.a });
}
Pending.Clear();
// ---- hold + fade + recycle ----
float hold = Mathf.Max(0f, DecalConfig.ScorchHoldSec);
float fade = Mathf.Max(0.01f, DecalConfig.ScorchFadeSec);
var baseCol = DecalConfig.ScorchColor;
for (int i = _active.Count - 1; i >= 0; i--)
{
var a = _active[i];
if (a.Go == null) { _active.RemoveAt(i); continue; } // scene reload can null pooled objects
float age = now - a.SpawnTime;
float alpha = age <= hold ? a.PeakAlpha : a.PeakAlpha * Mathf.Clamp01(1f - (age - hold) / fade);
if (alpha <= 0.001f) { Return(a.Go); _active.RemoveAt(i); continue; }
_mpb.SetColor(ColorId, new Color(baseCol.r, baseCol.g, baseCol.b, alpha));
a.Renderer.SetPropertyBlock(_mpb);
}
}
GameObject Rent()
{
GameObject go = null;
while (_pool.Count > 0) { go = _pool.Pop(); if (go != null) break; }
if (go == null)
{
go = new GameObject("Scorch");
go.transform.SetParent(_root.transform, false);
go.AddComponent<MeshFilter>();
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _mat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
}
go.SetActive(true);
return go;
}
void Return(GameObject go) { if (go != null) { go.SetActive(false); _pool.Push(go); } }
void RecycleOldest()
{
if (_active.Count == 0) return;
Return(_active[0].Go);
_active.RemoveAt(0);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 225e2db8aab51ad47847beba0f4e0d7f
@@ -139,6 +139,7 @@ namespace ProjectM.Client
PlayClip(_boomClip, (Vector3)c.Pos, 0.8f); PlayClip(_boomClip, (Vector3)c.Pos, 0.8f);
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f); PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f);
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f); PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f);
ScorchDecalSystem.RequestScorch((Vector3)c.Pos, Tuning.BarrelExplodeRadius); // bundle 2: charred ground mark at the boom
} }
else else
{ {