Aetheria/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs
Meta Vens a935b965c9 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

417 lines
17 KiB
C#

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
public float openW; // summed weight of unshared edges (free-surface ghost term)
public float pad0;
public float pad1;
public float pad2;
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;
}
/// <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;
float openW = 0f;
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;
if (other < 0)
{
// Unshared edge: a free surface. Accumulate its ghost weight so the
// wave kernel can impose stress = 0 there (Dirichlet -> sign-inverting
// reflection, which is what turns the incoming compression into the
// tension that spalls the rim).
openW += len / Mathf.Max((mid - ctr).magnitude, 1e-4f);
continue;
}
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),
openW = openW,
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;
}
}
}