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:
parent
e3c9eca2fd
commit
a935b965c9
13 changed files with 2157 additions and 0 deletions
8
Assets/Shaders/Compute/ShieldField.meta
Normal file
8
Assets/Shaders/Compute/ShieldField.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8003975577559f64cb70e6aff23e4c9d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
134
Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl
Normal file
134
Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl
Normal 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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 28966e181234948439910021e253326f
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
439
Assets/Shaders/Compute/ShieldField/ShieldPanel.cs
Normal file
439
Assets/Shaders/Compute/ShieldField/ShieldPanel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta
Normal file
2
Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c1b180b85c234a4488cf0662b75ac77f
|
||||
376
Assets/Shaders/Compute/ShieldField/ShieldPanel.shader
Normal file
376
Assets/Shaders/Compute/ShieldField/ShieldPanel.shader
Normal 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
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6a7f034931432c2418a0f0f0b9af0b93
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
367
Assets/Shaders/Compute/ShieldField/ShieldSim.compute
Normal file
367
Assets/Shaders/Compute/ShieldField/ShieldSim.compute
Normal 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1d17c100121b5ba47af9c75de451a2f1
|
||||
ComputeShaderImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
417
Assets/Shaders/Compute/ShieldField/TilingBuilder.cs
Normal file
417
Assets/Shaders/Compute/ShieldField/TilingBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta
Normal file
2
Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c12b1a6f3efce5245956dc0c796ede86
|
||||
387
Assets/Shaders/Compute/ShieldField/Tilings.cs
Normal file
387
Assets/Shaders/Compute/ShieldField/Tilings.cs
Normal 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) <= 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Shaders/Compute/ShieldField/Tilings.cs.meta
Normal file
2
Assets/Shaders/Compute/ShieldField/Tilings.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 72352dd87781d88499a809cfc695357c
|
||||
Loading…
Reference in a new issue