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

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

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

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

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

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

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

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

443 lines
18 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
// 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.
public float pad0;
public float pad1;
public float pad2;
public float pad3;
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>
/// 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;
}
/// <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;
// 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;
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;
}
}
}