Cut 5: hold the rim, clamp v, fix fracture's clock, tune to spec
R7 (operator ruling): the panel's emitters hold its edge, so the rim is no
longer a free surface. Deletes the openW ghost term end to end -- its only
consumer in ShieldSim.compute's Laplacian, and the accumulation loop in
TilingBuilder that built it -- rather than zeroing it at runtime. A broken
cell's own faces are untouched (KWaveStep's per-edge `solid` check already
treats a broken/ungrown neighbour as free), so the dicing cascade survives.
D9: v was unclamped while u was, so a hard hit drove v far past what one
substep's u-clamp could absorb, pinning u in compression indefinitely (the
measured collapse above energy ~60). Added `_VelClamp` and clamp v to it
every substep in KWaveStep, exposed as ShieldPanel.velocityClamp (100).
D10: KFracture dispatched once per frame but read `_Dt` still holding the
substep dt from the wave loop, so damage and temper erosion accrued at
1/substeps of the intended rate. Fracture's clock is the frame it settles
once per, not the substep, so it now gets the frame dt -- one line, not the
five-extra-dispatches-per-panel alternative (dispatch count is the measured
per-panel cost at up to 12 live panels).
D15: zero-init BuildLine's output struct so the compute compiler's dataflow
analysis stops flagging every branch as a potential non-initializer.
Tuning (ShieldPanel.cs Material header): tensileStrength 0.9->0.2,
temperErosion 2.2->12, damageGain 6->100, breakAt 0.75->0.08,
storedEnergyGain 0.8->1 -- makes a solid hit dice a local cluster around
impact, a weak hit leave nothing, and repeated weak hits erode temper
substantially without breaking anything outright.
Adds ShieldPanelCut5Verify (batchmode probe, same pattern as Cuts 1-3):
pins monotonic peak tension against energy (no D9-style collapse), solid
vs. weak hit outcomes, temper erosion under repeated sub-critical hits,
breaks clustering at the impact rather than the rim (R7), and a broken
cell's neighbours breaking more often than distant cells (the cascade).
D9 and D10 mutations were manually reverted and confirmed to fail their
respective checks; R7's reintroduced-rim mutation did not reliably fail
the cluster check within this probe's energy/step budget -- noted as a
known gap rather than papered over.
The map's own operator-level check ("repeated hits on the same spot break
through on a later hit than the first") is left to play: a closing hit
landed after several prior weak hits on one live panel instance measured
a materially lower peak tension than the same energy against a fresh
panel, a reproducible multi-hit interaction this cut did not chase to
ground.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3461eec520
commit
bdb45f6521
7 changed files with 517 additions and 22 deletions
463
Assets/Scripts/Editor/ShieldPanelCut5Verify.cs
Normal file
463
Assets/Scripts/Editor/ShieldPanelCut5Verify.cs
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ShieldField;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Aetheria.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// Batchmode probe for the shield-panel Cut 5 landing (docs/shield-panel-cut.md): R7 (the rim
|
||||
/// is held, not free — the `openW` ghost term is gone), D9 (v was unclamped and pinned u in
|
||||
/// compression above ~energy 60), D10 (KFracture ran once per frame but integrated with the
|
||||
/// substep dt), and the tuning pass that makes the design's targets reachable.
|
||||
///
|
||||
/// Pins BEHAVIOUR RULES, not today's numbers, per the brief: monotonic peak tension against
|
||||
/// energy, a solid hit breaks / a weak hit does not, repeated weak hits erode temper into an
|
||||
/// eventual break, breaks cluster near the impact rather than the rim (R7's whole point), and
|
||||
/// a broken cell's neighbours are more likely to break than distant cells (the dicing cascade).
|
||||
///
|
||||
/// Same reason as Cut 3's probe: this drives ShieldPanel's actual compute dispatches, so it
|
||||
/// needs a real graphics device -- run WITHOUT -nographics, or every kernel lookup fails with
|
||||
/// "Kernel 'KInit' not found", which reads like a broken shader and is not one.
|
||||
///
|
||||
/// Run via: Unity -batchmode -projectPath <repo> -executeMethod
|
||||
/// Aetheria.EditorTools.ShieldPanelCut5Verify.Run -quit -logFile <path>
|
||||
/// </summary>
|
||||
public static class ShieldPanelCut5Verify
|
||||
{
|
||||
const string Tag = "[ShieldPanelCut5Verify]";
|
||||
|
||||
static readonly MethodInfo PanelUpdate =
|
||||
typeof(ShieldPanel).GetMethod("Update", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
if (SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.Null)
|
||||
{
|
||||
Debug.LogError($"{Tag} FAIL: no graphics device in this session. Rerun batchmode " +
|
||||
"without -nographics (see ShieldPanelCut3Verify's note).");
|
||||
EditorApplication.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
ok &= CheckMonotonicTensionSweep();
|
||||
ok &= CheckSolidHitBreaksWeakDoesNot();
|
||||
ok &= CheckRepeatedWeakHitsErodeIntoBreak();
|
||||
ok &= CheckBreaksClusterNearImpactNotRim();
|
||||
ok &= CheckNeighboursMoreLikelyToBreak();
|
||||
|
||||
Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above");
|
||||
EditorApplication.Exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
static (GameObject go, ShieldPanel panel) BuildPanel(float panelRadius = 3f, float cellSize = 0.4f)
|
||||
{
|
||||
var sim = AssetDatabase.LoadAssetAtPath<ComputeShader>(
|
||||
"Assets/Shaders/Compute/ShieldField/ShieldSim.compute");
|
||||
var shader = AssetDatabase.LoadAssetAtPath<Shader>(
|
||||
"Assets/Shaders/Compute/ShieldField/ShieldPanel.shader");
|
||||
|
||||
var go = new GameObject("Cut5Probe_Panel");
|
||||
var panel = go.AddComponent<ShieldPanel>();
|
||||
panel.tiling = TilingKind.Hex;
|
||||
panel.panelRadius = panelRadius;
|
||||
panel.cellSize = cellSize;
|
||||
panel.sim = sim;
|
||||
panel.panelShader = shader;
|
||||
panel.Rebuild();
|
||||
return (go, panel);
|
||||
}
|
||||
|
||||
static void Step(ShieldPanel panel, int n = 1)
|
||||
{
|
||||
for (int i = 0; i < n; i++) PanelUpdate.Invoke(panel, null);
|
||||
}
|
||||
|
||||
static void Strike(ShieldPanel panel, Vector2 localPos, float energy) =>
|
||||
panel.Hit(panel.transform.position + new Vector3(localPos.x, localPos.y, 0),
|
||||
Vector3.back, energy);
|
||||
|
||||
// Mirrors ShieldField.CellState's layout, kept local exactly as Cut 3's probe does (a
|
||||
// future field reorder in the production struct is this probe's business to catch).
|
||||
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
||||
struct CellStateRaw
|
||||
{
|
||||
public Vector3 vel; public float spin; public Vector3 pos; public float rot;
|
||||
public float damage; public float breakTime; public float glow; public float growth;
|
||||
public float temper; public float released; public float peakTension; public float pad2;
|
||||
}
|
||||
|
||||
static CellStateRaw[] ReadState(ShieldPanel panel)
|
||||
{
|
||||
var raw = new CellStateRaw[panel.DebugCellCount];
|
||||
panel.DebugStateBuffer.GetData(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
static Vector2[] Centers(ShieldPanel panel)
|
||||
{
|
||||
var c = new Vector2[panel.DebugCellCount];
|
||||
for (int i = 0; i < c.Length; i++) c[i] = panel.DebugCellCenter(i);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// D9: peak tension must rise monotonically (within noise) with energy over the authored
|
||||
// range, never collapse the way the unclamped-v defect measured past ~energy 60.
|
||||
// ==============================================================
|
||||
static bool CheckMonotonicTensionSweep()
|
||||
{
|
||||
float[] energies = { 5f, 10f, 20f, 30f, 45f, 60f, 80f, 100f, 140f, 180f, 240f };
|
||||
float[] peak = new float[energies.Length];
|
||||
|
||||
for (int i = 0; i < energies.Length; i++)
|
||||
{
|
||||
var (go, panel) = BuildPanel();
|
||||
try
|
||||
{
|
||||
// Isolate D9 (the wave's velocity clamp) from the fracture cascade: once cells
|
||||
// start breaking, KFracture's neighbour feedback injects extra tension that has
|
||||
// nothing to do with the wave integrator, which would confound this specific
|
||||
// check. breakAt = 2 (above damage's [0,1] range) means nothing can ever cross
|
||||
// it, so this measures pure elastic peak tension against energy.
|
||||
panel.breakAt = 2f;
|
||||
Strike(panel, Vector2.zero, energies[i]);
|
||||
Step(panel, 150);
|
||||
var st = ReadState(panel);
|
||||
peak[i] = st.Max(s => s.peakTension);
|
||||
int brokenDbg = st.Count(s => s.breakTime >= 0f);
|
||||
if (brokenDbg > 0)
|
||||
Debug.LogWarning($"{Tag} DIAG sweep E={energies[i]}: {brokenDbg} broken despite breakAt override!");
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(go); }
|
||||
}
|
||||
|
||||
string trace = string.Join(", ", energies.Zip(peak, (e, p) => $"E={e:F0}->{p:F3}"));
|
||||
Debug.Log($"{Tag} tension sweep: {trace}");
|
||||
|
||||
// This is a clamped nonlinear wave PDE sampled after a fixed settling window, not a
|
||||
// strictly monotone function -- point-to-point wobble of a few tens of percent is
|
||||
// expected numerical texture, not the defect. The defect this guards against has a
|
||||
// specific, much larger shape: the map measured tension COLLAPSING above energy ~60,
|
||||
// i.e. falling back toward the low-energy baseline once v pinned the field in
|
||||
// compression. So the rule is a running-max floor: no sample may fall below half of
|
||||
// the best result seen at any lower energy so far. That tolerates ordinary wobble
|
||||
// (worst observed in tuning: ~55% of the running max) while still catching a real
|
||||
// collapse (which drops toward the near-zero baseline, not to 50-99% of it).
|
||||
bool ok = true;
|
||||
float runningMax = 0f;
|
||||
for (int i = 0; i < peak.Length; i++)
|
||||
{
|
||||
if (i > 0 && peak[i] < runningMax * 0.5f)
|
||||
{
|
||||
Debug.LogError($"{Tag} D9 not fixed: tension collapsed at energy {energies[i]:F0} " +
|
||||
$"(peak {peak[i]:F3} vs running max {runningMax:F3}).");
|
||||
ok = false;
|
||||
}
|
||||
runningMax = Mathf.Max(runningMax, peak[i]);
|
||||
}
|
||||
float overallMax = runningMax;
|
||||
|
||||
// Negative signature from the map: no energy in range may leave the field completely
|
||||
// flat (maxPeakTension == 0), which would mean nothing is reaching the material at all.
|
||||
if (overallMax <= 1e-4f)
|
||||
{
|
||||
Debug.LogError($"{Tag} D9 negative check failed: no energy in the sweep produced any " +
|
||||
"measurable peak tension (the saturation-to-zero signature).");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok) Debug.Log($"{Tag} D9 OK: tension never falls below half its running max across the " +
|
||||
$"sweep (peak {overallMax:F3}) -- no collapse.");
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
static bool CheckSolidHitBreaksWeakDoesNot()
|
||||
{
|
||||
bool ok = true;
|
||||
|
||||
var (weakGo, weakPanel) = BuildPanel();
|
||||
try
|
||||
{
|
||||
Strike(weakPanel, Vector2.zero, 8f);
|
||||
Step(weakPanel, 150);
|
||||
int broken = ReadState(weakPanel).Count(s => s.breakTime >= 0f);
|
||||
if (broken != 0)
|
||||
{
|
||||
Debug.LogError($"{Tag} weak hit broke {broken} cells; expected 0.");
|
||||
ok = false;
|
||||
}
|
||||
else Debug.Log($"{Tag} weak hit OK: 0 cells broken.");
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(weakGo); }
|
||||
|
||||
var (strongGo, strongPanel) = BuildPanel();
|
||||
try
|
||||
{
|
||||
int total = strongPanel.DebugCellCount;
|
||||
Strike(strongPanel, Vector2.zero, 300f);
|
||||
Step(strongPanel, 200);
|
||||
int broken = ReadState(strongPanel).Count(s => s.breakTime >= 0f);
|
||||
float frac = (float)broken / total;
|
||||
if (broken < 2)
|
||||
{
|
||||
Debug.LogError($"{Tag} solid hit broke {broken} of {total} cells; expected a local " +
|
||||
"cluster (>=2), not a single cell or nothing.");
|
||||
ok = false;
|
||||
}
|
||||
else if (frac > 0.6f)
|
||||
{
|
||||
Debug.LogError($"{Tag} solid hit broke {broken}/{total} ({frac:P0}) — that's the whole panel, " +
|
||||
"not a local shatter; tuning has overshot.");
|
||||
ok = false;
|
||||
}
|
||||
else Debug.Log($"{Tag} solid hit OK: broke {broken}/{total} ({frac:P0}).");
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(strongGo); }
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// Temper erosion: repeated sub-critical hits must measurably wear the temper down toward
|
||||
// the material's critical margin ("this is the multi-hit budget" -- ShieldSim.compute).
|
||||
//
|
||||
// NOTE ON SCOPE: the map's own text frames "a later hit breaks through where the first
|
||||
// didn't" as an OPERATOR check (Cut 5's own Verification list marks it "operator:", not
|
||||
// "test:"), and this probe tried to also pin it as an automated rule. It could not: a
|
||||
// closing hit landed AFTER several prior weak hits on the SAME live panel instance
|
||||
// measured a materially lower peak tension than the identical energy landed on a FRESH
|
||||
// panel (e.g. energy 20 broke a fresh panel's control only one increment away at energy
|
||||
// 21, yet produced no measurable rise over the weak hits' own peak when landed after three
|
||||
// prior weak hits on one panel) -- a real, reproducible interaction between repeated
|
||||
// strikes on one live instance that this cut did not chase to ground. Flagged for the
|
||||
// operator's own repeated-hit play check and for a follow-up investigation; not silently
|
||||
// asserted as passing. What IS pinned here, reliably: erosion itself measurably progresses.
|
||||
// ==============================================================
|
||||
static bool CheckRepeatedWeakHitsErodeIntoBreak()
|
||||
{
|
||||
const int erosionHits = 6;
|
||||
const float weakEnergy = 8f; // confirmed elsewhere: never breaks a fresh panel alone
|
||||
|
||||
var (go, panel) = BuildPanel();
|
||||
try
|
||||
{
|
||||
float temperInit = panel.temperInit;
|
||||
float temperAfterOne = float.NaN;
|
||||
|
||||
for (int hit = 1; hit <= erosionHits; hit++)
|
||||
{
|
||||
Strike(panel, Vector2.zero, weakEnergy);
|
||||
Step(panel, 120); // let each weak hit's wave settle before the next
|
||||
if (hit == 1) temperAfterOne = ReadState(panel).Min(s => s.temper);
|
||||
}
|
||||
|
||||
var raw = ReadState(panel);
|
||||
int broken = raw.Count(s => s.breakTime >= 0f);
|
||||
float temperFinal = raw.Min(s => s.temper);
|
||||
|
||||
Debug.Log($"{Tag} temper erosion: min temper {temperInit:F3} -> {temperAfterOne:F3} " +
|
||||
$"(after 1 hit) -> {temperFinal:F3} (after {erosionHits} hits); {broken} cells " +
|
||||
"broken along the way (not required by this rule -- see the scope note above).");
|
||||
|
||||
if (broken > 0)
|
||||
{
|
||||
Debug.LogError($"{Tag} temper erosion: {broken} cells broke from weak hits alone -- " +
|
||||
"energy 8 is not actually sub-critical at the current tuning.");
|
||||
return false;
|
||||
}
|
||||
// The erosion rule itself: repeated sub-critical hits must cut temper by more than
|
||||
// half of its starting budget -- a real, substantial erosion, not numerical noise.
|
||||
if (temperFinal > temperInit * 0.5f)
|
||||
{
|
||||
Debug.LogError($"{Tag} temper erosion not evident: {erosionHits} weak hits only " +
|
||||
$"eroded temper from {temperInit:F3} to {temperFinal:F3}.");
|
||||
return false;
|
||||
}
|
||||
// And it must be MONOTONE progress, not a one-shot drop that then does nothing --
|
||||
// hit 1 alone must not already have done everything hit 6 achieves.
|
||||
if (temperFinal >= temperAfterOne - 1e-4f)
|
||||
{
|
||||
Debug.LogError($"{Tag} temper erosion not evident: temper after 1 hit ({temperAfterOne:F3}) " +
|
||||
$"is not measurably above temper after {erosionHits} hits ({temperFinal:F3}) -- " +
|
||||
"erosion isn't accumulating across repeated hits.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Debug.Log($"{Tag} temper erosion OK: {erosionHits} weak hits eroded temper " +
|
||||
$"{temperInit:F3} -> {temperFinal:F3} without breaking anything.");
|
||||
return true;
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(go); }
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// R7: strike an interior point that is neither the panel centre nor the rim, and confirm
|
||||
// breaks cluster tightly around THAT point rather than smearing toward the boundary. Under
|
||||
// the old free-rim term, a strong hit's compressive wave reflects off the whole boundary as
|
||||
// tension, which (given enough travel time) preferentially loads the rim rather than the
|
||||
// impact -- exactly the "shatters only near the rim" symptom the operator reported.
|
||||
// ==============================================================
|
||||
static bool CheckBreaksClusterNearImpactNotRim()
|
||||
{
|
||||
// Same panelRadius/cellSize as the solid-hit check, which is the configuration this
|
||||
// cut's tuning was actually calibrated against (a bigger panel at the same cellSize
|
||||
// pushes the growth wavefront's spawnDelay timing out for cells far from centre and
|
||||
// starves the cascade of frames to work with inside this probe's fixed step budget).
|
||||
const float panelRadius = 3f;
|
||||
var impact = new Vector2(0.9f, 0.2f); // interior, well off both centre and rim (|impact| ~= 0.92 vs rim 3)
|
||||
|
||||
var (go, panel) = BuildPanel(panelRadius: panelRadius, cellSize: 0.4f);
|
||||
try
|
||||
{
|
||||
Strike(panel, impact, 300f);
|
||||
Step(panel, 200);
|
||||
|
||||
var state = ReadState(panel);
|
||||
var centers = Centers(panel);
|
||||
int total = state.Length;
|
||||
|
||||
var brokenDist = new System.Collections.Generic.List<float>();
|
||||
for (int i = 0; i < total; i++)
|
||||
if (state[i].breakTime >= 0f) brokenDist.Add(Vector2.Distance(centers[i], impact));
|
||||
|
||||
if (brokenDist.Count == 0)
|
||||
{
|
||||
Debug.LogError($"{Tag} R7 cluster check: the strong off-centre hit broke nothing; " +
|
||||
"cannot evaluate clustering.");
|
||||
return false;
|
||||
}
|
||||
|
||||
float meanAll = 0f;
|
||||
for (int i = 0; i < total; i++) meanAll += Vector2.Distance(centers[i], impact);
|
||||
meanAll /= total;
|
||||
|
||||
float meanBroken = brokenDist.Average();
|
||||
float maxBroken = brokenDist.Max();
|
||||
|
||||
Debug.Log($"{Tag} R7 cluster: {brokenDist.Count}/{total} broken, meanDistToImpact(broken)=" +
|
||||
$"{meanBroken:F3}, meanDistToImpact(all)={meanAll:F3}, maxDistToImpact(broken)=" +
|
||||
$"{maxBroken:F3}, panelRadius={panelRadius}.");
|
||||
|
||||
bool ok = true;
|
||||
if (meanBroken > meanAll * 0.35f)
|
||||
{
|
||||
Debug.LogError($"{Tag} R7 not held: broken cells' mean distance to impact ({meanBroken:F3}) " +
|
||||
$"is not tightly clustered against the panel-wide mean ({meanAll:F3}) — " +
|
||||
"breaks are spreading rather than localising at the hit.");
|
||||
ok = false;
|
||||
}
|
||||
// Distance from impact to the nearest rim point is panelRadius - |impact|; breaks
|
||||
// reaching anywhere near that distance means the rim, not the impact, is spalling.
|
||||
float distToRim = panelRadius - impact.magnitude;
|
||||
if (maxBroken > distToRim * 0.6f)
|
||||
{
|
||||
Debug.LogError($"{Tag} R7 not held: a broken cell sits {maxBroken:F3} from the impact, " +
|
||||
$"{maxBroken / distToRim:P0} of the way to the rim ({distToRim:F3} away) — " +
|
||||
"the rim is still acting as a stress source.");
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok) Debug.Log($"{Tag} R7 OK: breaks cluster at the impact, not the rim.");
|
||||
return ok;
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(go); }
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// Dicing cascade: a broken cell dumps its released energy into its dual-graph neighbours
|
||||
// (KFracture's feedback loop), so a neighbour of a broken cell should be measurably more
|
||||
// likely to also be broken than a cell far from any break. Approximate "dual-graph
|
||||
// neighbour" geometrically (nearest cell centres) since the probe has no access to the
|
||||
// private edge list -- for a hex tiling that is the same adjacency the sim actually uses.
|
||||
// ==============================================================
|
||||
static bool CheckNeighboursMoreLikelyToBreak()
|
||||
{
|
||||
var (go, panel) = BuildPanel(panelRadius: 3f, cellSize: 0.4f);
|
||||
try
|
||||
{
|
||||
Strike(panel, Vector2.zero, 150f);
|
||||
Step(panel, 200);
|
||||
|
||||
var state = ReadState(panel);
|
||||
var centers = Centers(panel);
|
||||
int n = state.Length;
|
||||
bool[] broken = new bool[n];
|
||||
for (int i = 0; i < n; i++) broken[i] = state[i].breakTime >= 0f;
|
||||
|
||||
int brokenCount = broken.Count(b => b);
|
||||
if (brokenCount == 0 || brokenCount == n)
|
||||
{
|
||||
Debug.LogError($"{Tag} cascade check: {brokenCount}/{n} broken — need a partial " +
|
||||
"break to say anything about neighbours vs. distant cells.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Self-calibrate "adjacent" from the tiling's OWN geometry instead of guessing a
|
||||
// multiple of cellSize (which does not equal true hex centroid spacing): take the
|
||||
// median nearest-OTHER-cell distance across the whole panel as the typical
|
||||
// dual-graph edge length, then classify relative to that.
|
||||
float[] nearestOther = new float[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
if (j == i) continue;
|
||||
best = Mathf.Min(best, Vector2.Distance(centers[i], centers[j]));
|
||||
}
|
||||
nearestOther[i] = best;
|
||||
}
|
||||
float typicalSpacing = nearestOther.OrderBy(d => d).ElementAt(n / 2);
|
||||
float nbrRadius = typicalSpacing * 1.2f;
|
||||
float farRadius = typicalSpacing * 1.7f;
|
||||
Debug.Log($"{Tag} cascade calibration: typical spacing={typicalSpacing:F3}, " +
|
||||
$"nbrRadius={nbrRadius:F3}, farRadius={farRadius:F3}, broken={brokenCount}/{n}.");
|
||||
|
||||
// For every cell, find its distance to the nearest OTHER broken cell, then bucket
|
||||
// it as "near a break" or "distant from every break" and see which bucket breaks
|
||||
// more often. A cell counts toward its own bucket whether or not it is itself
|
||||
// broken -- that's the point: does proximity to a break predict being broken.
|
||||
int nbrTotal = 0, nbrBroken = 0, farTotal = 0, farBroken = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
float nearestOtherBrokenDist = float.MaxValue;
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
if (j == i || !broken[j]) continue;
|
||||
nearestOtherBrokenDist = Mathf.Min(nearestOtherBrokenDist, Vector2.Distance(centers[i], centers[j]));
|
||||
}
|
||||
if (nearestOtherBrokenDist <= nbrRadius) { nbrTotal++; if (broken[i]) nbrBroken++; }
|
||||
else if (nearestOtherBrokenDist >= farRadius) { farTotal++; if (broken[i]) farBroken++; }
|
||||
}
|
||||
|
||||
if (nbrTotal == 0 || farTotal == 0)
|
||||
{
|
||||
Debug.LogError($"{Tag} cascade check: not enough cells in one bucket to compare " +
|
||||
$"(neighbours={nbrTotal}, distant={farTotal}).");
|
||||
return false;
|
||||
}
|
||||
|
||||
float nbrRate = (float)nbrBroken / nbrTotal;
|
||||
float farRate = (float)farBroken / farTotal;
|
||||
Debug.Log($"{Tag} cascade: neighbour-of-a-break rate {nbrBroken}/{nbrTotal}={nbrRate:P1}, " +
|
||||
$"distant rate {farBroken}/{farTotal}={farRate:P1}.");
|
||||
|
||||
if (nbrRate <= farRate)
|
||||
{
|
||||
Debug.LogError($"{Tag} cascade not evident: neighbours of a break ({nbrRate:P1}) are not " +
|
||||
$"more likely to be broken than distant cells ({farRate:P1}).");
|
||||
return false;
|
||||
}
|
||||
|
||||
Debug.Log($"{Tag} cascade OK: neighbours break at {nbrRate:P1} vs {farRate:P1} for distant cells.");
|
||||
return true;
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(go); }
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Editor/ShieldPanelCut5Verify.cs.meta
Normal file
2
Assets/Scripts/Editor/ShieldPanelCut5Verify.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 767a47fc9125f044b92b5ffdc2390fb4
|
||||
|
|
@ -26,10 +26,11 @@ struct CellStatic
|
|||
uint tileType;
|
||||
uint splitSeed;
|
||||
float area; // Laplacian denominator
|
||||
float openW; // free-surface ghost weight (rim edges)
|
||||
// R7: openW (rim free-surface ghost weight) removed — the panel's emitters hold its edge.
|
||||
float pad0;
|
||||
float pad1;
|
||||
float pad2;
|
||||
float pad3;
|
||||
};
|
||||
|
||||
struct DualEdge
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ namespace ShieldField
|
|||
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;
|
||||
|
||||
|
|
@ -41,14 +44,14 @@ namespace ShieldField
|
|||
public float echoReflect = -0.65f;
|
||||
|
||||
[Header("Material (tempered)")]
|
||||
public float tensileStrength = 0.9f;
|
||||
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 = 2.2f;
|
||||
public float temperErosion = 12f;
|
||||
[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;
|
||||
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;
|
||||
|
|
@ -405,6 +408,7 @@ namespace ShieldField
|
|||
sim.SetFloat("_WaveSpeed2", waveSpeed2);
|
||||
sim.SetFloat("_Damping", damping);
|
||||
sim.SetFloat("_StressClamp", stressClamp);
|
||||
sim.SetFloat("_VelClamp", velocityClamp);
|
||||
sim.SetInt("_EchoDelay", echoDelaySteps);
|
||||
sim.SetFloat("_EchoReflect", echoReflect);
|
||||
|
||||
|
|
@ -426,6 +430,16 @@ namespace ShieldField
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -158,7 +158,11 @@ Shader "ShieldField/Panel"
|
|||
|
||||
LineVary BuildLine(float3 wa, float3 wb, uint k, float widthPx, float4 col)
|
||||
{
|
||||
LineVary o;
|
||||
// D15: every path below does set all four fields, but the compute compiler's
|
||||
// dataflow analysis doesn't trust that across the early-return branch and warns
|
||||
// "potentially uninitialized variable (BuildLine)". Zero-init once instead of
|
||||
// relying on the analysis.
|
||||
LineVary o = (LineVary)0;
|
||||
float4 ca = UnityWorldToClipPos(wa);
|
||||
float4 cb = UnityWorldToClipPos(wb);
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ float _Dt;
|
|||
float _WaveSpeed2; // c^2
|
||||
float _Damping;
|
||||
float _StressClamp;
|
||||
float _VelClamp; // D9: bounds v so a hard hit cannot pin u in compression forever
|
||||
|
||||
// --- through-thickness echo
|
||||
uint _EchoSlot; // current write slot, advanced by the host each substep
|
||||
|
|
@ -217,15 +218,25 @@ void KWaveStep(uint3 id : SV_DispatchThreadID)
|
|||
flowMax = max(flowMax, f);
|
||||
}
|
||||
|
||||
// Rim edges have no arc at all, so their ghost term was precomputed.
|
||||
lap += c.openW * (-2.0 * ui);
|
||||
|
||||
// R7: the rim used to be a free surface with a precomputed ghost term (openW) that
|
||||
// reflected compression back as tension. It no longer contributes at all — the panel's
|
||||
// emitters hold the edge, so an unshared boundary edge is simply not in the Laplacian.
|
||||
// A broken cell's own faces are unaffected: the `solid` check above already treats a
|
||||
// broken (or ungrown) neighbour as free regardless of position, so the dicing cascade
|
||||
// survives untouched.
|
||||
lap /= max(c.area, 1e-5);
|
||||
|
||||
float v = _V[i];
|
||||
v += _WaveSpeed2 * lap * _Dt;
|
||||
v *= exp(-_Damping * _Dt);
|
||||
|
||||
// D9: u was clamped but v was not, so a hard-enough hit drove v far past what one
|
||||
// substep's u-clamp could absorb; u pinned at -_StressClamp every substep afterward and
|
||||
// the field never swung back into tension (the measured collapse above energy ~60).
|
||||
// Clamping v to the same scale keeps it a real integrator input instead of an unbounded
|
||||
// accumulator.
|
||||
v = clamp(v, -_VelClamp, _VelClamp);
|
||||
|
||||
float u = ui + v * _Dt;
|
||||
u = clamp(u, -_StressClamp, _StressClamp);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,10 +22,14 @@ namespace ShieldField
|
|||
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)
|
||||
// R7 (docs/shield-panel-cut.md): openW (rim free-surface ghost weight) removed — the
|
||||
// panel's emitters hold its edge, so the rim is no longer a stress-reflecting boundary.
|
||||
// No other consumer read this field (grep confirmed); pad0 keeps the stride unchanged
|
||||
// rather than reshuffling every offset downstream in the HLSL mirror.
|
||||
public float pad0;
|
||||
public float pad1;
|
||||
public float pad2;
|
||||
public float pad3;
|
||||
public const int Stride = 4 * 16;
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +220,6 @@ namespace ShieldField
|
|||
}
|
||||
|
||||
int eStart = edgePool.Count;
|
||||
float openW = 0f;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int ia = ring[i], ib = ring[(i + 1) % n];
|
||||
|
|
@ -228,15 +231,13 @@ namespace ShieldField
|
|||
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;
|
||||
}
|
||||
// R7 (operator, 2026-09-18): the emitters projecting the panel hold its
|
||||
// rim, so an unshared edge on the panel boundary is not a free surface —
|
||||
// it carries no ghost term and generates no reflected tension. A broken
|
||||
// cell's faces are a different case entirely: KWaveStep's per-edge `solid`
|
||||
// check still treats a broken neighbour as free, so the dicing cascade
|
||||
// (reflection off actual damage) is untouched by this.
|
||||
if (other < 0) continue;
|
||||
|
||||
float nbrDist = Mathf.Max((ctrs[other] - ctr).magnitude, 1e-4f);
|
||||
edgePool.Add(new DualEdge
|
||||
|
|
@ -264,7 +265,6 @@ namespace ShieldField
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue