Aetheria/Assets/Shaders/Compute/ShieldField/ShieldSim.compute
Meta Vens bdb45f6521 Cut 5: hold the rim, clamp v, fix fracture's clock, tune to spec
R7 (operator ruling): the panel's emitters hold its edge, so the rim is no
longer a free surface. Deletes the openW ghost term end to end -- its only
consumer in ShieldSim.compute's Laplacian, and the accumulation loop in
TilingBuilder that built it -- rather than zeroing it at runtime. A broken
cell's own faces are untouched (KWaveStep's per-edge `solid` check already
treats a broken/ungrown neighbour as free), so the dicing cascade survives.

D9: v was unclamped while u was, so a hard hit drove v far past what one
substep's u-clamp could absorb, pinning u in compression indefinitely (the
measured collapse above energy ~60). Added `_VelClamp` and clamp v to it
every substep in KWaveStep, exposed as ShieldPanel.velocityClamp (100).

D10: KFracture dispatched once per frame but read `_Dt` still holding the
substep dt from the wave loop, so damage and temper erosion accrued at
1/substeps of the intended rate. Fracture's clock is the frame it settles
once per, not the substep, so it now gets the frame dt -- one line, not the
five-extra-dispatches-per-panel alternative (dispatch count is the measured
per-panel cost at up to 12 live panels).

D15: zero-init BuildLine's output struct so the compute compiler's dataflow
analysis stops flagging every branch as a potential non-initializer.

Tuning (ShieldPanel.cs Material header): tensileStrength 0.9->0.2,
temperErosion 2.2->12, damageGain 6->100, breakAt 0.75->0.08,
storedEnergyGain 0.8->1 -- makes a solid hit dice a local cluster around
impact, a weak hit leave nothing, and repeated weak hits erode temper
substantially without breaking anything outright.

Adds ShieldPanelCut5Verify (batchmode probe, same pattern as Cuts 1-3):
pins monotonic peak tension against energy (no D9-style collapse), solid
vs. weak hit outcomes, temper erosion under repeated sub-critical hits,
breaks clustering at the impact rather than the rim (R7), and a broken
cell's neighbours breaking more often than distant cells (the cascade).
D9 and D10 mutations were manually reverted and confirmed to fail their
respective checks; R7's reintroduced-rim mutation did not reliably fail
the cluster check within this probe's energy/step budget -- noted as a
known gap rather than papered over.

The map's own operator-level check ("repeated hits on the same spot break
through on a later hit than the first") is left to play: a closing hit
landed after several prior weak hits on one live panel instance measured
a materially lower peak tension than the same energy against a fresh
panel, a reproducible multi-hit interaction this cut did not chase to
ground.

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

378 lines
13 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;
float _VelClamp; // D9: bounds v so a hard hit cannot pin u in compression forever
// --- 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);
}
// R7: the rim used to be a free surface with a precomputed ghost term (openW) that
// reflected compression back as tension. It no longer contributes at all — the panel's
// emitters hold the edge, so an unshared boundary edge is simply not in the Laplacian.
// A broken cell's own faces are unaffected: the `solid` check above already treats a
// broken (or ungrown) neighbour as free regardless of position, so the dicing cascade
// survives untouched.
lap /= max(c.area, 1e-5);
float v = _V[i];
v += _WaveSpeed2 * lap * _Dt;
v *= exp(-_Damping * _Dt);
// D9: u was clamped but v was not, so a hard-enough hit drove v far past what one
// substep's u-clamp could absorb; u pinned at -_StressClamp every substep afterward and
// the field never swung back into tension (the measured collapse above energy ~60).
// Clamping v to the same scale keeps it a real integrator input instead of an unbounded
// accumulator.
v = clamp(v, -_VelClamp, _VelClamp);
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);
}