Aetheria/Assets/Shaders/Compute/ShieldField/ShieldSim.compute
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

367 lines
12 KiB
Text

// ShieldSim.compute
//
// Stress propagates as a wave on the dual graph of the tiling rather than as a
// diffusion. The whole spall effect rests on one convention:
//
// u > 0 is TENSION, u < 0 is COMPRESSION. An impact injects compression.
// Material fails on tension only.
//
// A free surface must hold stress at zero, which is a Dirichlet condition, and
// Dirichlet boundaries reflect with INVERTED sign. So the compressive pulse
// returns from the rim (and from the faces of already-broken cells) as tension,
// and that reflected tension is what spalls. Imposing Neumann here instead --
// which is what you get by default if you simply omit missing neighbours from
// the Laplacian -- reflects compression as compression and nothing ever breaks.
#pragma kernel KInit
#pragma kernel KInject
#pragma kernel KWaveStep
#pragma kernel KEcho
#pragma kernel KFracture
#pragma kernel KUpdate
#include "ShieldCommon.hlsl"
#define ECHO_SLOTS 16
StructuredBuffer<CellStatic> _Cells;
StructuredBuffer<DualEdge> _Edges;
RWStructuredBuffer<CellState> _State;
// wave field, ping-ponged by the host
StructuredBuffer<float> _UIn;
RWStructuredBuffer<float> _UOut;
RWStructuredBuffer<float> _V; // du/dt
RWStructuredBuffer<float> _Echo; // [cell * ECHO_SLOTS + slot] through-thickness delay line
RWStructuredBuffer<float> _EdgeFlow; // per-arc |gradient|, for conduit rendering
AppendStructuredBuffer<uint> _Visible;
RWStructuredBuffer<uint> _BreakBudget; // [0]=count this frame (atomic), [1]=cap; caps cascade rate per frame
uint _CellCount;
float _Time;
float _Dt;
// --- wave
float _WaveSpeed2; // c^2
float _Damping;
float _StressClamp;
// --- through-thickness echo
uint _EchoSlot; // current write slot, advanced by the host each substep
uint _EchoDelay; // slots to wait ~ 2 * thickness / c
float _EchoReflect; // negative: free back face inverts the pulse
// --- hit
float2 _HitPos;
float2 _HitDir; // in-plane component of the incoming direction
float _HitEnergy;
float _HitRadius;
float _HitObliquity; // 0 head-on, 1 grazing
uint _Pattern;
float _StarArms;
float _PatternFocus;
float _HitSpin;
float _SpiralTwist;
// --- material
float _TensileStrength;
float _TemperInit; // surface compression at full charge
float _TemperErosion; // how fast sub-critical tension wears the temper down
float _StoredEnergyGain; // fraction of temper released back into the field on break
float _DamageGain;
float _BreakAt;
float _CrackStart;
// --- shards
float _RadialKick;
float _LateralKick;
float _NormalKick;
float _SpinKick;
float _ShardLife;
float _ShardDrag;
// --- growth
float _PanelSpawnTime;
float _GrowDuration;
float _GlowDecay;
// ==================================================================
[numthreads(64,1,1)]
void KInit(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = (CellState)0;
s.breakTime = -1.0;
s.temper = _TemperInit;
_State[i] = s;
_UOut[i] = 0.0;
_V[i] = 0.0;
for (uint k = 0; k < ECHO_SLOTS; k++) _Echo[i * ECHO_SLOTS + k] = 0.0;
}
// ==================================================================
// Stamp the initial pulse. The pattern shapes the pulse; the material's
// own anisotropy (baked into the arc weights) decides how it spreads.
// ==================================================================
float InjectionProfile(float2 rel, float r)
{
float2 rad = (r > 1e-5) ? rel / r : float2(1, 0);
float falloff = saturate(1.0 - r / max(_HitRadius, 1e-4));
falloff = falloff * falloff;
float shape = 1.0;
switch (_Pattern)
{
case PAT_RADIAL:
break;
case PAT_STAR:
{
float ang = atan2(rad.y, rad.x) + _HitSpin;
float spoke = abs(frac(ang * _StarArms / TAU + 0.5) - 0.5) * 2.0; // 0 on a spoke
shape = lerp(1.0, _PatternFocus, spoke);
break;
}
case PAT_LANCE:
{
float a = saturate(dot(rad, _HitDir) * 0.5 + 0.5);
shape = lerp(_PatternFocus, 1.0, a * a);
break;
}
case PAT_SPIRAL:
{
float ang = atan2(rad.y, rad.x);
float target = _SpiralTwist * r;
float d = abs(frac((ang - target) / TAU * _StarArms + 0.5) - 0.5) * 2.0;
shape = lerp(1.0, _PatternFocus, d);
break;
}
case PAT_CRYSTAL:
// leave the pulse round; the grain in the arc weights does the shaping
break;
case PAT_FIZZLE:
shape = lerp(0.35, 1.0, Hash01(asuint(r * 991.0) ^ asuint(rad.x * 577.0)));
break;
}
// A grazing hit couples less energy in and skews it downrange.
float oblique = lerp(1.0, saturate(dot(rad, _HitDir) * 0.5 + 0.5) * 1.6, _HitObliquity);
return falloff * shape * oblique;
}
[numthreads(64,1,1)]
void KInject(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = _State[i];
if (s.breakTime >= 0.0 || s.growth < 0.6) return;
CellStatic c = _Cells[i];
float2 rel = c.center - _HitPos;
float r = length(rel);
if (r > _HitRadius) return;
float amp = InjectionProfile(rel, r) * _HitEnergy;
// Negative: the strike puts the material into compression. Injecting into
// velocity rather than displacement gives a propagating pulse instead of a
// standing bump.
_V[i] -= amp / max(c.area, 1e-5);
// Seed the through-thickness delay line at the current slot.
_Echo[i * ECHO_SLOTS + (_EchoSlot % ECHO_SLOTS)] -= amp;
}
// ==================================================================
// One substep of u_tt = c^2 * lap(u) - damping * u_t
// ==================================================================
[numthreads(64,1,1)]
void KWaveStep(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellStatic c = _Cells[i];
CellState st = _State[i];
float ui = _UIn[i];
// A broken cell carries no stress and reflects everything back at its neighbours.
if (st.breakTime >= 0.0 || st.growth < 0.6)
{
_UOut[i] = 0.0;
_V[i] = 0.0;
return;
}
float lap = 0.0;
float flowMax = 0.0;
for (uint k = 0; k < c.nbrCount; k++)
{
uint ei = c.nbrStart + k;
DualEdge e = _Edges[ei];
CellState ns = _State[e.nbr];
// Ghost value: an intact neighbour contributes its own stress; a broken
// one (or an ungrown one) is a free surface, so the ghost is -ui, which
// pins the face to zero and inverts the reflected pulse.
bool solid = (ns.breakTime < 0.0) && (ns.growth >= 0.6);
float uj = solid ? _UIn[e.nbr] : -ui;
lap += e.w * (uj - ui);
float f = abs(uj - ui) * e.w;
_EdgeFlow[ei] = f;
flowMax = max(flowMax, f);
}
// Rim edges have no arc at all, so their ghost term was precomputed.
lap += c.openW * (-2.0 * ui);
lap /= max(c.area, 1e-5);
float v = _V[i];
v += _WaveSpeed2 * lap * _Dt;
v *= exp(-_Damping * _Dt);
float u = ui + v * _Dt;
u = clamp(u, -_StressClamp, _StressClamp);
_UOut[i] = u;
_V[i] = v;
}
// ==================================================================
// Through-thickness reflection: the compressive pulse that entered the
// plate returns from the back face, inverted, one round trip later.
// This is the delayed tensile spike co-located with the impact, which is
// why the far face erupts a beat after the near face was struck.
// ==================================================================
[numthreads(64,1,1)]
void KEcho(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = _State[i];
if (s.breakTime >= 0.0) return;
uint base = i * ECHO_SLOTS;
uint read = (_EchoSlot + ECHO_SLOTS - (_EchoDelay % ECHO_SLOTS)) % ECHO_SLOTS;
float pulse = _Echo[base + read];
_Echo[base + read] = 0.0;
if (abs(pulse) < 1e-6) return;
_V[i] += _EchoReflect * pulse / max(_Cells[i].area, 1e-5);
}
// ==================================================================
// Fracture. Tension is the only thing that breaks anything.
//
// effective tension = u - temper
//
// temper is the tempered-glass surface compression: it must be cancelled
// before a crack can start. Sub-critical tension erodes it, which is what
// lets a sequence of weak hits eventually produce a break that no single
// one of them could. When a cell finally goes, its stored core energy is
// dumped back into the wave field, and that is the dicing cascade.
// ==================================================================
[numthreads(64,1,1)]
void KFracture(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = _State[i];
if (s.breakTime >= 0.0 || s.growth < 0.6) return;
CellStatic c = _Cells[i];
float u = _UIn[i];
if (u <= 0.0) return; // compression does nothing
s.peakTension = max(s.peakTension, u);
float strength = _TensileStrength * c.toughness;
float net = u - s.temper; // must out-pull the surface compression
if (net <= 0.0)
{
// Sub-critical: wears the temper down. This is the multi-hit budget.
s.temper = max(0.0, s.temper - u * _TemperErosion * _Dt);
s.glow = max(s.glow, saturate(u / max(strength, 1e-4)) * 0.35);
_State[i] = s;
return;
}
s.damage = saturate(s.damage + (net / max(strength, 1e-4)) * _DamageGain * _Dt);
s.glow = max(s.glow, saturate(net / max(strength, 1e-4)));
s.temper = max(0.0, s.temper - u * _TemperErosion * _Dt * 3.0);
if (s.damage >= _BreakAt && net >= strength)
{
uint slot;
InterlockedAdd(_BreakBudget[0], 1u, slot);
if (slot < _BreakBudget[1]) // budget caps the cascade rate per frame
{
s.breakTime = _Time;
// Stored core energy, released. Sets fragment fineness: a panel that
// was still heavily charged dices finely, a depleted one breaks into
// a few large pieces.
s.released = saturate((s.temper + net) * _StoredEnergyGain);
float2 rel = c.center - _HitPos;
float r = max(length(rel), 1e-4);
float2 rad = rel / r;
float2 lateral = rad * _RadialKick + _HitDir * _LateralKick * _HitObliquity;
float2 j = (Hash2(c.splitSeed) - 0.5) * 0.6;
s.vel = float3(lateral + j, _NormalKick * (0.4 + net));
s.vel *= (0.5 + saturate(net / max(strength, 1e-4)));
s.spin = (Hash01(c.splitSeed ^ 0x51ed2701u) - 0.5) * _SpinKick * (0.5 + net);
// Dump the released energy back into the neighbours as tension. This
// is the feedback loop that makes the panel dice rather than chip.
for (uint k = 0; k < c.nbrCount; k++)
{
DualEdge e = _Edges[c.nbrStart + k];
_V[e.nbr] += s.released * e.w / max(_Cells[e.nbr].area, 1e-5);
}
}
}
_State[i] = s;
}
// ==================================================================
[numthreads(64,1,1)]
void KUpdate(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellStatic c = _Cells[i];
CellState s = _State[i];
s.growth = saturate((_Time - _PanelSpawnTime - c.spawnDelay) / max(_GrowDuration, 1e-3));
s.glow *= exp(-_GlowDecay * _Dt);
if (s.breakTime >= 0.0)
{
float age = _Time - s.breakTime;
if (age > _ShardLife) return; // dead: not appended, not drawn
s.pos += s.vel * _Dt;
s.vel *= exp(-_ShardDrag * _Dt);
s.vel.z -= 0.6 * _Dt; // gentle settle, not real gravity
s.rot += s.spin * _Dt;
}
else if (s.growth <= 0.0)
{
_State[i] = s;
return;
}
_State[i] = s;
_Visible.Append(i);
}