Aetheria/Assets/Shaders/Compute/ShieldField/ShieldSim.compute

367 lines
12 KiB
Text
Raw Permalink Normal View History

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
// 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);
}