Aetheria/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs

443 lines
18 KiB
C#
Raw 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
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
namespace ShieldField
{
/// <summary>Hard cap on vertices per cell. Must match MAX_VERTS in ShieldCommon.hlsl.</summary>
public static class Limits { public const int MaxVerts = 8; }
[StructLayout(LayoutKind.Sequential)]
public struct CellStatic
{
public Vector2 centroid;
public float radius; // circumradius, used for growth + culling
public float spawnDelay; // materialisation wavefront offset, seconds
public uint vertStart;
public uint vertCount;
public uint nbrStart;
public uint nbrCount;
public float toughness; // per-cell break resistance multiplier
public uint tileType; // author-defined class (thin/fat rhomb, etc)
public uint splitSeed; // stable per-cell RNG seed for shard assignment
public float area; // cell area — denominator of the graph Laplacian
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 (docs/shield-panel-cut.md): openW (rim free-surface ghost weight) removed — the
// panel's emitters hold its edge, so the rim is no longer a stress-reflecting boundary.
// No other consumer read this field (grep confirmed); pad0 keeps the stride unchanged
// rather than reshuffling every offset downstream in the HLSL mirror.
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
public float pad0;
public float pad1;
public float pad2;
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
public float pad3;
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
public const int Stride = 4 * 16;
}
[StructLayout(LayoutKind.Sequential)]
public struct DualEdge
{
public uint nbr; // neighbouring cell index
public Vector2 mid; // midpoint of the shared edge, panel space
public Vector2 dir; // unit direction along the shared edge
public float len; // shared edge length
public float w; // finite-volume Laplacian weight: len / |c_i - c_j|
public const int Stride = 4 * 7;
}
Fix D17/D20/D21 and the pool-rebuild trap in ShieldPanel (Cut 3) D17: KInject/KWaveStep/KFracture gate on growth, which only KUpdate writes and only after this same frame's injection dispatch, so a panel struck the instant it spawns swallowed its own creating hit. ResetSim now seeds bState directly from a precomputed per-cell arm state (backdated by one growDuration) instead of leaving KInit's zero in place, so the strike centre reads as already grown. D20: _PanelSpawnTime was hardcoded to 0f every frame; it now tracks each panel's own backdated arm instant, so growth keeps advancing correctly on the frames after spawn instead of collapsing back under the gate. Pool-rebuild trap (not map-numbered, but load-bearing for the whole pool argument in §2.4): OnEnable unconditionally rebuilt and OnDisable unconditionally released, so every SetActive cycle a pooled panel goes through paid the 1.3-21ms Rebuild cost the pool exists to avoid. OnEnable now only rebuilds when not yet built; release moves to OnDestroy. ResetSim is also self-healing (builds if not yet built) rather than depending on OnEnable's timing, which a batchmode probe showed is not synchronous with Object.Instantiate in every context this runs in. D21: Penrose's iteration count was CeilToInt(...)+1, always overshooting the requested cellSize by a full extra phi-subdivision. Rounding to the nearest achievable iteration count instead brings Penrose within 2-5x of Hex's cell count at the same radius/cellSize, down from an order of magnitude. TilingBuilder.cs gains a CellState mirror struct (matching ShieldCommon.hlsl's CellState field-for-field) so ResetSim can write growth/temper/breakTime directly and the Cut 3 verification probe can read them back without a new readback path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:51:11 +02:00
/// <summary>
/// C# mirror of CellState in ShieldCommon.hlsl — field-for-field, same order, same 16-float
/// stride as `bState`'s allocation in ShieldPanel.cs. Cut 3 (docs/shield-panel-cut.md) needs
/// this on both sides of the boundary: ShieldPanel.ResetSim writes it directly to seed each
/// cell's `growth` at arm time (the D17/D20 fix — see the comment on ResetSim), and the Cut 3
/// batchmode probe reads it back to verify same-frame injection and reuse-driven temper erosion
/// without adding a readback path the running sim doesn't already need.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct CellState
{
public Vector3 vel;
public float spin;
public Vector3 pos;
public float rot;
public float damage;
public float breakTime;
public float glow;
public float growth;
public float temper;
public float released;
public float peakTension;
public float pad2;
public const int Stride = 4 * 16;
}
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
/// <summary>Raw output of a tiling generator: a bag of convex polygons, wound CCW.</summary>
public class PolygonSoup
{
public readonly List<Vector2[]> polys = new();
public readonly List<int> types = new();
public void Add(Vector2[] p, int type = 0)
{
if (p.Length < 3) return;
if (SignedArea(p) < 0f) Array.Reverse(p); // force CCW
polys.Add(p);
types.Add(type);
}
public static float SignedArea(IList<Vector2> p)
{
float a = 0f;
for (int i = 0, n = p.Count; i < n; i++)
{
Vector2 u = p[i], v = p[(i + 1) % n];
a += u.x * v.y - v.x * u.y;
}
return a * 0.5f;
}
public static Vector2 Centroid(IList<Vector2> p)
{
float a = 0f; Vector2 c = Vector2.zero;
for (int i = 0, n = p.Count; i < n; i++)
{
Vector2 u = p[i], v = p[(i + 1) % n];
float cr = u.x * v.y - v.x * u.y;
a += cr; c += (u + v) * cr;
}
if (Mathf.Abs(a) < 1e-9f)
{
c = Vector2.zero;
foreach (var q in p) c += q;
return c / p.Count;
}
return c / (3f * a);
}
}
/// <summary>Welded, adjacency-resolved tiling ready for GPU upload.</summary>
public class TilingData
{
public Vector2[] verts; // flat pool, each cell's ring is contiguous
public CellStatic[] cells;
public DualEdge[] dualEdges; // flat pool, each cell's arcs are contiguous
public Bounds bounds;
public int CellCount => cells.Length;
public int EdgeCount => dualEdges.Length;
}
public static class TilingBuilder
{
/// <summary>
/// Welds coincident vertices, discards cells outside the clip radius, resolves the dual
/// (cell adjacency) graph, and packs everything into flat GPU-friendly arrays.
/// </summary>
public static TilingData Build(PolygonSoup soup, float weldEps = 1e-3f,
float clipRadius = float.PositiveInfinity,
Vector2 wavefrontOrigin = default,
float wavefrontSpeed = 6f,
float delayJitter = 0.08f,
int seed = 12345)
{
var weld = new VertexWelder(weldEps);
// ---- pass 1: keep cells whose centroid is inside the clip disc, weld their rings
var keptRings = new List<int[]>();
var keptTypes = new List<int>();
for (int c = 0; c < soup.polys.Count; c++)
{
var poly = soup.polys[c];
if (poly.Length > Limits.MaxVerts) continue; // silently drop; raise MaxVerts if this bites
Vector2 ctr = PolygonSoup.Centroid(poly);
if (ctr.sqrMagnitude > clipRadius * clipRadius) continue;
var ring = new int[poly.Length];
bool degenerate = false;
for (int i = 0; i < poly.Length; i++)
{
ring[i] = weld.Get(poly[i]);
for (int j = 0; j < i; j++) if (ring[j] == ring[i]) degenerate = true;
}
if (degenerate) continue;
keptRings.Add(ring);
keptTypes.Add(soup.types[c]);
}
int cellCount = keptRings.Count;
if (cellCount == 0) throw new InvalidOperationException("Tiling produced no cells.");
// ---- pass 2: undirected edge -> incident cells (at most two for a manifold tiling)
var edgeMap = new Dictionary<long, (int a, int b)>(cellCount * 4);
for (int c = 0; c < cellCount; c++)
{
var ring = keptRings[c];
for (int i = 0; i < ring.Length; i++)
{
long k = EdgeKey(ring[i], ring[(i + 1) % ring.Length]);
if (edgeMap.TryGetValue(k, out var e))
edgeMap[k] = (e.a, e.b < 0 ? c : e.b); // third incidence would be non-manifold; ignore
else
edgeMap[k] = (c, -1);
}
}
// Centroids must exist before arcs, since Laplacian weights need
// the distance between the two cells' centroids.
var ctrs = new Vector2[cellCount];
var areas = new float[cellCount];
for (int c = 0; c < cellCount; c++)
{
var ring = keptRings[c];
var pts = new Vector2[ring.Length];
for (int i = 0; i < ring.Length; i++) pts[i] = weld.Point(ring[i]);
ctrs[c] = PolygonSoup.Centroid(pts);
areas[c] = Mathf.Abs(PolygonSoup.SignedArea(pts));
}
// ---- pass 3: build directed dual arcs per cell
var rng = new System.Random(seed);
var vertPool = new List<Vector2>(cellCount * 5);
var edgePool = new List<DualEdge>(cellCount * 5);
var cells = new CellStatic[cellCount];
var bMin = new Vector2(float.MaxValue, float.MaxValue);
var bMax = new Vector2(float.MinValue, float.MinValue);
for (int c = 0; c < cellCount; c++)
{
var ring = keptRings[c];
int n = ring.Length;
int vStart = vertPool.Count;
Vector2 ctr = ctrs[c];
for (int i = 0; i < n; i++) vertPool.Add(weld.Point(ring[i]));
float radius = 0f;
for (int i = 0; i < n; i++)
{
Vector2 p = vertPool[vStart + i];
radius = Mathf.Max(radius, (p - ctr).magnitude);
bMin = Vector2.Min(bMin, p); bMax = Vector2.Max(bMax, p);
}
int eStart = edgePool.Count;
for (int i = 0; i < n; i++)
{
int ia = ring[i], ib = ring[(i + 1) % n];
var pair = edgeMap[EdgeKey(ia, ib)];
int other = pair.a == c ? pair.b : pair.a;
Vector2 pa = weld.Point(ia), pb = weld.Point(ib);
Vector2 d = pb - pa; float len = d.magnitude;
if (len < 1e-6f) continue;
Vector2 mid = (pa + pb) * 0.5f;
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 (operator, 2026-09-18): the emitters projecting the panel hold its
// rim, so an unshared edge on the panel boundary is not a free surface —
// it carries no ghost term and generates no reflected tension. A broken
// cell's faces are a different case entirely: KWaveStep's per-edge `solid`
// check still treats a broken neighbour as free, so the dicing cascade
// (reflection off actual damage) is untouched by this.
if (other < 0) continue;
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 nbrDist = Mathf.Max((ctrs[other] - ctr).magnitude, 1e-4f);
edgePool.Add(new DualEdge
{
nbr = (uint)other,
mid = mid,
dir = d / len,
len = len,
w = len / nbrDist
});
}
float dist = (ctr - wavefrontOrigin).magnitude;
cells[c] = new CellStatic
{
centroid = ctr,
radius = radius,
spawnDelay = dist / Mathf.Max(wavefrontSpeed, 1e-3f)
+ (float)rng.NextDouble() * delayJitter,
vertStart = (uint)vStart,
vertCount = (uint)n,
nbrStart = (uint)eStart,
nbrCount = (uint)(edgePool.Count - eStart),
toughness = 0.8f + (float)rng.NextDouble() * 0.4f,
tileType = (uint)Mathf.Max(0, keptTypes[c]),
splitSeed = (uint)rng.Next(int.MinValue, int.MaxValue),
area = Mathf.Max(areas[c], 1e-6f),
pad0 = 0f,
pad1 = 0f,
pad2 = 0f
};
}
var data = new TilingData
{
verts = vertPool.ToArray(),
cells = cells,
dualEdges = edgePool.ToArray()
};
Vector2 size = bMax - bMin;
data.bounds = new Bounds((bMin + bMax) * 0.5f, new Vector3(size.x, size.y, 0.1f));
return data;
}
static long EdgeKey(int a, int b) => a < b ? ((long)a << 32) | (uint)b : ((long)b << 32) | (uint)a;
// ------------------------------------------------------------------
// Dual tiling: one dual cell per interior primal vertex, its corners
// being the centroids of the primal cells around that vertex.
// ------------------------------------------------------------------
public static PolygonSoup TakeDual(PolygonSoup soup, float weldEps = 1e-3f)
{
var weld = new VertexWelder(weldEps);
var rings = new List<int[]>();
var centroids = new List<Vector2>();
for (int c = 0; c < soup.polys.Count; c++)
{
var poly = soup.polys[c];
var ring = new int[poly.Length];
for (int i = 0; i < poly.Length; i++) ring[i] = weld.Get(poly[i]);
rings.Add(ring);
centroids.Add(PolygonSoup.Centroid(poly));
}
// vertex -> incident cells, and vertex -> boundary flag
var incident = new Dictionary<int, List<int>>();
var degree = new Dictionary<int, int>();
var edgeCount = new Dictionary<long, int>();
for (int c = 0; c < rings.Count; c++)
{
var ring = rings[c];
for (int i = 0; i < ring.Length; i++)
{
if (!incident.TryGetValue(ring[i], out var lst)) incident[ring[i]] = lst = new List<int>();
lst.Add(c);
long k = EdgeKey(ring[i], ring[(i + 1) % ring.Length]);
edgeCount[k] = edgeCount.TryGetValue(k, out int n) ? n + 1 : 1;
}
}
// a vertex is interior only if every edge touching it is shared by two cells
var onBoundary = new HashSet<int>();
foreach (var kv in edgeCount)
if (kv.Value < 2)
{
onBoundary.Add((int)(kv.Key >> 32));
onBoundary.Add((int)(kv.Key & 0xffffffff));
}
var dual = new PolygonSoup();
foreach (var kv in incident)
{
if (onBoundary.Contains(kv.Key)) continue;
var cellsAround = kv.Value;
if (cellsAround.Count < 3) continue;
Vector2 hub = weld.Point(kv.Key);
var pts = new List<Vector2>(cellsAround.Count);
foreach (int c in cellsAround) pts.Add(centroids[c]);
pts.Sort((u, v) =>
Mathf.Atan2(u.y - hub.y, u.x - hub.x).CompareTo(
Mathf.Atan2(v.y - hub.y, v.x - hub.x)));
dual.Add(pts.ToArray(), cellsAround.Count); // type = vertex figure order
}
return dual;
}
// ------------------------------------------------------------------
// Merge triangle pairs into rhombs where the union is equilateral.
// Turns a Penrose half-rhomb (Robinson triangle) soup into P3 rhombs.
// ------------------------------------------------------------------
public static PolygonSoup MergeRhombs(PolygonSoup soup, float weldEps = 1e-3f, float tol = 0.04f)
{
var weld = new VertexWelder(weldEps);
int n = soup.polys.Count;
var rings = new int[n][];
for (int c = 0; c < n; c++)
{
var poly = soup.polys[c];
rings[c] = new int[poly.Length];
for (int i = 0; i < poly.Length; i++) rings[c][i] = weld.Get(poly[i]);
}
var edgeMap = new Dictionary<long, (int a, int b)>();
for (int c = 0; c < n; c++)
{
if (rings[c].Length != 3) continue;
for (int i = 0; i < 3; i++)
{
long k = EdgeKey(rings[c][i], rings[c][(i + 1) % 3]);
edgeMap[k] = edgeMap.TryGetValue(k, out var e) ? (e.a, c) : (c, -1);
}
}
var used = new bool[n];
var outSoup = new PolygonSoup();
foreach (var kv in edgeMap)
{
var (ca, cb) = kv.Value;
if (cb < 0 || used[ca] || used[cb]) continue;
if (rings[ca].Length != 3 || rings[cb].Length != 3) continue;
if (soup.types[ca] != soup.types[cb]) continue;
int p = (int)(kv.Key >> 32), q = (int)(kv.Key & 0xffffffff);
int xa = Opposite(rings[ca], p, q), xb = Opposite(rings[cb], p, q);
if (xa < 0 || xb < 0) continue;
Vector2 P = weld.Point(p), Q = weld.Point(q), A = weld.Point(xa), B = weld.Point(xb);
float e0 = (A - P).magnitude, e1 = (Q - A).magnitude,
e2 = (B - Q).magnitude, e3 = (P - B).magnitude;
float avg = 0.25f * (e0 + e1 + e2 + e3);
if (avg < 1e-5f) continue;
float dev = (Mathf.Abs(e0 - avg) + Mathf.Abs(e1 - avg)
+ Mathf.Abs(e2 - avg) + Mathf.Abs(e3 - avg)) / (4f * avg);
if (dev > tol) continue; // not a rhombus, leave them alone
outSoup.Add(new[] { P, A, Q, B }, soup.types[ca]);
used[ca] = used[cb] = true;
}
for (int c = 0; c < n; c++)
if (!used[c]) outSoup.Add((Vector2[])soup.polys[c].Clone(), soup.types[c]);
return outSoup;
}
static int Opposite(int[] tri, int p, int q)
{
for (int i = 0; i < 3; i++) if (tri[i] != p && tri[i] != q) return tri[i];
return -1;
}
}
/// <summary>Grid-bucketed vertex welder. O(1) lookup, tolerant to floating point drift.</summary>
public class VertexWelder
{
readonly float eps, inv;
readonly Dictionary<(int, int), List<int>> buckets = new();
readonly List<Vector2> points = new();
public VertexWelder(float eps) { this.eps = eps; inv = 1f / eps; }
public Vector2 Point(int i) => points[i];
public int Count => points.Count;
public int Get(Vector2 p)
{
int gx = Mathf.FloorToInt(p.x * inv), gy = Mathf.FloorToInt(p.y * inv);
float e2 = eps * eps;
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++)
if (buckets.TryGetValue((gx + dx, gy + dy), out var lst))
foreach (int i in lst)
if ((points[i] - p).sqrMagnitude <= e2) return i;
int id = points.Count;
points.Add(p);
if (!buckets.TryGetValue((gx, gy), out var b)) buckets[(gx, gy)] = b = new List<int>();
b.Add(id);
return id;
}
}
}