The operator's play report on FieldShieldTest ("weak hits are very
janky, clicking outside the hex still makes it tremble and shatter,
pooling seems very rough") traced to two provable defects, not one
tuning constant:
- ShieldPanel.Hit had no bounds check at all. A point handed to it was
always enqueued and injected regardless of how far outside the
panel's own panelRadius it was.
- ShieldInterceptor.FindReusable decided reuse from a raw world-space
chord distance to the panel's stale spawn-time Position, using
PanelRadius*ReuseFraction (authored up to 2x) as the threshold. On a
non-uniformly scaled/curved envelope a chord across curvature is not
the panel's own in-plane extent, so a click clearly outside the
rendered tiling could still measure inside threshold and get routed
to Hit() -- which had nothing to reject it.
- Reusing a panel never refreshed its pooled DieAt, so a panel struck
again could still expire and get returned to the pool mid-fracture
("pooling seems very rough").
Fix: ShieldPanel.ContainsWorldPoint is now the one owner of "is this
point within my own radius" (panel-local in-plane distance vs.
panelRadius), used both as Hit's hard gate and as FindReusable's
candidacy test (with ReuseFraction, now capped at 1, narrowing but
never widening past the physical radius). Reuse now also extends
DieAt from the moment of the new hit.
Pinned in ShieldPanelCut3Verify.CheckHitOutsideRadiusDoesNotReachPanel:
a direct Hit() and a routed Absorb() outside a panel's own radius must
enqueue nothing and leave its state buffer untouched, and a genuinely
outside click opens its own panel. Deterministic (checks the pending
queue and no-op state, not temper drift) because breakBudgetFraction
paces fracture over many frames on purpose, so temper keeps drifting
from an earlier hit alone -- a drift-based check would have been
measuring that, not this rule (caught this against the check's own
first run).
All four ShieldPanelCut<N>Verify probes pass (batchmode, real GPU
device, no -nographics).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
553 lines
26 KiB
C#
553 lines
26 KiB
C#
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;
|
|
[Tooltip("D9: bounds dU/dt so a hard hit cannot pin the field in compression forever. " +
|
|
"Too low flattens the wave; too high reproduces the saturation defect.")]
|
|
public float velocityClamp = 100f;
|
|
[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.9f;
|
|
|
|
[Header("Material (tempered)")]
|
|
public float tensileStrength = 0.2f;
|
|
[Tooltip("Surface compression at full charge. This is the shield's hit budget.")]
|
|
public float temperInit = 0.55f;
|
|
public float temperErosion = 12f;
|
|
[Tooltip("How much stored core energy a break dumps back into the field.")]
|
|
public float storedEnergyGain = 1f;
|
|
public float damageGain = 100f;
|
|
[Range(0.05f, 1f)] public float breakAt = 0.08f;
|
|
[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)
|
|
|
|
// D17/D20 (Cut 3, docs/shield-panel-cut.md): the panel's own arm instant, backdated by
|
|
// one growDuration so a cell at the strike centre (spawnDelay ~= 0) is already past the
|
|
// KInject/KWaveStep/KFracture growth gate the moment it is struck. See ResetSim.
|
|
float panelSpawnTime;
|
|
CellState[] growthSeed; // cached per-cell arm state; rebuilt only when the tiling is (re)built,
|
|
// re-uploaded (not reallocated) on every ResetSim — no interception allocation.
|
|
|
|
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");
|
|
|
|
// ==============================================================
|
|
// Cut 3 (docs/shield-panel-cut.md §2.4): a pooled panel is SetActive(false)/(true) every
|
|
// time it is returned to and taken back out of Assets/Scripts/Prototype.cs's pool, which
|
|
// fires OnDisable/OnEnable on every reuse, not just once at load. The pool's entire reason
|
|
// for existing is that a rebuild is 1.3-21 ms and a ResetSim is 8-11 us (§2.4); OnEnable
|
|
// unconditionally rebuilding and OnDisable unconditionally releasing would pay the
|
|
// expensive path on every single interception, silently defeating the pool. So the tiling
|
|
// and buffers are built once (guarded by `data == null`) and released only when the
|
|
// GameObject is actually destroyed, not merely deactivated -- ShieldInterceptor.ResetSim()
|
|
// is what re-arms a reused instance (see ResetSim below), not OnEnable.
|
|
void OnEnable() { if (data == null) Rebuild(); }
|
|
void OnDisable() { }
|
|
void OnDestroy() { ReleaseAll(); }
|
|
|
|
// Cut 3 verification surface: counts real tiling builds so the pooling fix (OnEnable only
|
|
// rebuilds when data == null) can be pinned against a regression that rebuilds on every
|
|
// pooled reuse (see ShieldPanelCut3Verify.CheckPoolReuseDoesNotRebuild).
|
|
public int DebugRebuildCount { get; private set; }
|
|
|
|
[UnityEngine.ContextMenu("Rebuild")]
|
|
public void Rebuild()
|
|
{
|
|
DebugRebuildCount++;
|
|
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, CellState.Stride);
|
|
|
|
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];
|
|
BuildGrowthSeed();
|
|
}
|
|
|
|
// D17/D20: precompute the arm-time CellState for every cell once per tiling build, keyed
|
|
// only on each cell's baked `spawnDelay` (TilingBuilder.cs, itself derived from the
|
|
// wavefrontOrigin the tiling was built with -- always the panel centre, per Cut 1's note
|
|
// that one tiling serves every panel). ResetSim re-uploads this same array on every arm/
|
|
// reuse; nothing here runs at interception time (§3's "no allocation at interception").
|
|
void BuildGrowthSeed()
|
|
{
|
|
growthSeed ??= new CellState[data.CellCount];
|
|
if (growthSeed.Length != data.CellCount) growthSeed = new CellState[data.CellCount];
|
|
|
|
for (int i = 0; i < growthSeed.Length; i++)
|
|
{
|
|
growthSeed[i] = default;
|
|
growthSeed[i].breakTime = -1f;
|
|
growthSeed[i].temper = temperInit;
|
|
// Backdate the arm instant by one growDuration (panelSpawnTime, set alongside this
|
|
// in ResetSim) so a cell with spawnDelay ~= 0 -- the strike centre -- reads as
|
|
// already past the 0.6 growth gate the instant it is struck, while a cell whose
|
|
// baked spawnDelay is >= growDuration starts at 0 and grows forward normally as
|
|
// simTime advances past panelSpawnTime. This is what fixes D17 (the growth gate
|
|
// otherwise swallows the hit that created the panel, because KUpdate -- the only
|
|
// writer of `growth` -- does not run until after this same frame's KInject) without
|
|
// touching the gate itself or adding a per-interception dispatch.
|
|
growthSeed[i].growth = Mathf.Clamp01(
|
|
(growDuration - data.cells[i].spawnDelay) / Mathf.Max(growDuration, 1e-3f));
|
|
}
|
|
}
|
|
|
|
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()
|
|
{
|
|
// Cut 3: a freshly-instantiated pool instance's OnEnable is not guaranteed to have run
|
|
// by the time the interceptor calls ResetSim() on it in the same call stack (observed in
|
|
// the batchmode probe: Object.Instantiate's Awake/OnEnable are not synchronous with the
|
|
// call that created the clone in every context this runs in). ResetSim is what every
|
|
// spawn and reuse already goes through, so it -- not OnEnable's timing -- is what
|
|
// guarantees "built before struck". This costs nothing on the pooled-reuse path that
|
|
// §2.4's budget is about: data is already non-null there, so this is one null check, not
|
|
// a second rebuild.
|
|
if (data == null) { Rebuild(); return; } // Rebuild() ends by calling ResetSim() itself
|
|
if (sim == 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);
|
|
|
|
// D17/D20: KInit above just zeroed every cell's `growth` (and this panel's own local
|
|
// clock, simTime, back to 0). Overwrite with the precomputed arm-time state instead of
|
|
// leaving it at 0 -- see BuildGrowthSeed's comment for why waiting on KUpdate to catch
|
|
// up is one frame too late for a hit queued this same call. `growDuration` may have
|
|
// changed in the inspector since the seed was built, but recomputing it here would be a
|
|
// per-arm allocation-shaped cost the pool's 8-11 us budget (§2.4) has no room for;
|
|
// Rebuild (a manual, non-pooled action) is what re-derives the seed from authored
|
|
// fields, matching CheckCfl's existing must-Rebuild-to-see-it contract.
|
|
panelSpawnTime = -growDuration;
|
|
bState.SetData(growthSeed);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ==============================================================
|
|
// Read-only verification surface for the Cut 3 batchmode probe (Tests.asmdef cannot see
|
|
// Assembly-CSharp -- the map's Q7 -- so this, like Cut 1/2's probes, is what stands in for
|
|
// a unit test). Nothing outside ShieldPanel may WRITE these buffers (the authority map's
|
|
// "nothing outside ShieldPanel touches its buffers" is about writers); GraphicsBuffer.
|
|
// GetData is a read.
|
|
public int DebugCellCount => data?.CellCount ?? 0;
|
|
public GraphicsBuffer DebugStateBuffer => bState;
|
|
public GraphicsBuffer DebugVField => bV;
|
|
public Vector2 DebugCellCenter(int i) => data.cells[i].centroid;
|
|
|
|
// ==============================================================
|
|
// Cut 3 fix (operator's play report, docs/shield-panel-cut.md): the panel is the one owner
|
|
// of "is this point within my own radius" -- both the reuse decision (ShieldInterceptor.
|
|
// FindReusable) and the strike gate below (Hit) go through this so a hit outside a panel's
|
|
// own tiling can never reach it, however it got routed. `radiusScale` is clamped to at most
|
|
// 1 -- ReuseFraction's authored [0,1] range narrows the reuse catchment; it must never widen
|
|
// it past the panel's own physical radius, which is what Hit gates on.
|
|
public bool ContainsWorldPoint(Vector3 worldPoint, float radiusScale = 1f)
|
|
{
|
|
Vector3 local = transform.InverseTransformPoint(worldPoint);
|
|
float scale = Mathf.Clamp01(radiusScale);
|
|
float r = panelRadius * scale;
|
|
return local.x * local.x + local.y * local.y <= r * r;
|
|
}
|
|
|
|
/// <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;
|
|
// R (operator, playing FieldShieldTest): a hit outside the panel's own radius must not
|
|
// reach it at all -- no injection, no temper loss, no tremble. See ContainsWorldPoint.
|
|
if (!ContainsWorldPoint(worldPos)) 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.SetFloat("_VelClamp", velocityClamp);
|
|
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
|
|
// D10: KFracture dispatches once per frame while KWaveStep just integrated
|
|
// `substeps` times at fixedSubstepDt. _Dt was still holding that substep dt from
|
|
// the injection/wave setup above, so damage and temper erosion accrued at 1/substeps
|
|
// of the intended rate. Fracture's clock is the frame, not the substep — it reads
|
|
// one settled `u` per frame regardless of how many substeps produced it — so hand it
|
|
// the frame dt here. (Dispatching KFracture inside the substep loop instead would be
|
|
// the "physically honest" alternative the map names, but at up to 12 live panels
|
|
// dispatch count is the measured cost (§2.3), and fracture only needs to see the
|
|
// post-wave state once a frame, not mid-integration.)
|
|
sim.SetFloat("_Dt", dt);
|
|
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", panelSpawnTime);
|
|
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);
|
|
}
|
|
}
|
|
}
|