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>
378 lines
13 KiB
Text
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);
|
|
}
|