Aetheria/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs
Meta Vens a935b965c9 Land the six shield-panel sources, ported to built-in and compiling
Cut 1 of docs/shield-panel-cut.md: land ShieldPanel.cs, ShieldPanel.shader,
ShieldSim.compute, ShieldCommon.hlsl, TilingBuilder.cs and Tilings.cs under
Assets/Shaders/Compute/ShieldField/, alongside Lightning/ and Slime/.

Port (URP -> built-in, three substitutions per the map's §2.1 probe):
- drop the UniversalPipeline SubShader tag, add IgnoreProjector
- swap the URP Core.hlsl include for UnityCG.cginc
- TransformWorldToHClip -> UnityWorldToClipPos (3 call sites)

Drawing collapse (D2/D5): RenderParams has no shaderPass member in
6000.3.24f1, and a multi-pass material draws every SubShader pass from one
indirect call regardless, so the three per-pass args buffers and draw calls
collapse to one args buffer sized to the widest pass (Outline) and one
Graphics.RenderPrimitivesIndirect call. Deleted: argsOutline, argsConduit,
drawFill/drawOutline/drawConduits and their branches.

Also deleted: ShieldPanelTester (the click harness the operator ruled
against; it also can't run — activeInputHandler is New Input System only)
and [ExecuteAlways] (incompatible with a pool per D13; Rebuild stays on the
context menu).

Defects fixed:
- D1: TilingBuilder.cs's inner `dist` shadowed the outer one (CS0136);
  renamed to nbrDist.
- D3: CellStatic was 15 floats against a declared Stride of 16; added pad2
  on both the HLSL and C# sides.
- D4: `centroid` is reserved in the compute compiler; renamed the
  ShieldCommon.hlsl field to `center` (kept the C# field name as-is; it's
  not reserved there and the two sides don't need matching names, only
  matching layout).
- D6: fixed the comment on _BreakBudget — it's two elements (count + cap),
  not the "single element" the old comment claimed.
- D12: the two-element uint[] Update() allocated every frame is now a
  cached field, cheaper still under a pool of many live panels.
- D18 (R5 — operator ruling, not deferred to a follow-up): Penrose ignored
  panelRadius entirely — a fixed authored iteration count meant the tiling
  was just uniformly rescaled, not resampled, so cell count never grew with
  the panel. Every other tiling here uses cellSize to hold local cell size
  constant while radius extends coverage; Penrose now derives its iteration
  count the same way (each substitution divides edge length by phi, solved
  for the count that brings a unit edge to ~cellSize once scaled to
  radius). This retires the now-meaningless `penroseIterations` field and
  the `subdivisions` parameter it threaded through Tilings.Generate.
- Also removed a dead `idx` counter in Tilings.Voronoi (D8's mention of
  dead code; the Vector2.zero sentinel ambiguity itself is unfixed, same as
  D7/D19 — Voronoi and TriHex stay recorded defects, not fixed, per R5).

Not fixed here, per the map: D7 (Voronoi is unusably slow) and D19 (TriHex
has zero dual edges) are recorded, not fixed. D9/D10 (fracture tuning) are
Cut 5. D17 (growth gate blocks the spawning hit) and D20 (_PanelSpawnTime
hardcoded to 0) are Cut 3.

Repo-specific fix not in the map: Assets/Scripts/UI/ContextMenu.cs defines
a global `ContextMenu` MonoBehaviour that shadows UnityEngine.ContextMenu,
so `[ContextMenu("Rebuild")]` doesn't compile here (CS0616) even though it
did in the map's throwaway probe project. Used the fully-qualified
`[UnityEngine.ContextMenu(...)]` instead.

Adds Assets/Scripts/Editor/ShieldPanelCut1Verify.cs, a batchmode probe (no
NUnit path exists per the map's Q7 — Tests.asmdef can't see
Assembly-CSharp): asserts zero shader/compute compile errors and that
Penrose cell count actually scales with panelRadius (D18). Also adds
tests/mutation_tests_shield_panel_cut1.py, which mutates the D18 fix, the
D4 fix, and the shaderPass deletion in turn and asserts the probe goes red
each time, restoring the source after each case (own file per the
convention mutation_tests_addressables_cut.py established, so as not to
touch item-provenance's or the addressables cut's harnesses).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 06:08:04 +02:00

439 lines
18 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace ShieldField
{
public enum FracturePattern { Radial = 0, Star = 1, Lance = 2, Spiral = 3, Crystal = 4, Fizzle = 5 }
public class ShieldPanel : MonoBehaviour
{
// ---------------- authoring ----------------
[Header("Tiling")]
public TilingKind tiling = TilingKind.PenroseP3;
public float panelRadius = 5f;
public float cellSize = 0.35f;
public bool mergeRhombs = true; // Penrose triangles -> P3 rhombs
public bool takeDual = false; // render the dual tiling instead
public int seed = 1337;
[Header("Assets")]
public ComputeShader sim;
public Shader panelShader;
[Header("Materialisation")]
public float growDuration = 0.35f;
public float wavefrontSpeed = 8f;
public float delayJitter = 0.06f;
[Header("Wave")]
[Tooltip("c^2. Raise for a faster shock; watch the CFL warning in the console.")]
public float waveSpeed2 = 26f;
public float damping = 1.6f;
public float stressClamp = 4f;
[Range(1, 16)] public int substeps = 6;
public float fixedSubstepDt = 0.0025f;
[Header("Through-thickness echo")]
[Tooltip("Substeps for one round trip through the plate. Small = fast back-face spall.")]
[Range(1, 15)] public int echoDelaySteps = 5;
[Tooltip("Negative: a free back face returns the compressive pulse as tension.")]
public float echoReflect = -0.65f;
[Header("Material (tempered)")]
public float tensileStrength = 0.9f;
[Tooltip("Surface compression at full charge. This is the shield's hit budget.")]
public float temperInit = 0.55f;
public float temperErosion = 2.2f;
[Tooltip("How much stored core energy a break dumps back into the field.")]
public float storedEnergyGain = 0.8f;
public float damageGain = 6f;
[Range(0.05f, 1f)] public float breakAt = 0.75f;
[Range(0f, 0.9f)] public float crackStart = 0.18f;
[Tooltip("Breaks permitted per frame. Without this the cascade finishes in one frame and you see nothing.")]
[Range(0.001f, 1f)] public float breakBudgetFraction = 0.02f;
[Header("Shards")]
public float radialKick = 1.6f;
public float lateralKick = 2.2f;
public float normalKick = 1.1f;
public float spinKick = 7f;
public float shardLife = 2.5f;
public float shardDrag = 0.7f;
[Range(2, 8)] public int maxShards = 6;
[Header("Render")]
public float conduitGain = 2.5f;
// ---------------- runtime ----------------
struct PendingHit
{
public Vector2 pos, dir;
public float energy, radius, obliquity, spin;
public FracturePattern pattern;
public float starArms, focus, twist;
}
readonly Queue<PendingHit> pending = new();
TilingData data;
Material mat;
MaterialPropertyBlock mpb;
GraphicsBuffer bCells, bVerts, bEdges, bState;
GraphicsBuffer bUA, bUB, bV, bEcho, bFlow, bVisible, bBudget;
GraphicsBuffer argsFill;
bool uUsingA = true;
int kInit, kInject, kWave, kEcho, kFracture, kUpdate;
int groups, echoSlot;
float simTime;
Bounds worldBounds;
uint[] budgetScratch; // cached to avoid a per-frame allocation (D12)
const int EchoSlots = 16;
static readonly int MaxV = Limits.MaxVerts;
// shader ids
static readonly int idCells = Shader.PropertyToID("_Cells");
static readonly int idVerts = Shader.PropertyToID("_Verts");
static readonly int idEdges = Shader.PropertyToID("_Edges");
static readonly int idState = Shader.PropertyToID("_State");
static readonly int idUIn = Shader.PropertyToID("_UIn");
static readonly int idUOut = Shader.PropertyToID("_UOut");
static readonly int idU = Shader.PropertyToID("_U");
static readonly int idV = Shader.PropertyToID("_V");
static readonly int idEcho = Shader.PropertyToID("_Echo");
static readonly int idFlow = Shader.PropertyToID("_EdgeFlow");
static readonly int idVisible = Shader.PropertyToID("_Visible");
static readonly int idBudget = Shader.PropertyToID("_BreakBudget");
// ==============================================================
void OnEnable() { Rebuild(); }
void OnDisable() { ReleaseAll(); }
[UnityEngine.ContextMenu("Rebuild")]
public void Rebuild()
{
ReleaseAll();
if (sim == null || panelShader == null) return;
var soup = Tilings.Generate(tiling, panelRadius, cellSize, seed);
if (tiling == TilingKind.PenroseP3 && mergeRhombs)
soup = TilingBuilder.MergeRhombs(soup);
if (takeDual)
soup = TilingBuilder.TakeDual(soup);
data = TilingBuilder.Build(soup,
weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f),
clipRadius: panelRadius,
wavefrontOrigin: Vector2.zero,
wavefrontSpeed: wavefrontSpeed,
delayJitter: delayJitter,
seed: seed);
AllocateBuffers();
CacheKernels();
ResetSim();
CheckCfl();
}
void AllocateBuffers()
{
int n = data.CellCount;
groups = Mathf.CeilToInt(n / 64f);
bCells = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, CellStatic.Stride);
bVerts = new GraphicsBuffer(GraphicsBuffer.Target.Structured, data.verts.Length, sizeof(float) * 2);
bEdges = new GraphicsBuffer(GraphicsBuffer.Target.Structured, Mathf.Max(data.EdgeCount, 1), DualEdge.Stride);
bState = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float) * 16);
bUA = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float));
bUB = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float));
bV = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float));
bEcho = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n * EchoSlots, sizeof(float));
bFlow = new GraphicsBuffer(GraphicsBuffer.Target.Structured, Mathf.Max(data.EdgeCount, 1), sizeof(float));
bBudget = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 2, sizeof(uint));
bVisible = new GraphicsBuffer(GraphicsBuffer.Target.Append | GraphicsBuffer.Target.Structured,
n, sizeof(uint));
bCells.SetData(data.cells);
bVerts.SetData(data.verts);
if (data.EdgeCount > 0) bEdges.SetData(data.dualEdges);
// One args buffer, sized to the widest pass (Outline: n*2 segments * 6 verts).
// RenderParams exposes no per-pass selector in 6000.3.24f1 (D2), and a multi-pass material
// draws every pass from one indirect call anyway (D5/§2.1.4-5), so the three
// per-pass buffers this shipped with were dead weight, not three draw paths.
argsFill = MakeArgs((uint)(MaxV * 2 * 6));
mat = new Material(panelShader) { hideFlags = HideFlags.DontSave };
mpb = new MaterialPropertyBlock();
var b = data.bounds;
float pad = panelRadius * 1.5f + shardLife * 3f;
worldBounds = new Bounds(transform.TransformPoint(b.center),
b.size + Vector3.one * pad);
budgetScratch ??= new uint[2];
}
static GraphicsBuffer MakeArgs(uint vertsPerInstance)
{
var a = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, 1,
GraphicsBuffer.IndirectDrawArgs.size);
a.SetData(new[] { new GraphicsBuffer.IndirectDrawArgs {
vertexCountPerInstance = vertsPerInstance,
instanceCount = 0, startVertex = 0, startInstance = 0 } });
return a;
}
void CacheKernels()
{
kInit = sim.FindKernel("KInit");
kInject = sim.FindKernel("KInject");
kWave = sim.FindKernel("KWaveStep");
kEcho = sim.FindKernel("KEcho");
kFracture = sim.FindKernel("KFracture");
kUpdate = sim.FindKernel("KUpdate");
foreach (int k in new[] { kInit, kInject, kWave, kEcho, kFracture, kUpdate })
{
sim.SetBuffer(k, idCells, bCells);
sim.SetBuffer(k, idEdges, bEdges);
sim.SetBuffer(k, idState, bState);
sim.SetBuffer(k, idV, bV);
sim.SetBuffer(k, idEcho, bEcho);
sim.SetBuffer(k, idFlow, bFlow);
sim.SetBuffer(k, idBudget, bBudget);
}
sim.SetBuffer(kInit, idUOut, bUA);
sim.SetBuffer(kWave, idFlow, bFlow);
sim.SetBuffer(kUpdate, idVisible, bVisible);
}
[UnityEngine.ContextMenu("Reset Simulation")]
public void ResetSim()
{
if (sim == null || data == null) return;
simTime = 0f;
echoSlot = 0;
uUsingA = true;
pending.Clear();
sim.SetInt("_CellCount", data.CellCount);
sim.SetFloat("_TemperInit", temperInit);
sim.SetBuffer(kInit, idUOut, bUA);
sim.Dispatch(kInit, groups, 1, 1);
// second clear so both halves of the ping-pong start at rest
sim.SetBuffer(kInit, idUOut, bUB);
sim.Dispatch(kInit, groups, 1, 1);
sim.SetBuffer(kInit, idUOut, bUA);
}
void CheckCfl()
{
float minArea = float.MaxValue;
int maxDeg = 0;
foreach (var c in data.cells)
{
minArea = Mathf.Min(minArea, c.area);
maxDeg = Mathf.Max(maxDeg, (int)c.nbrCount);
}
// rough stability bound for the explicit leapfrog on an irregular graph
float lim = waveSpeed2 * fixedSubstepDt * fixedSubstepDt * maxDeg / Mathf.Max(minArea, 1e-6f);
if (lim > 1.8f)
Debug.LogWarning($"[ShieldPanel] CFL estimate {lim:F2} exceeds ~1.8 and the wave " +
$"will likely diverge. Lower waveSpeed2 or fixedSubstepDt, or raise cellSize. " +
$"(smallest cell area {minArea:E2}, max degree {maxDeg})", this);
}
// ==============================================================
/// <summary>Strike the panel. worldDir is the projectile's travel direction.</summary>
public void Hit(Vector3 worldPos, Vector3 worldDir, float energy,
FracturePattern pattern = FracturePattern.Radial,
float radius = -1f)
{
if (data == null) return;
Vector3 local = transform.InverseTransformPoint(worldPos);
Vector3 ldir = transform.InverseTransformDirection(worldDir).normalized;
Vector2 flat = new Vector2(ldir.x, ldir.y);
float obliquity = Mathf.Clamp01(1f - Mathf.Abs(ldir.z));
pending.Enqueue(new PendingHit
{
pos = new Vector2(local.x, local.y),
dir = flat.sqrMagnitude > 1e-6f ? flat.normalized : Vector2.right,
energy = energy,
radius = radius > 0f ? radius : Mathf.Max(cellSize * 2.5f, panelRadius * 0.09f),
obliquity = obliquity,
pattern = pattern,
starArms = Random.Range(3, 9),
focus = Mathf.Lerp(0.15f, 0.55f, Random.value),
twist = Random.Range(0.4f, 1.6f),
spin = Random.value * Mathf.PI * 2f
});
}
// ==============================================================
void Update()
{
if (sim == null || data == null || mat == null) return;
float dt = Application.isPlaying ? Time.deltaTime : 1f / 60f;
simTime += dt;
sim.SetInt("_CellCount", data.CellCount);
sim.SetFloat("_Time", simTime);
PushMaterialUniforms();
// ---- break budget resets every frame; this is what paces the cascade
uint cap = (uint)Mathf.Max(1, Mathf.RoundToInt(data.CellCount * breakBudgetFraction));
budgetScratch[0] = 0u;
budgetScratch[1] = cap;
bBudget.SetData(budgetScratch);
// ---- inject queued hits
sim.SetFloat("_Dt", fixedSubstepDt);
while (pending.Count > 0)
{
var h = pending.Dequeue();
sim.SetVector("_HitPos", h.pos);
sim.SetVector("_HitDir", h.dir);
sim.SetFloat("_HitEnergy", h.energy);
sim.SetFloat("_HitRadius", h.radius);
sim.SetFloat("_HitObliquity", h.obliquity);
sim.SetInt("_Pattern", (int)h.pattern);
sim.SetFloat("_StarArms", h.starArms);
sim.SetFloat("_PatternFocus", h.focus);
sim.SetFloat("_SpiralTwist", h.twist);
sim.SetFloat("_HitSpin", h.spin);
sim.SetInt("_EchoSlot", echoSlot);
sim.SetBuffer(kInject, idV, bV);
sim.Dispatch(kInject, groups, 1, 1);
}
// ---- wave substeps (fixed dt: the CFL limit does not care about frame rate)
sim.SetFloat("_WaveSpeed2", waveSpeed2);
sim.SetFloat("_Damping", damping);
sim.SetFloat("_StressClamp", stressClamp);
sim.SetInt("_EchoDelay", echoDelaySteps);
sim.SetFloat("_EchoReflect", echoReflect);
for (int s = 0; s < substeps; s++)
{
var src = uUsingA ? bUA : bUB;
var dst = uUsingA ? bUB : bUA;
sim.SetBuffer(kWave, idUIn, src);
sim.SetBuffer(kWave, idUOut, dst);
sim.Dispatch(kWave, groups, 1, 1);
uUsingA = !uUsingA;
sim.SetInt("_EchoSlot", echoSlot);
sim.Dispatch(kEcho, groups, 1, 1);
echoSlot = (echoSlot + 1) % EchoSlots;
}
var cur = uUsingA ? bUA : bUB;
// ---- fracture
sim.SetFloat("_TensileStrength", tensileStrength);
sim.SetFloat("_TemperErosion", temperErosion);
sim.SetFloat("_StoredEnergyGain", storedEnergyGain);
sim.SetFloat("_DamageGain", damageGain);
sim.SetFloat("_BreakAt", breakAt);
sim.SetFloat("_CrackStart", crackStart);
sim.SetFloat("_RadialKick", radialKick);
sim.SetFloat("_LateralKick", lateralKick);
sim.SetFloat("_NormalKick", normalKick);
sim.SetFloat("_SpinKick", spinKick);
sim.SetBuffer(kFracture, idUIn, cur);
sim.Dispatch(kFracture, groups, 1, 1);
// ---- per-frame state advance + visibility
sim.SetFloat("_Dt", dt);
sim.SetFloat("_PanelSpawnTime", 0f);
sim.SetFloat("_GrowDuration", growDuration);
sim.SetFloat("_GlowDecay", 3.2f);
sim.SetFloat("_ShardLife", shardLife);
sim.SetFloat("_ShardDrag", shardDrag);
bVisible.SetCounterValue(0);
sim.Dispatch(kUpdate, groups, 1, 1);
GraphicsBuffer.CopyCount(bVisible, argsFill, sizeof(uint));
Draw(cur);
}
void PushMaterialUniforms()
{
mat.SetFloat("_CrackStart", crackStart);
mat.SetFloat("_ShardLife", shardLife);
mat.SetInt("_MaxShards", maxShards);
mat.SetFloat("_ConduitGain", conduitGain);
}
void Draw(GraphicsBuffer curU)
{
mpb.Clear();
mpb.SetBuffer(idCells, bCells);
mpb.SetBuffer(idVerts, bVerts);
mpb.SetBuffer(idEdges, bEdges);
mpb.SetBuffer(idState, bState);
mpb.SetBuffer(idU, curU);
mpb.SetBuffer(idFlow, bFlow);
mpb.SetBuffer(idVisible, bVisible);
mpb.SetMatrix("_PanelToWorld", transform.localToWorldMatrix);
mpb.SetFloat("_Time01", simTime);
worldBounds.center = transform.position;
var rp = new RenderParams(mat)
{
worldBounds = worldBounds,
matProps = mpb,
shadowCastingMode = ShadowCastingMode.Off,
receiveShadows = false,
layer = gameObject.layer
};
// RenderParams exposes no per-pass selector in 6000.3.24f1 (D2), and a multi-pass
// material draws every SubShader pass from one indirect call regardless
// (D5) — Fill, Outline and Conduit all run here, pixel-identical to the
// old three-call version (§2.1.5), off one args buffer sized to the
// widest pass (Outline).
Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);
}
// ==============================================================
void ReleaseAll()
{
void R(ref GraphicsBuffer b) { b?.Release(); b = null; }
R(ref bCells); R(ref bVerts); R(ref bEdges); R(ref bState);
R(ref bUA); R(ref bUB); R(ref bV); R(ref bEcho); R(ref bFlow);
R(ref bVisible); R(ref bBudget);
R(ref argsFill);
if (mat != null)
{
if (Application.isPlaying) Destroy(mat); else DestroyImmediate(mat);
mat = null;
}
data = null;
}
void OnDrawGizmosSelected()
{
Gizmos.color = new Color(0.4f, 0.8f, 1f, 0.5f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawWireSphere(Vector3.zero, panelRadius);
}
}
}