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

378 lines
13 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;
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
float _VelClamp; // D9: bounds v so a hard hit cannot pin u in compression forever
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
// --- 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);
}
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
// 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.
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
lap /= max(c.area, 1e-5);
float v = _V[i];
v += _WaveSpeed2 * lap * _Dt;
v *= exp(-_Damping * _Dt);
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
// 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);
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
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);
}