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>
This commit is contained in:
Meta Vens 2026-09-18 06:08:04 +02:00
commit a935b965c9
13 changed files with 2157 additions and 0 deletions

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8003975577559f64cb70e6aff23e4c9d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,134 @@
#ifndef SHIELD_COMMON_INCLUDED
#define SHIELD_COMMON_INCLUDED
#define MAX_VERTS 8 // must match Limits.MaxVerts in TilingBuilder.cs
#define TAU 6.28318530718
#define PI 3.14159265359
// ---- fracture pattern ids (must match FracturePattern in ShieldPanel.cs)
#define PAT_RADIAL 0
#define PAT_STAR 1
#define PAT_LANCE 2
#define PAT_SPIRAL 3
#define PAT_CRYSTAL 4
#define PAT_FIZZLE 5
struct CellStatic
{
float2 center;
float radius;
float spawnDelay;
uint vertStart;
uint vertCount;
uint nbrStart;
uint nbrCount;
float toughness;
uint tileType;
uint splitSeed;
float area; // Laplacian denominator
float openW; // free-surface ghost weight (rim edges)
float pad0;
float pad1;
float pad2;
};
struct DualEdge
{
uint nbr;
float2 mid;
float2 dir;
float len;
float w; // finite-volume weight, len / |c_i - c_j|
};
struct CellState
{
float3 vel;
float spin;
float3 pos; // displacement from rest, panel space (z = out of plane)
float rot;
float damage; // 0..1 persistent
float breakTime; // <0 while intact
float glow; // transient crack emission
float growth; // 0..1 materialisation
float temper; // remaining surface compression — the "charge" that resists initiation
float released; // stored energy dumped at break; sets fragment fineness
float peakTension; // running max, for visualisation
float pad2;
};
// ------------------------------------------------------------------
// hashing
// ------------------------------------------------------------------
uint Hash(uint x)
{
x ^= x >> 16; x *= 0x7feb352du;
x ^= x >> 15; x *= 0x846ca68bu;
x ^= x >> 16; return x;
}
float Hash01(uint x) { return Hash(x) * (1.0 / 4294967296.0); }
float2 Hash2(uint x) { return float2(Hash01(x), Hash01(x ^ 0x68bc21ebu)); }
// ------------------------------------------------------------------
// shard partitioning
//
// A cell is drawn as a fan of vertCount triangles around its centroid.
// A shard is a contiguous run of those triangles. Split boundaries are
// derived from the cell seed, so every shader agrees without storage.
// ------------------------------------------------------------------
// Fragment count scales with the stored elastic energy released at the moment
// of breaking -- the same reason a highly tempered pane dices into gravel while
// an annealed one splits into a few big shards. `released` is 0 for a cell that
// is merely cracked and not yet broken, so the chords appear before separation.
uint ShardCount(uint seed, float damage, float released, uint vertCount,
float crackStart, uint maxShards)
{
if (damage <= crackStart) return 0;
float t = saturate(max(damage - crackStart, 0.0) / max(1.0 - crackStart, 1e-4));
t = saturate(t * 0.4 + released * 1.2);
uint hi = min(vertCount, maxShards);
if (hi < 2) return 0;
uint n = (uint)round(lerp(2.0, (float)hi, t * (0.55 + 0.45 * Hash01(seed))));
return clamp(n, 2u, hi);
}
// Returns the shard index for fan triangle `tri`; startTri receives the
// first triangle of that shard (used as the shard's motion reference).
uint ShardOf(uint seed, uint nSplits, uint vertCount, uint tri, out uint startTri)
{
uint count = 0, prevB = 0, maxB = 0;
[loop] for (uint k = 0; k < nSplits; k++)
{
uint b = Hash(seed ^ (k * 0x9E3779B9u + 0x2545F491u)) % max(vertCount, 1u);
maxB = max(maxB, b);
if (b <= tri) { count++; prevB = max(prevB, b); }
}
startTri = (count == 0) ? maxB : prevB;
return (count == 0) ? (nSplits - 1) : (count - 1);
}
// Is fan edge `ei` (the radial chord from centroid to vertex ei) a shard boundary?
bool IsChord(uint seed, uint nSplits, uint vertCount, uint ei)
{
[loop] for (uint k = 0; k < nSplits; k++)
if ((Hash(seed ^ (k * 0x9E3779B9u + 0x2545F491u)) % max(vertCount, 1u)) == ei) return true;
return false;
}
// ------------------------------------------------------------------
// easing
// ------------------------------------------------------------------
float EaseOutBack(float t)
{
float u = t - 1.0;
return 1.0 + 2.70158 * u * u * u + 1.70158 * u * u;
}
float EaseOutCubic(float t) { float u = 1.0 - t; return 1.0 - u * u * u; }
float2 Rot2(float2 p, float a)
{
float s = sin(a), c = cos(a);
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
}
#endif

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 28966e181234948439910021e253326f
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,439 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace ShieldField
{
public enum FracturePattern { Radial = 0, Star = 1, Lance = 2, Spiral = 3, Crystal = 4, Fizzle = 5 }
public class ShieldPanel : MonoBehaviour
{
// ---------------- authoring ----------------
[Header("Tiling")]
public TilingKind tiling = TilingKind.PenroseP3;
public float panelRadius = 5f;
public float cellSize = 0.35f;
public bool mergeRhombs = true; // Penrose triangles -> P3 rhombs
public bool takeDual = false; // render the dual tiling instead
public int seed = 1337;
[Header("Assets")]
public ComputeShader sim;
public Shader panelShader;
[Header("Materialisation")]
public float growDuration = 0.35f;
public float wavefrontSpeed = 8f;
public float delayJitter = 0.06f;
[Header("Wave")]
[Tooltip("c^2. Raise for a faster shock; watch the CFL warning in the console.")]
public float waveSpeed2 = 26f;
public float damping = 1.6f;
public float stressClamp = 4f;
[Range(1, 16)] public int substeps = 6;
public float fixedSubstepDt = 0.0025f;
[Header("Through-thickness echo")]
[Tooltip("Substeps for one round trip through the plate. Small = fast back-face spall.")]
[Range(1, 15)] public int echoDelaySteps = 5;
[Tooltip("Negative: a free back face returns the compressive pulse as tension.")]
public float echoReflect = -0.65f;
[Header("Material (tempered)")]
public float tensileStrength = 0.9f;
[Tooltip("Surface compression at full charge. This is the shield's hit budget.")]
public float temperInit = 0.55f;
public float temperErosion = 2.2f;
[Tooltip("How much stored core energy a break dumps back into the field.")]
public float storedEnergyGain = 0.8f;
public float damageGain = 6f;
[Range(0.05f, 1f)] public float breakAt = 0.75f;
[Range(0f, 0.9f)] public float crackStart = 0.18f;
[Tooltip("Breaks permitted per frame. Without this the cascade finishes in one frame and you see nothing.")]
[Range(0.001f, 1f)] public float breakBudgetFraction = 0.02f;
[Header("Shards")]
public float radialKick = 1.6f;
public float lateralKick = 2.2f;
public float normalKick = 1.1f;
public float spinKick = 7f;
public float shardLife = 2.5f;
public float shardDrag = 0.7f;
[Range(2, 8)] public int maxShards = 6;
[Header("Render")]
public float conduitGain = 2.5f;
// ---------------- runtime ----------------
struct PendingHit
{
public Vector2 pos, dir;
public float energy, radius, obliquity, spin;
public FracturePattern pattern;
public float starArms, focus, twist;
}
readonly Queue<PendingHit> pending = new();
TilingData data;
Material mat;
MaterialPropertyBlock mpb;
GraphicsBuffer bCells, bVerts, bEdges, bState;
GraphicsBuffer bUA, bUB, bV, bEcho, bFlow, bVisible, bBudget;
GraphicsBuffer argsFill;
bool uUsingA = true;
int kInit, kInject, kWave, kEcho, kFracture, kUpdate;
int groups, echoSlot;
float simTime;
Bounds worldBounds;
uint[] budgetScratch; // cached to avoid a per-frame allocation (D12)
const int EchoSlots = 16;
static readonly int MaxV = Limits.MaxVerts;
// shader ids
static readonly int idCells = Shader.PropertyToID("_Cells");
static readonly int idVerts = Shader.PropertyToID("_Verts");
static readonly int idEdges = Shader.PropertyToID("_Edges");
static readonly int idState = Shader.PropertyToID("_State");
static readonly int idUIn = Shader.PropertyToID("_UIn");
static readonly int idUOut = Shader.PropertyToID("_UOut");
static readonly int idU = Shader.PropertyToID("_U");
static readonly int idV = Shader.PropertyToID("_V");
static readonly int idEcho = Shader.PropertyToID("_Echo");
static readonly int idFlow = Shader.PropertyToID("_EdgeFlow");
static readonly int idVisible = Shader.PropertyToID("_Visible");
static readonly int idBudget = Shader.PropertyToID("_BreakBudget");
// ==============================================================
void OnEnable() { Rebuild(); }
void OnDisable() { ReleaseAll(); }
[UnityEngine.ContextMenu("Rebuild")]
public void Rebuild()
{
ReleaseAll();
if (sim == null || panelShader == null) return;
var soup = Tilings.Generate(tiling, panelRadius, cellSize, seed);
if (tiling == TilingKind.PenroseP3 && mergeRhombs)
soup = TilingBuilder.MergeRhombs(soup);
if (takeDual)
soup = TilingBuilder.TakeDual(soup);
data = TilingBuilder.Build(soup,
weldEps: Mathf.Max(cellSize * 0.02f, 1e-4f),
clipRadius: panelRadius,
wavefrontOrigin: Vector2.zero,
wavefrontSpeed: wavefrontSpeed,
delayJitter: delayJitter,
seed: seed);
AllocateBuffers();
CacheKernels();
ResetSim();
CheckCfl();
}
void AllocateBuffers()
{
int n = data.CellCount;
groups = Mathf.CeilToInt(n / 64f);
bCells = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, CellStatic.Stride);
bVerts = new GraphicsBuffer(GraphicsBuffer.Target.Structured, data.verts.Length, sizeof(float) * 2);
bEdges = new GraphicsBuffer(GraphicsBuffer.Target.Structured, Mathf.Max(data.EdgeCount, 1), DualEdge.Stride);
bState = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float) * 16);
bUA = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float));
bUB = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float));
bV = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n, sizeof(float));
bEcho = new GraphicsBuffer(GraphicsBuffer.Target.Structured, n * EchoSlots, sizeof(float));
bFlow = new GraphicsBuffer(GraphicsBuffer.Target.Structured, Mathf.Max(data.EdgeCount, 1), sizeof(float));
bBudget = new GraphicsBuffer(GraphicsBuffer.Target.Structured, 2, sizeof(uint));
bVisible = new GraphicsBuffer(GraphicsBuffer.Target.Append | GraphicsBuffer.Target.Structured,
n, sizeof(uint));
bCells.SetData(data.cells);
bVerts.SetData(data.verts);
if (data.EdgeCount > 0) bEdges.SetData(data.dualEdges);
// One args buffer, sized to the widest pass (Outline: n*2 segments * 6 verts).
// RenderParams exposes no per-pass selector in 6000.3.24f1 (D2), and a multi-pass material
// draws every pass from one indirect call anyway (D5/§2.1.4-5), so the three
// per-pass buffers this shipped with were dead weight, not three draw paths.
argsFill = MakeArgs((uint)(MaxV * 2 * 6));
mat = new Material(panelShader) { hideFlags = HideFlags.DontSave };
mpb = new MaterialPropertyBlock();
var b = data.bounds;
float pad = panelRadius * 1.5f + shardLife * 3f;
worldBounds = new Bounds(transform.TransformPoint(b.center),
b.size + Vector3.one * pad);
budgetScratch ??= new uint[2];
}
static GraphicsBuffer MakeArgs(uint vertsPerInstance)
{
var a = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, 1,
GraphicsBuffer.IndirectDrawArgs.size);
a.SetData(new[] { new GraphicsBuffer.IndirectDrawArgs {
vertexCountPerInstance = vertsPerInstance,
instanceCount = 0, startVertex = 0, startInstance = 0 } });
return a;
}
void CacheKernels()
{
kInit = sim.FindKernel("KInit");
kInject = sim.FindKernel("KInject");
kWave = sim.FindKernel("KWaveStep");
kEcho = sim.FindKernel("KEcho");
kFracture = sim.FindKernel("KFracture");
kUpdate = sim.FindKernel("KUpdate");
foreach (int k in new[] { kInit, kInject, kWave, kEcho, kFracture, kUpdate })
{
sim.SetBuffer(k, idCells, bCells);
sim.SetBuffer(k, idEdges, bEdges);
sim.SetBuffer(k, idState, bState);
sim.SetBuffer(k, idV, bV);
sim.SetBuffer(k, idEcho, bEcho);
sim.SetBuffer(k, idFlow, bFlow);
sim.SetBuffer(k, idBudget, bBudget);
}
sim.SetBuffer(kInit, idUOut, bUA);
sim.SetBuffer(kWave, idFlow, bFlow);
sim.SetBuffer(kUpdate, idVisible, bVisible);
}
[UnityEngine.ContextMenu("Reset Simulation")]
public void ResetSim()
{
if (sim == null || data == null) return;
simTime = 0f;
echoSlot = 0;
uUsingA = true;
pending.Clear();
sim.SetInt("_CellCount", data.CellCount);
sim.SetFloat("_TemperInit", temperInit);
sim.SetBuffer(kInit, idUOut, bUA);
sim.Dispatch(kInit, groups, 1, 1);
// second clear so both halves of the ping-pong start at rest
sim.SetBuffer(kInit, idUOut, bUB);
sim.Dispatch(kInit, groups, 1, 1);
sim.SetBuffer(kInit, idUOut, bUA);
}
void CheckCfl()
{
float minArea = float.MaxValue;
int maxDeg = 0;
foreach (var c in data.cells)
{
minArea = Mathf.Min(minArea, c.area);
maxDeg = Mathf.Max(maxDeg, (int)c.nbrCount);
}
// rough stability bound for the explicit leapfrog on an irregular graph
float lim = waveSpeed2 * fixedSubstepDt * fixedSubstepDt * maxDeg / Mathf.Max(minArea, 1e-6f);
if (lim > 1.8f)
Debug.LogWarning($"[ShieldPanel] CFL estimate {lim:F2} exceeds ~1.8 and the wave " +
$"will likely diverge. Lower waveSpeed2 or fixedSubstepDt, or raise cellSize. " +
$"(smallest cell area {minArea:E2}, max degree {maxDeg})", this);
}
// ==============================================================
/// <summary>Strike the panel. worldDir is the projectile's travel direction.</summary>
public void Hit(Vector3 worldPos, Vector3 worldDir, float energy,
FracturePattern pattern = FracturePattern.Radial,
float radius = -1f)
{
if (data == null) return;
Vector3 local = transform.InverseTransformPoint(worldPos);
Vector3 ldir = transform.InverseTransformDirection(worldDir).normalized;
Vector2 flat = new Vector2(ldir.x, ldir.y);
float obliquity = Mathf.Clamp01(1f - Mathf.Abs(ldir.z));
pending.Enqueue(new PendingHit
{
pos = new Vector2(local.x, local.y),
dir = flat.sqrMagnitude > 1e-6f ? flat.normalized : Vector2.right,
energy = energy,
radius = radius > 0f ? radius : Mathf.Max(cellSize * 2.5f, panelRadius * 0.09f),
obliquity = obliquity,
pattern = pattern,
starArms = Random.Range(3, 9),
focus = Mathf.Lerp(0.15f, 0.55f, Random.value),
twist = Random.Range(0.4f, 1.6f),
spin = Random.value * Mathf.PI * 2f
});
}
// ==============================================================
void Update()
{
if (sim == null || data == null || mat == null) return;
float dt = Application.isPlaying ? Time.deltaTime : 1f / 60f;
simTime += dt;
sim.SetInt("_CellCount", data.CellCount);
sim.SetFloat("_Time", simTime);
PushMaterialUniforms();
// ---- break budget resets every frame; this is what paces the cascade
uint cap = (uint)Mathf.Max(1, Mathf.RoundToInt(data.CellCount * breakBudgetFraction));
budgetScratch[0] = 0u;
budgetScratch[1] = cap;
bBudget.SetData(budgetScratch);
// ---- inject queued hits
sim.SetFloat("_Dt", fixedSubstepDt);
while (pending.Count > 0)
{
var h = pending.Dequeue();
sim.SetVector("_HitPos", h.pos);
sim.SetVector("_HitDir", h.dir);
sim.SetFloat("_HitEnergy", h.energy);
sim.SetFloat("_HitRadius", h.radius);
sim.SetFloat("_HitObliquity", h.obliquity);
sim.SetInt("_Pattern", (int)h.pattern);
sim.SetFloat("_StarArms", h.starArms);
sim.SetFloat("_PatternFocus", h.focus);
sim.SetFloat("_SpiralTwist", h.twist);
sim.SetFloat("_HitSpin", h.spin);
sim.SetInt("_EchoSlot", echoSlot);
sim.SetBuffer(kInject, idV, bV);
sim.Dispatch(kInject, groups, 1, 1);
}
// ---- wave substeps (fixed dt: the CFL limit does not care about frame rate)
sim.SetFloat("_WaveSpeed2", waveSpeed2);
sim.SetFloat("_Damping", damping);
sim.SetFloat("_StressClamp", stressClamp);
sim.SetInt("_EchoDelay", echoDelaySteps);
sim.SetFloat("_EchoReflect", echoReflect);
for (int s = 0; s < substeps; s++)
{
var src = uUsingA ? bUA : bUB;
var dst = uUsingA ? bUB : bUA;
sim.SetBuffer(kWave, idUIn, src);
sim.SetBuffer(kWave, idUOut, dst);
sim.Dispatch(kWave, groups, 1, 1);
uUsingA = !uUsingA;
sim.SetInt("_EchoSlot", echoSlot);
sim.Dispatch(kEcho, groups, 1, 1);
echoSlot = (echoSlot + 1) % EchoSlots;
}
var cur = uUsingA ? bUA : bUB;
// ---- fracture
sim.SetFloat("_TensileStrength", tensileStrength);
sim.SetFloat("_TemperErosion", temperErosion);
sim.SetFloat("_StoredEnergyGain", storedEnergyGain);
sim.SetFloat("_DamageGain", damageGain);
sim.SetFloat("_BreakAt", breakAt);
sim.SetFloat("_CrackStart", crackStart);
sim.SetFloat("_RadialKick", radialKick);
sim.SetFloat("_LateralKick", lateralKick);
sim.SetFloat("_NormalKick", normalKick);
sim.SetFloat("_SpinKick", spinKick);
sim.SetBuffer(kFracture, idUIn, cur);
sim.Dispatch(kFracture, groups, 1, 1);
// ---- per-frame state advance + visibility
sim.SetFloat("_Dt", dt);
sim.SetFloat("_PanelSpawnTime", 0f);
sim.SetFloat("_GrowDuration", growDuration);
sim.SetFloat("_GlowDecay", 3.2f);
sim.SetFloat("_ShardLife", shardLife);
sim.SetFloat("_ShardDrag", shardDrag);
bVisible.SetCounterValue(0);
sim.Dispatch(kUpdate, groups, 1, 1);
GraphicsBuffer.CopyCount(bVisible, argsFill, sizeof(uint));
Draw(cur);
}
void PushMaterialUniforms()
{
mat.SetFloat("_CrackStart", crackStart);
mat.SetFloat("_ShardLife", shardLife);
mat.SetInt("_MaxShards", maxShards);
mat.SetFloat("_ConduitGain", conduitGain);
}
void Draw(GraphicsBuffer curU)
{
mpb.Clear();
mpb.SetBuffer(idCells, bCells);
mpb.SetBuffer(idVerts, bVerts);
mpb.SetBuffer(idEdges, bEdges);
mpb.SetBuffer(idState, bState);
mpb.SetBuffer(idU, curU);
mpb.SetBuffer(idFlow, bFlow);
mpb.SetBuffer(idVisible, bVisible);
mpb.SetMatrix("_PanelToWorld", transform.localToWorldMatrix);
mpb.SetFloat("_Time01", simTime);
worldBounds.center = transform.position;
var rp = new RenderParams(mat)
{
worldBounds = worldBounds,
matProps = mpb,
shadowCastingMode = ShadowCastingMode.Off,
receiveShadows = false,
layer = gameObject.layer
};
// RenderParams exposes no per-pass selector in 6000.3.24f1 (D2), and a multi-pass
// material draws every SubShader pass from one indirect call regardless
// (D5) — Fill, Outline and Conduit all run here, pixel-identical to the
// old three-call version (§2.1.5), off one args buffer sized to the
// widest pass (Outline).
Graphics.RenderPrimitivesIndirect(rp, MeshTopology.Triangles, argsFill, 1, 0);
}
// ==============================================================
void ReleaseAll()
{
void R(ref GraphicsBuffer b) { b?.Release(); b = null; }
R(ref bCells); R(ref bVerts); R(ref bEdges); R(ref bState);
R(ref bUA); R(ref bUB); R(ref bV); R(ref bEcho); R(ref bFlow);
R(ref bVisible); R(ref bBudget);
R(ref argsFill);
if (mat != null)
{
if (Application.isPlaying) Destroy(mat); else DestroyImmediate(mat);
mat = null;
}
data = null;
}
void OnDrawGizmosSelected()
{
Gizmos.color = new Color(0.4f, 0.8f, 1f, 0.5f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawWireSphere(Vector3.zero, panelRadius);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c1b180b85c234a4488cf0662b75ac77f

View file

@ -0,0 +1,376 @@
Shader "ShieldField/Panel"
{
Properties
{
_FillColorA ("Fill Tint A", Color) = (0.15, 0.45, 0.9, 0.28)
_FillColorB ("Fill Tint B", Color) = (0.30, 0.20, 0.8, 0.28)
_LineColor ("Outline", Color) = (0.6, 0.9, 1.0, 1)
_CrackColor ("Crack Emission", Color) = (1.0, 0.55, 0.25, 1)
_CompressColor ("Compression", Color) = (0.1, 0.3, 1.0, 1)
_TensionColor ("Tension", Color) = (1.0, 0.35, 0.15, 1)
_ConduitColor ("Conduit", Color) = (0.35, 0.8, 1.0, 1)
_LineWidthPx ("Outline px", Range(0.5, 8)) = 1.5
_CrackWidthPx ("Crack px", Range(0.5, 8)) = 1.0
_ConduitWidthPx ("Conduit px", Range(0.5, 8)) = 1.0
_FeatherPx ("Feather px", Range(0.5, 4)) = 1.2
_StrainScale ("In-plane strain", Float) = 0.35
_RippleScale ("Out-of-plane", Float) = 0.30
_StressColorGain("Stress emission", Float) = 1.6
_CrackGap ("Pre-break gap", Float) = 0.10
_ShardSpread ("Shard divergence", Float) = 1.40
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" }
Blend One One // additive; fragments output premultiplied colour
ZWrite Off
Cull Off
HLSLINCLUDE
#include "UnityCG.cginc"
#include "ShieldCommon.hlsl"
StructuredBuffer<CellStatic> _Cells;
StructuredBuffer<float2> _Verts;
StructuredBuffer<DualEdge> _Edges;
StructuredBuffer<CellState> _State;
StructuredBuffer<float> _U;
StructuredBuffer<float> _EdgeFlow;
StructuredBuffer<uint> _Visible;
float4x4 _PanelToWorld;
float _Time01; // absolute sim time; _Time.y is unreliable for indirect draws
float _CrackStart;
float _ShardLife;
uint _MaxShards;
float4 _FillColorA, _FillColorB, _LineColor, _CrackColor;
float4 _CompressColor, _TensionColor, _ConduitColor;
float _LineWidthPx, _CrackWidthPx, _ConduitWidthPx, _FeatherPx;
float _StrainScale, _RippleScale, _StressColorGain, _CrackGap, _ShardSpread;
struct Ctx
{
CellStatic c;
CellState s;
float u;
float age; // seconds since break, 0 while intact
float fade;
uint nSplits;
float fillT, lineT;
};
Ctx GetCtx(uint cell)
{
Ctx x;
x.c = _Cells[cell];
x.s = _State[cell];
x.u = _U[cell];
x.lineT = saturate(x.s.growth / 0.55);
x.fillT = saturate((x.s.growth - 0.30) / 0.70);
bool broken = x.s.breakTime >= 0.0;
x.age = broken ? max(_Time01 - x.s.breakTime, 0.0) : 0.0;
x.fade = broken ? saturate(1.0 - x.age / max(_ShardLife, 1e-3)) : 1.0;
x.nSplits = ShardCount(x.c.splitSeed, x.s.damage, x.s.released,
x.c.vertCount, _CrackStart, _MaxShards);
return x;
}
// Panel-space position of one corner of one fan triangle.
// corner 0 = centroid, 1 = vert[tri], 2 = vert[tri+1].
float3 CellPoint(Ctx x, uint tri, uint corner)
{
uint n = max(x.c.vertCount, 1u);
float2 p = (corner == 0)
? x.c.center
: _Verts[x.c.vertStart + ((tri + corner - 1) % n)];
float2 local = p - x.c.center;
// materialisation: the cell inflates from its own centroid
local *= EaseOutBack(x.fillT);
// the stress wave is visible in the geometry itself: tension dilates
// the cell, compression pinches it
local *= (1.0 + x.u * _StrainScale);
float z = x.u * _RippleScale;
if (x.nSplits > 0)
{
uint startTri;
uint sid = ShardOf(x.c.splitSeed, x.nSplits, n, tri, startTri);
float2 outward = _Verts[x.c.vertStart + startTri] - x.c.center;
outward = normalize(outward + float2(1e-5, 1e-5));
uint sSeed = x.c.splitSeed ^ (sid * 0x9E3779B9u);
float2 jit = Hash2(sSeed) - 0.5;
if (x.s.breakTime < 0.0)
{
// cracked but holding: shards ease apart just enough to open
// the chord lines
float gap = saturate((x.s.damage - _CrackStart) / 0.35);
local += outward * gap * _CrackGap * x.c.radius;
}
else
{
float2 drift = (outward + jit * 0.9) * x.age * _ShardSpread;
local = Rot2(local, x.s.rot * (0.5 + jit.y));
local += drift * x.c.radius;
z += jit.x * x.age * 0.4;
}
}
float3 pos = float3(x.c.center + local, z) + x.s.pos;
return mul(_PanelToWorld, float4(pos, 1.0)).xyz;
}
float3 StressEmission(float u, float glow)
{
float compress = saturate(-u * _StressColorGain);
float tension = saturate( u * _StressColorGain);
return _CompressColor.rgb * compress
+ _TensionColor.rgb * tension
+ _CrackColor.rgb * glow;
}
// ----------------------------------------------------------
// Screen-space line expansion. Both endpoints are projected, the
// perpendicular is taken in pixels, and the offset is scaled by w so it
// survives the perspective divide. Width is therefore exact in pixels at
// any distance or field of view.
// ----------------------------------------------------------
struct LineVary
{
float4 pos : SV_POSITION;
noperspective float dist : TEXCOORD0; // signed pixels from the line centre
float halfPx : TEXCOORD1;
float4 col : TEXCOORD2;
};
LineVary BuildLine(float3 wa, float3 wb, uint k, float widthPx, float4 col)
{
LineVary o;
float4 ca = UnityWorldToClipPos(wa);
float4 cb = UnityWorldToClipPos(wb);
// behind the near plane on either end: collapse to a zero-area tri
if (ca.w <= 1e-4 || cb.w <= 1e-4 || col.a <= 0.001)
{
o.pos = float4(0, 0, 0, 1);
o.dist = 0; o.halfPx = 1; o.col = 0;
return o;
}
float2 halfRes = _ScreenParams.xy * 0.5;
float2 sa = ca.xy / ca.w * halfRes;
float2 sb = cb.xy / cb.w * halfRes;
float2 d = sb - sa;
float L = length(d);
d = (L > 1e-5) ? d / L : float2(1, 0);
float2 nrm = float2(-d.y, d.x);
float hw = widthPx * 0.5 + _FeatherPx;
float ext = hw; // overshoot the ends so joins close
// 6 vertices: (end, side) pairs forming two triangles
static const uint2 QUAD[6] = { uint2(0,0), uint2(1,0), uint2(0,1),
uint2(0,1), uint2(1,0), uint2(1,1) };
uint2 q = QUAD[k];
float side = q.y * 2.0 - 1.0;
float along = q.x * 2.0 - 1.0;
float4 cp = q.x ? cb : ca;
float2 offPx = nrm * side * hw + d * along * ext;
cp.xy += offPx / halfRes * cp.w;
o.pos = cp;
o.dist = side * hw;
o.halfPx = widthPx * 0.5;
o.col = col;
return o;
}
float4 ShadeLine(LineVary i) : SV_Target
{
float a = 1.0 - smoothstep(i.halfPx - _FeatherPx * 0.5,
i.halfPx + _FeatherPx * 0.5, abs(i.dist));
a *= i.col.a;
clip(a - 0.002);
return float4(i.col.rgb * a, a);
}
ENDHLSL
// ==========================================================
// Pass 0 — fill
// ==========================================================
Pass
{
Name "Fill"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 4.5
struct V2F
{
float4 pos : SV_POSITION;
float4 col : TEXCOORD0;
};
V2F vert(uint vid : SV_VertexID, uint iid : SV_InstanceID)
{
V2F o;
uint cell = _Visible[iid];
Ctx x = GetCtx(cell);
uint tri = vid / 3, corner = vid % 3;
if (tri >= x.c.vertCount || x.fillT <= 0.0 || x.fade <= 0.0)
{
o.pos = float4(0, 0, 0, 1); o.col = 0; return o;
}
float3 w = CellPoint(x, tri, corner);
o.pos = UnityWorldToClipPos(w);
float4 base = lerp(_FillColorA, _FillColorB, (x.c.tileType & 1) ? 1.0 : 0.0);
float a = base.a * saturate(x.fillT) * x.fade;
// a depleted panel reads dimmer; temper is the visible charge
a *= lerp(0.45, 1.0, saturate(x.s.temper));
float3 rgb = base.rgb + StressEmission(x.u, x.s.glow);
o.col = float4(rgb, a);
return o;
}
float4 frag(V2F i) : SV_Target
{
clip(i.col.a - 0.002);
return float4(i.col.rgb * i.col.a, i.col.a);
}
ENDHLSL
}
// ==========================================================
// Pass 1 — outline. Perimeter edges plus the radial chords that
// separate shard groups. Chords fade in with damage well before the
// shards actually part, which is what produces the spidered-but-holding
// state between weak hits.
// ==========================================================
Pass
{
Name "Outline"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 4.5
LineVary vert(uint vid : SV_VertexID, uint iid : SV_InstanceID)
{
uint cell = _Visible[iid];
Ctx x = GetCtx(cell);
uint seg = vid / 6, k = vid % 6;
uint n = x.c.vertCount;
if (seg >= n * 2 || x.lineT <= 0.0 || x.fade <= 0.0)
return BuildLine(0, 0, k, 1, 0);
bool radial = seg >= n;
uint ei = radial ? seg - n : seg;
float3 wa, wb;
float4 col;
float width;
if (radial)
{
bool chord = (x.nSplits > 0) && IsChord(x.c.splitSeed, x.nSplits, n, ei);
float a = chord ? saturate((x.s.damage - _CrackStart) / 0.25) : 0.0;
a *= x.fade;
wa = CellPoint(x, ei, 0);
wb = CellPoint(x, ei, 1);
col = float4(_CrackColor.rgb * (1.0 + x.s.glow * 2.0), _CrackColor.a * a);
width = _CrackWidthPx;
}
else
{
// during materialisation the ring draws itself on edge by edge
float a = saturate(x.lineT * (float)n - (float)ei) * x.fade;
wa = CellPoint(x, ei, 1);
wb = CellPoint(x, ei, 2);
col = float4(_LineColor.rgb + StressEmission(x.u, x.s.glow) * 0.7,
_LineColor.a * a);
width = _LineWidthPx;
}
return BuildLine(wa, wb, k, width, col);
}
float4 frag(LineVary i) : SV_Target { return ShadeLine(i); }
ENDHLSL
}
// ==========================================================
// Pass 2 — dual-graph conduits. Centroid-to-centroid arcs lit by the
// stress gradient flowing across them, so you can watch the wave route
// through the lattice and reflect off broken regions.
// ==========================================================
Pass
{
Name "Conduit"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 4.5
float _ConduitGain;
LineVary vert(uint vid : SV_VertexID, uint iid : SV_InstanceID)
{
uint cell = _Visible[iid];
Ctx x = GetCtx(cell);
uint seg = vid / 6, k = vid % 6;
if (seg >= x.c.nbrCount || x.s.breakTime >= 0.0 || x.s.growth < 0.6)
return BuildLine(0, 0, k, 1, 0);
uint ei = x.c.nbrStart + seg;
DualEdge e = _Edges[ei];
if (e.nbr <= cell) return BuildLine(0, 0, k, 1, 0); // draw each arc once
CellState ns = _State[e.nbr];
if (ns.breakTime >= 0.0 || ns.growth < 0.6) return BuildLine(0, 0, k, 1, 0);
float flow = saturate(_EdgeFlow[ei] * _ConduitGain);
float a = flow * min(x.s.growth, ns.growth);
if (a <= 0.003) return BuildLine(0, 0, k, 1, 0);
CellStatic nc = _Cells[e.nbr];
float3 wa = mul(_PanelToWorld,
float4(x.c.center, x.u * _RippleScale, 1)).xyz + x.s.pos;
float3 wb = mul(_PanelToWorld,
float4(nc.center, _U[e.nbr] * _RippleScale, 1)).xyz + ns.pos;
float4 col = float4(_ConduitColor.rgb * (0.4 + flow * 2.0), _ConduitColor.a * a);
return BuildLine(wa, wb, k, _ConduitWidthPx, col);
}
float4 frag(LineVary i) : SV_Target { return ShadeLine(i); }
ENDHLSL
}
}
Fallback Off
}

View file

@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 6a7f034931432c2418a0f0f0b9af0b93
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,367 @@
// ShieldSim.compute
//
// Stress propagates as a wave on the dual graph of the tiling rather than as a
// diffusion. The whole spall effect rests on one convention:
//
// u > 0 is TENSION, u < 0 is COMPRESSION. An impact injects compression.
// Material fails on tension only.
//
// A free surface must hold stress at zero, which is a Dirichlet condition, and
// Dirichlet boundaries reflect with INVERTED sign. So the compressive pulse
// returns from the rim (and from the faces of already-broken cells) as tension,
// and that reflected tension is what spalls. Imposing Neumann here instead --
// which is what you get by default if you simply omit missing neighbours from
// the Laplacian -- reflects compression as compression and nothing ever breaks.
#pragma kernel KInit
#pragma kernel KInject
#pragma kernel KWaveStep
#pragma kernel KEcho
#pragma kernel KFracture
#pragma kernel KUpdate
#include "ShieldCommon.hlsl"
#define ECHO_SLOTS 16
StructuredBuffer<CellStatic> _Cells;
StructuredBuffer<DualEdge> _Edges;
RWStructuredBuffer<CellState> _State;
// wave field, ping-ponged by the host
StructuredBuffer<float> _UIn;
RWStructuredBuffer<float> _UOut;
RWStructuredBuffer<float> _V; // du/dt
RWStructuredBuffer<float> _Echo; // [cell * ECHO_SLOTS + slot] through-thickness delay line
RWStructuredBuffer<float> _EdgeFlow; // per-arc |gradient|, for conduit rendering
AppendStructuredBuffer<uint> _Visible;
RWStructuredBuffer<uint> _BreakBudget; // [0]=count this frame (atomic), [1]=cap; caps cascade rate per frame
uint _CellCount;
float _Time;
float _Dt;
// --- wave
float _WaveSpeed2; // c^2
float _Damping;
float _StressClamp;
// --- through-thickness echo
uint _EchoSlot; // current write slot, advanced by the host each substep
uint _EchoDelay; // slots to wait ~ 2 * thickness / c
float _EchoReflect; // negative: free back face inverts the pulse
// --- hit
float2 _HitPos;
float2 _HitDir; // in-plane component of the incoming direction
float _HitEnergy;
float _HitRadius;
float _HitObliquity; // 0 head-on, 1 grazing
uint _Pattern;
float _StarArms;
float _PatternFocus;
float _HitSpin;
float _SpiralTwist;
// --- material
float _TensileStrength;
float _TemperInit; // surface compression at full charge
float _TemperErosion; // how fast sub-critical tension wears the temper down
float _StoredEnergyGain; // fraction of temper released back into the field on break
float _DamageGain;
float _BreakAt;
float _CrackStart;
// --- shards
float _RadialKick;
float _LateralKick;
float _NormalKick;
float _SpinKick;
float _ShardLife;
float _ShardDrag;
// --- growth
float _PanelSpawnTime;
float _GrowDuration;
float _GlowDecay;
// ==================================================================
[numthreads(64,1,1)]
void KInit(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = (CellState)0;
s.breakTime = -1.0;
s.temper = _TemperInit;
_State[i] = s;
_UOut[i] = 0.0;
_V[i] = 0.0;
for (uint k = 0; k < ECHO_SLOTS; k++) _Echo[i * ECHO_SLOTS + k] = 0.0;
}
// ==================================================================
// Stamp the initial pulse. The pattern shapes the pulse; the material's
// own anisotropy (baked into the arc weights) decides how it spreads.
// ==================================================================
float InjectionProfile(float2 rel, float r)
{
float2 rad = (r > 1e-5) ? rel / r : float2(1, 0);
float falloff = saturate(1.0 - r / max(_HitRadius, 1e-4));
falloff = falloff * falloff;
float shape = 1.0;
switch (_Pattern)
{
case PAT_RADIAL:
break;
case PAT_STAR:
{
float ang = atan2(rad.y, rad.x) + _HitSpin;
float spoke = abs(frac(ang * _StarArms / TAU + 0.5) - 0.5) * 2.0; // 0 on a spoke
shape = lerp(1.0, _PatternFocus, spoke);
break;
}
case PAT_LANCE:
{
float a = saturate(dot(rad, _HitDir) * 0.5 + 0.5);
shape = lerp(_PatternFocus, 1.0, a * a);
break;
}
case PAT_SPIRAL:
{
float ang = atan2(rad.y, rad.x);
float target = _SpiralTwist * r;
float d = abs(frac((ang - target) / TAU * _StarArms + 0.5) - 0.5) * 2.0;
shape = lerp(1.0, _PatternFocus, d);
break;
}
case PAT_CRYSTAL:
// leave the pulse round; the grain in the arc weights does the shaping
break;
case PAT_FIZZLE:
shape = lerp(0.35, 1.0, Hash01(asuint(r * 991.0) ^ asuint(rad.x * 577.0)));
break;
}
// A grazing hit couples less energy in and skews it downrange.
float oblique = lerp(1.0, saturate(dot(rad, _HitDir) * 0.5 + 0.5) * 1.6, _HitObliquity);
return falloff * shape * oblique;
}
[numthreads(64,1,1)]
void KInject(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = _State[i];
if (s.breakTime >= 0.0 || s.growth < 0.6) return;
CellStatic c = _Cells[i];
float2 rel = c.center - _HitPos;
float r = length(rel);
if (r > _HitRadius) return;
float amp = InjectionProfile(rel, r) * _HitEnergy;
// Negative: the strike puts the material into compression. Injecting into
// velocity rather than displacement gives a propagating pulse instead of a
// standing bump.
_V[i] -= amp / max(c.area, 1e-5);
// Seed the through-thickness delay line at the current slot.
_Echo[i * ECHO_SLOTS + (_EchoSlot % ECHO_SLOTS)] -= amp;
}
// ==================================================================
// One substep of u_tt = c^2 * lap(u) - damping * u_t
// ==================================================================
[numthreads(64,1,1)]
void KWaveStep(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellStatic c = _Cells[i];
CellState st = _State[i];
float ui = _UIn[i];
// A broken cell carries no stress and reflects everything back at its neighbours.
if (st.breakTime >= 0.0 || st.growth < 0.6)
{
_UOut[i] = 0.0;
_V[i] = 0.0;
return;
}
float lap = 0.0;
float flowMax = 0.0;
for (uint k = 0; k < c.nbrCount; k++)
{
uint ei = c.nbrStart + k;
DualEdge e = _Edges[ei];
CellState ns = _State[e.nbr];
// Ghost value: an intact neighbour contributes its own stress; a broken
// one (or an ungrown one) is a free surface, so the ghost is -ui, which
// pins the face to zero and inverts the reflected pulse.
bool solid = (ns.breakTime < 0.0) && (ns.growth >= 0.6);
float uj = solid ? _UIn[e.nbr] : -ui;
lap += e.w * (uj - ui);
float f = abs(uj - ui) * e.w;
_EdgeFlow[ei] = f;
flowMax = max(flowMax, f);
}
// Rim edges have no arc at all, so their ghost term was precomputed.
lap += c.openW * (-2.0 * ui);
lap /= max(c.area, 1e-5);
float v = _V[i];
v += _WaveSpeed2 * lap * _Dt;
v *= exp(-_Damping * _Dt);
float u = ui + v * _Dt;
u = clamp(u, -_StressClamp, _StressClamp);
_UOut[i] = u;
_V[i] = v;
}
// ==================================================================
// Through-thickness reflection: the compressive pulse that entered the
// plate returns from the back face, inverted, one round trip later.
// This is the delayed tensile spike co-located with the impact, which is
// why the far face erupts a beat after the near face was struck.
// ==================================================================
[numthreads(64,1,1)]
void KEcho(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = _State[i];
if (s.breakTime >= 0.0) return;
uint base = i * ECHO_SLOTS;
uint read = (_EchoSlot + ECHO_SLOTS - (_EchoDelay % ECHO_SLOTS)) % ECHO_SLOTS;
float pulse = _Echo[base + read];
_Echo[base + read] = 0.0;
if (abs(pulse) < 1e-6) return;
_V[i] += _EchoReflect * pulse / max(_Cells[i].area, 1e-5);
}
// ==================================================================
// Fracture. Tension is the only thing that breaks anything.
//
// effective tension = u - temper
//
// temper is the tempered-glass surface compression: it must be cancelled
// before a crack can start. Sub-critical tension erodes it, which is what
// lets a sequence of weak hits eventually produce a break that no single
// one of them could. When a cell finally goes, its stored core energy is
// dumped back into the wave field, and that is the dicing cascade.
// ==================================================================
[numthreads(64,1,1)]
void KFracture(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellState s = _State[i];
if (s.breakTime >= 0.0 || s.growth < 0.6) return;
CellStatic c = _Cells[i];
float u = _UIn[i];
if (u <= 0.0) return; // compression does nothing
s.peakTension = max(s.peakTension, u);
float strength = _TensileStrength * c.toughness;
float net = u - s.temper; // must out-pull the surface compression
if (net <= 0.0)
{
// Sub-critical: wears the temper down. This is the multi-hit budget.
s.temper = max(0.0, s.temper - u * _TemperErosion * _Dt);
s.glow = max(s.glow, saturate(u / max(strength, 1e-4)) * 0.35);
_State[i] = s;
return;
}
s.damage = saturate(s.damage + (net / max(strength, 1e-4)) * _DamageGain * _Dt);
s.glow = max(s.glow, saturate(net / max(strength, 1e-4)));
s.temper = max(0.0, s.temper - u * _TemperErosion * _Dt * 3.0);
if (s.damage >= _BreakAt && net >= strength)
{
uint slot;
InterlockedAdd(_BreakBudget[0], 1u, slot);
if (slot < _BreakBudget[1]) // budget caps the cascade rate per frame
{
s.breakTime = _Time;
// Stored core energy, released. Sets fragment fineness: a panel that
// was still heavily charged dices finely, a depleted one breaks into
// a few large pieces.
s.released = saturate((s.temper + net) * _StoredEnergyGain);
float2 rel = c.center - _HitPos;
float r = max(length(rel), 1e-4);
float2 rad = rel / r;
float2 lateral = rad * _RadialKick + _HitDir * _LateralKick * _HitObliquity;
float2 j = (Hash2(c.splitSeed) - 0.5) * 0.6;
s.vel = float3(lateral + j, _NormalKick * (0.4 + net));
s.vel *= (0.5 + saturate(net / max(strength, 1e-4)));
s.spin = (Hash01(c.splitSeed ^ 0x51ed2701u) - 0.5) * _SpinKick * (0.5 + net);
// Dump the released energy back into the neighbours as tension. This
// is the feedback loop that makes the panel dice rather than chip.
for (uint k = 0; k < c.nbrCount; k++)
{
DualEdge e = _Edges[c.nbrStart + k];
_V[e.nbr] += s.released * e.w / max(_Cells[e.nbr].area, 1e-5);
}
}
}
_State[i] = s;
}
// ==================================================================
[numthreads(64,1,1)]
void KUpdate(uint3 id : SV_DispatchThreadID)
{
uint i = id.x; if (i >= _CellCount) return;
CellStatic c = _Cells[i];
CellState s = _State[i];
s.growth = saturate((_Time - _PanelSpawnTime - c.spawnDelay) / max(_GrowDuration, 1e-3));
s.glow *= exp(-_GlowDecay * _Dt);
if (s.breakTime >= 0.0)
{
float age = _Time - s.breakTime;
if (age > _ShardLife) return; // dead: not appended, not drawn
s.pos += s.vel * _Dt;
s.vel *= exp(-_ShardDrag * _Dt);
s.vel.z -= 0.6 * _Dt; // gentle settle, not real gravity
s.rot += s.spin * _Dt;
}
else if (s.growth <= 0.0)
{
_State[i] = s;
return;
}
_State[i] = s;
_Visible.Append(i);
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1d17c100121b5ba47af9c75de451a2f1
ComputeShaderImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,417 @@
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;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c12b1a6f3efce5245956dc0c796ede86

View file

@ -0,0 +1,387 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace ShieldField
{
public enum TilingKind
{
Hex, // regular hexagons
TriHex, // 3.6.3.6 — hexagons + triangles (dual: rhombille)
Square,
PenroseP3, // five-fold quasiperiodic rhombs, via substitution
Voronoi, // Lloyd-relaxed jittered lattice
VoronoiIFS // Voronoi over a chaos-game IFS attractor — fractal cell density
}
/// <summary>One affine map of an iterated function system, with its selection weight.</summary>
[Serializable]
public struct IfsMap
{
public Vector2 translate;
public float rotateDeg;
public float scale;
public float weight;
public Vector2 Apply(Vector2 p)
{
float r = rotateDeg * Mathf.Deg2Rad, c = Mathf.Cos(r), s = Mathf.Sin(r);
Vector2 q = new Vector2(p.x * c - p.y * s, p.x * s + p.y * c) * scale;
return q + translate;
}
}
public static class Tilings
{
public const float Phi = 1.6180339887f;
public static PolygonSoup Generate(TilingKind kind, float radius, float cellSize,
int seed, IfsMap[] ifs = null)
{
switch (kind)
{
case TilingKind.Hex: return Hex(radius, cellSize);
case TilingKind.TriHex: return TriHex(radius, cellSize);
case TilingKind.Square: return Square(radius, cellSize);
case TilingKind.PenroseP3: return Penrose(radius, cellSize);
case TilingKind.Voronoi: return Voronoi(JitteredSites(radius, cellSize, seed), radius, 3);
case TilingKind.VoronoiIFS: return Voronoi(ChaosGame(ifs ?? Pentaflake(), radius, cellSize, seed), radius, 1);
default: return Hex(radius, cellSize);
}
}
// ==============================================================
// Periodic lattices
// ==============================================================
public static PolygonSoup Hex(float radius, float size)
{
var soup = new PolygonSoup();
float w = size * Mathf.Sqrt(3f), h = size * 1.5f;
int nx = Mathf.CeilToInt(radius / w) + 2, ny = Mathf.CeilToInt(radius / h) + 2;
for (int j = -ny; j <= ny; j++)
for (int i = -nx; i <= nx; i++)
{
Vector2 c = new Vector2(i * w + (j & 1) * w * 0.5f, j * h);
if (c.magnitude > radius) continue;
soup.Add(Ngon(c, size, 6, 90f), 0);
}
return soup;
}
public static PolygonSoup TriHex(float radius, float size)
{
// Hexagons on a triangular lattice with gaps filled by upward/downward triangles.
var soup = new PolygonSoup();
float s = size; // hexagon circumradius
float a = s * Mathf.Sqrt(3f); // lattice spacing between hex centres
float w = a * Mathf.Sqrt(3f), h = a * 1.5f;
int nx = Mathf.CeilToInt(radius / w) + 2, ny = Mathf.CeilToInt(radius / h) + 2;
var hexCentres = new List<Vector2>();
for (int j = -ny; j <= ny; j++)
for (int i = -nx; i <= nx; i++)
{
Vector2 c = new Vector2(i * w + (j & 1) * w * 0.5f, j * h);
if (c.magnitude > radius + a) continue;
hexCentres.Add(c);
soup.Add(Ngon(c, s, 6, 90f), 0);
}
// Triangles sit in the interstices: each is bounded by three hex corners.
var weld = new VertexWelder(1e-3f);
var corners = new Dictionary<int, Vector2>();
foreach (var c in hexCentres)
for (int k = 0; k < 6; k++)
{
Vector2 p = c + Polar(s, 90f + k * 60f);
corners[weld.Get(p)] = p;
}
float triSide = s;
var seen = new HashSet<long>();
foreach (var c in hexCentres)
for (int k = 0; k < 6; k++)
{
// interstitial triangle centre lies between two adjacent hexes
Vector2 dir = Polar(1f, 60f * k);
Vector2 tc = c + dir * (a / Mathf.Sqrt(3f));
if (tc.magnitude > radius) continue;
long key = ((long)Mathf.RoundToInt(tc.x * 1000f) << 32) ^ (uint)Mathf.RoundToInt(tc.y * 1000f);
if (!seen.Add(key)) continue;
float rot = (k % 2 == 0) ? 180f : 0f;
var tri = Ngon(tc, triSide / Mathf.Sqrt(3f), 3, rot);
soup.Add(tri, 1);
}
return soup;
}
public static PolygonSoup Square(float radius, float size)
{
var soup = new PolygonSoup();
int n = Mathf.CeilToInt(radius / size) + 1;
for (int j = -n; j <= n; j++)
for (int i = -n; i <= n; i++)
{
Vector2 c = new Vector2((i + 0.5f) * size, (j + 0.5f) * size);
if (c.magnitude > radius) continue;
float h = size * 0.5f;
soup.Add(new[]{ c + new Vector2(-h,-h), c + new Vector2(h,-h),
c + new Vector2(h, h), c + new Vector2(-h, h) }, 0);
}
return soup;
}
// ==============================================================
// Penrose P3 — Robinson triangle substitution.
// Run TilingBuilder.MergeRhombs on the result to recover the rhombs.
// ==============================================================
public static PolygonSoup Penrose(float radius, float cellSize)
{
// D18 fix: the substitution count used to be a fixed authored constant,
// so the whole tiling was just uniformly rescaled to `radius` afterward —
// cell count never grew with panel size. Every other tiling here (Hex,
// TriHex, Square, Voronoi) uses cellSize to hold local cell size constant
// while radius controls how far the tiling extends; derive an iteration
// count that does the same for Penrose. Each substitution divides edge
// length by phi (the inflation factor), and the seed triangles have unit
// edges before the final `* scale` below, so solve for the iteration
// count that brings a unit edge down to approximately cellSize once
// scaled by `radius`.
int iterations = Mathf.Clamp(
Mathf.CeilToInt(Mathf.Log(Mathf.Max(radius / Mathf.Max(cellSize, 1e-4f), 1f), Phi)) + 1,
1, 12);
return Penrose(radius, iterations);
}
/// <summary>Penrose substitution with an explicit iteration count. Kept for probes
/// and callers that want to pin the level of detail directly.</summary>
public static PolygonSoup Penrose(float radius, int iterations)
{
var tris = new List<(int type, Vector2 a, Vector2 b, Vector2 c)>();
for (int i = 0; i < 10; i++)
{
Vector2 b = Polar(1f, (2 * i - 1) * 18f);
Vector2 c = Polar(1f, (2 * i + 1) * 18f);
if (i % 2 == 0) (b, c) = (c, b);
tris.Add((0, Vector2.zero, b, c));
}
for (int it = 0; it < iterations; it++)
{
var next = new List<(int, Vector2, Vector2, Vector2)>(tris.Count * 3);
foreach (var (type, A, B, C) in tris)
{
if (type == 0)
{
Vector2 P = A + (B - A) / Phi;
next.Add((0, C, P, B));
next.Add((1, P, C, A));
}
else
{
Vector2 Q = B + (A - B) / Phi;
Vector2 R = B + (C - B) / Phi;
next.Add((1, R, C, A));
next.Add((1, Q, R, B));
next.Add((0, R, Q, A));
}
}
tris = next;
}
float scale = radius / 1.0f;
var soup = new PolygonSoup();
foreach (var (type, A, B, C) in tris)
soup.Add(new[] { A * scale, B * scale, C * scale }, type);
return soup;
}
// ==============================================================
// Site generators
// ==============================================================
public static List<Vector2> JitteredSites(float radius, float spacing, int seed)
{
var rng = new System.Random(seed);
var pts = new List<Vector2>();
float w = spacing, h = spacing * 0.866f;
int nx = Mathf.CeilToInt(radius / w) + 1, ny = Mathf.CeilToInt(radius / h) + 1;
for (int j = -ny; j <= ny; j++)
for (int i = -nx; i <= nx; i++)
{
Vector2 p = new Vector2(i * w + (j & 1) * w * 0.5f, j * h);
p += new Vector2((float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f) * spacing * 0.45f;
if (p.magnitude < radius * 1.15f) pts.Add(p);
}
return pts;
}
/// <summary>Chaos game over an IFS. Cell density inherits the attractor's fractal measure.</summary>
public static List<Vector2> ChaosGame(IfsMap[] maps, float radius, float spacing, int seed)
{
int target = Mathf.Clamp(Mathf.RoundToInt(Mathf.PI * radius * radius / (spacing * spacing)), 64, 6000);
var rng = new System.Random(seed);
float total = 0f; foreach (var m in maps) total += Mathf.Max(m.weight, 1e-4f);
Vector2 p = Vector2.zero;
for (int i = 0; i < 64; i++) p = Pick(maps, rng, total).Apply(p); // burn in
// Accept points onto a grid so we never emit two sites on top of each other.
float minDist = spacing * 0.42f;
var grid = new Dictionary<(int, int), List<Vector2>>();
float inv = 1f / minDist;
var pts = new List<Vector2>(target);
for (int i = 0; i < target * 220 && pts.Count < target; i++)
{
p = Pick(maps, rng, total).Apply(p);
Vector2 q = p * radius;
if (q.magnitude > radius * 1.1f) continue;
int gx = Mathf.FloorToInt(q.x * inv), gy = Mathf.FloorToInt(q.y * inv);
bool tooClose = false;
for (int dx = -1; dx <= 1 && !tooClose; dx++)
for (int dy = -1; dy <= 1 && !tooClose; dy++)
if (grid.TryGetValue((gx + dx, gy + dy), out var lst))
foreach (var o in lst)
if ((o - q).sqrMagnitude < minDist * minDist) { tooClose = true; break; }
if (tooClose) continue;
if (!grid.TryGetValue((gx, gy), out var b)) grid[(gx, gy)] = b = new List<Vector2>();
b.Add(q);
pts.Add(q);
}
return pts;
}
static IfsMap Pick(IfsMap[] maps, System.Random rng, float total)
{
float r = (float)rng.NextDouble() * total;
foreach (var m in maps) { r -= Mathf.Max(m.weight, 1e-4f); if (r <= 0f) return m; }
return maps[maps.Length - 1];
}
/// <summary>Five-fold pentaflake attractor. Pairs well with a Penrose outline layer.</summary>
public static IfsMap[] Pentaflake()
{
float r = 1f / (1f + Phi);
var maps = new IfsMap[5];
for (int i = 0; i < 5; i++)
maps[i] = new IfsMap
{
translate = Polar(1f - r, 90f + i * 72f),
rotateDeg = 0f,
scale = r,
weight = 1f
};
return maps;
}
/// <summary>Sierpinski triangle attractor — sparse, angular density falloff.</summary>
public static IfsMap[] Sierpinski()
{
var maps = new IfsMap[3];
for (int i = 0; i < 3; i++)
maps[i] = new IfsMap
{
translate = Polar(0.5f, 90f + i * 120f),
rotateDeg = 0f,
scale = 0.5f,
weight = 1f
};
return maps;
}
// ==============================================================
// Voronoi by half-plane clipping. O(n * k) with k nearest neighbours.
// Brute-force nearest search; fine at init for a few thousand sites.
// ==============================================================
public static PolygonSoup Voronoi(List<Vector2> sites, float radius, int lloydPasses)
{
for (int pass = 0; pass < lloydPasses; pass++)
{
var moved = new List<Vector2>(sites.Count);
foreach (var cell in VoronoiCells(sites, radius))
moved.Add(cell == null ? Vector2.zero : PolygonSoup.Centroid(cell));
for (int i = 0; i < sites.Count; i++)
if (moved[i] != Vector2.zero) sites[i] = moved[i];
}
var soup = new PolygonSoup();
foreach (var cell in VoronoiCells(sites, radius))
{
if (cell != null && cell.Count >= 3 && cell.Count <= Limits.MaxVerts)
soup.Add(cell.ToArray(), cell.Count % 3);
}
return soup;
}
static IEnumerable<List<Vector2>> VoronoiCells(List<Vector2> sites, float radius)
{
const int K = 24;
float R = radius * 1.4f;
var box = new List<Vector2> { new(-R,-R), new(R,-R), new(R,R), new(-R,R) };
var near = new List<(float d, int i)>(sites.Count);
for (int i = 0; i < sites.Count; i++)
{
Vector2 s = sites[i];
near.Clear();
for (int j = 0; j < sites.Count; j++)
{
if (j == i) continue;
near.Add(((sites[j] - s).sqrMagnitude, j));
}
near.Sort((a, b) => a.d.CompareTo(b.d));
var poly = new List<Vector2>(box);
int k = Mathf.Min(K, near.Count);
for (int m = 0; m < k && poly.Count > 0; m++)
{
Vector2 t = sites[near[m].i];
Vector2 n = t - s;
float len = n.magnitude;
if (len < 1e-6f) continue;
n /= len;
float d = Vector2.Dot((s + t) * 0.5f, n);
poly = ClipHalfPlane(poly, n, d);
}
yield return poly.Count >= 3 ? poly : null;
}
}
/// <summary>Sutherland–Hodgman clip; keeps the side where dot(p, n) &lt;= d.</summary>
static List<Vector2> ClipHalfPlane(List<Vector2> poly, Vector2 n, float d)
{
var outp = new List<Vector2>(poly.Count + 2);
for (int i = 0; i < poly.Count; i++)
{
Vector2 a = poly[i], b = poly[(i + 1) % poly.Count];
float da = Vector2.Dot(a, n) - d, db = Vector2.Dot(b, n) - d;
bool ina = da <= 0f, inb = db <= 0f;
if (ina) outp.Add(a);
if (ina != inb)
{
float t = da / (da - db);
outp.Add(Vector2.Lerp(a, b, t));
}
}
return outp;
}
// ==============================================================
static Vector2 Polar(float r, float deg)
{
float a = deg * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(a) * r, Mathf.Sin(a) * r);
}
static Vector2[] Ngon(Vector2 c, float r, int n, float rotDeg)
{
var p = new Vector2[n];
for (int i = 0; i < n; i++) p[i] = c + Polar(r, rotDeg + i * 360f / n);
return p;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 72352dd87781d88499a809cfc695357c