diff --git a/Assets/Shaders/Compute/ShieldField.meta b/Assets/Shaders/Compute/ShieldField.meta new file mode 100644 index 000000000..3932969fb --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8003975577559f64cb70e6aff23e4c9d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl new file mode 100644 index 000000000..8213cc0a1 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl @@ -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 diff --git a/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl.meta b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl.meta new file mode 100644 index 000000000..a329d8fdb --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldCommon.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 28966e181234948439910021e253326f +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs new file mode 100644 index 000000000..949d001cc --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs @@ -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 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); + } + + // ============================================================== + /// Strike the panel. worldDir is the projectile's travel direction. + 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); + } + } +} diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta new file mode 100644 index 000000000..ce9171785 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1b180b85c234a4488cf0662b75ac77f \ No newline at end of file diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader new file mode 100644 index 000000000..490a03261 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader @@ -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 _Cells; + StructuredBuffer _Verts; + StructuredBuffer _Edges; + StructuredBuffer _State; + StructuredBuffer _U; + StructuredBuffer _EdgeFlow; + StructuredBuffer _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 +} diff --git a/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader.meta b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader.meta new file mode 100644 index 000000000..03995c8e7 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldPanel.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 6a7f034931432c2418a0f0f0b9af0b93 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/ShieldSim.compute b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute new file mode 100644 index 000000000..104c8080b --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute @@ -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 _Cells; +StructuredBuffer _Edges; +RWStructuredBuffer _State; + +// wave field, ping-ponged by the host +StructuredBuffer _UIn; +RWStructuredBuffer _UOut; +RWStructuredBuffer _V; // du/dt +RWStructuredBuffer _Echo; // [cell * ECHO_SLOTS + slot] through-thickness delay line +RWStructuredBuffer _EdgeFlow; // per-arc |gradient|, for conduit rendering +AppendStructuredBuffer _Visible; +RWStructuredBuffer _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); +} diff --git a/Assets/Shaders/Compute/ShieldField/ShieldSim.compute.meta b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute.meta new file mode 100644 index 000000000..18e309e55 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/ShieldSim.compute.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1d17c100121b5ba47af9c75de451a2f1 +ComputeShaderImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs new file mode 100644 index 000000000..2adf19b94 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using UnityEngine; + +namespace ShieldField +{ + /// Hard cap on vertices per cell. Must match MAX_VERTS in ShieldCommon.hlsl. + 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; + } + + /// Raw output of a tiling generator: a bag of convex polygons, wound CCW. + public class PolygonSoup + { + public readonly List polys = new(); + public readonly List 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 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 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); + } + } + + /// Welded, adjacency-resolved tiling ready for GPU upload. + 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 + { + /// + /// Welds coincident vertices, discards cells outside the clip radius, resolves the dual + /// (cell adjacency) graph, and packs everything into flat GPU-friendly arrays. + /// + 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(); + var keptTypes = new List(); + 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(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(cellCount * 5); + var edgePool = new List(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(); + var centroids = new List(); + + 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>(); + var degree = new Dictionary(); + var edgeCount = new Dictionary(); + + 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(); + 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(); + 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(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(); + 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; + } + } + + /// Grid-bucketed vertex welder. O(1) lookup, tolerant to floating point drift. + public class VertexWelder + { + readonly float eps, inv; + readonly Dictionary<(int, int), List> buckets = new(); + readonly List 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(); + b.Add(id); + return id; + } + } +} diff --git a/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta new file mode 100644 index 000000000..324c4dab1 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/TilingBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c12b1a6f3efce5245956dc0c796ede86 \ No newline at end of file diff --git a/Assets/Shaders/Compute/ShieldField/Tilings.cs b/Assets/Shaders/Compute/ShieldField/Tilings.cs new file mode 100644 index 000000000..4f45c34df --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/Tilings.cs @@ -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 + } + + /// One affine map of an iterated function system, with its selection weight. + [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(); + 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(); + 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(); + 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); + } + + /// Penrose substitution with an explicit iteration count. Kept for probes + /// and callers that want to pin the level of detail directly. + 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 JitteredSites(float radius, float spacing, int seed) + { + var rng = new System.Random(seed); + var pts = new List(); + 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; + } + + /// Chaos game over an IFS. Cell density inherits the attractor's fractal measure. + public static List 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>(); + float inv = 1f / minDist; + var pts = new List(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(); + 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]; + } + + /// Five-fold pentaflake attractor. Pairs well with a Penrose outline layer. + 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; + } + + /// Sierpinski triangle attractor — sparse, angular density falloff. + 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 sites, float radius, int lloydPasses) + { + for (int pass = 0; pass < lloydPasses; pass++) + { + var moved = new List(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> VoronoiCells(List sites, float radius) + { + const int K = 24; + float R = radius * 1.4f; + var box = new List { 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(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; + } + } + + /// Sutherland–Hodgman clip; keeps the side where dot(p, n) <= d. + static List ClipHalfPlane(List poly, Vector2 n, float d) + { + var outp = new List(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; + } + } +} diff --git a/Assets/Shaders/Compute/ShieldField/Tilings.cs.meta b/Assets/Shaders/Compute/ShieldField/Tilings.cs.meta new file mode 100644 index 000000000..1e0934a49 --- /dev/null +++ b/Assets/Shaders/Compute/ShieldField/Tilings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 72352dd87781d88499a809cfc695357c \ No newline at end of file