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>
443 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|