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
@@ -14,6 +14,11 @@ namespace ProjectM.Client
/// (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
/// 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>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
@@ -22,6 +27,13 @@ namespace ProjectM.Client
Transform _root;
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()
{
if (_root == null) _root = new GameObject("~RoomDressing").transform;
@@ -30,6 +42,10 @@ namespace ProjectM.Client
protected override void OnDestroy()
{
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()
@@ -106,6 +122,51 @@ namespace ProjectM.Client
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
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);
}
}
}