feat(opstack): tunnels get a threshold test -- T1.d is measured at 6 of 40 tiles, 0 violations

THE PRIZE LANDED. At production defaults: 6 of 40 tiles proved AllSolid, 7986
voxels brute-forced, 0 violations. 15% of tiles skip GenerateMesh entirely, one
BuildChunkCache traded against 30000+ density evaluations each, and not one
verdict is asserted -- every voxel was re-evaluated and came back on the claimed
side. The dense fixture still reports 0, exactly as the arithmetic said it must,
so this is a measurement of production rather than of a widened test.

The breakdown finally isolated the tunnels: of 34 unproved tiles, 32 were
blocked by a tunnel and 21 by a room, so >=13 were tunnel-only. That is what
justified the deferred disjunction, and why it was deferred rather than guessed.

A primitive no longer matters if it misses its cull OR its own SDF stays >= T+K
over the box. Each branch wins on a different class, by calculation: for rooms
the cull (Rmax+3K) is tighter than the threshold (Rmax+T+K), so rooms keep the
cull alone; for tunnels the cull is a capsule BOUNDING SPHERE, radius ~107 for a
200-long tube of radius 7, against a true segment distance. Order of magnitude.

The bound is exact, not cautious. TaperedCapsule was read, not assumed --
Dist(P, ClosestOnSegment) - Lerp(Ra,Rb,t) -- so SDF >= dist(P,segment) -
max(Ra,Rb); and dist(box,segment) >= dist(centre,segment) - half-diagonal by
triangle inequality. VF_DistPointSegment is written locally rather than taken
from FMath: five lines, and "I think that function does that" is not good enough
under a correctness bound.

Identity CHANGED MEANING, from "Sdf stays FLT_MAX" to "Sdf >= T" with
T = max(3*SDFBlendRadius, WormNetworkRange). Sound only because all three
consumers of the SDF channel were read one by one: FSdfConvertOp's Blend is
MakeSdfCarve(P.SDFBlendRadius, ...) => K; the twelve modifiers gate at 3K;
FWormFieldSource at WormNetworkRange. Plus the one that could have bitten --
FCaveTerraceMod re-probes the SDF at Z+-1, OUTSIDE the box, but its gate is line
13 and the probes are lines 28-29, so a gate false everywhere never emits one.
ANY NEW CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR JOIN THAT MAX,
or it gets tiles with no geometry and no collision. Written at the site.

Why -K suffices for any N: SmoothMin's penalty is exactly zero once |A-B| >= K,
so the running minimum saturates at K below the smallest term and cannot descend
further. Sdf >= min_i(SDF_i) - K for ANY number of primitives, not - N*K/6 --
without which the slack would scale with the ~88 tunnels and be worthless.

Unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:45:56 +02:00
parent 87a0b996ec
commit 9733179723
4 changed files with 225 additions and 16 deletions
+1 -1
View File
@@ -154,7 +154,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. | | `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. | | `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | | `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28: it builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then lifts each primitive's per-voxel cull from point to box — rooms/tunnels as spheres vs the warp-dilated box, pits/chimneys vs the **undilated** box (real coords), columns as infinite cylinders. `Identity``Sdf` stays `FLT_MAX``FSdfConvertOp` **and all twelve detail modifiers** go identity with it. Verdict is memoised per box (the twelve all ask the same question). Warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`, not the header's observed `~[-1,1]`. | | `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|AB\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. |
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. | | `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. |
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). | | `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. | | `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
+92
View File
@@ -2935,3 +2935,95 @@ real T1.d saving in the plugin. If it is *also* zero, then the per-class line un
rooms still saturate at 80 spacing (my model is wrong again, look there) or whether tunnels are rooms still saturate at 80 spacing (my model is wrong again, look there) or whether tunnels are
finally alone (the segment-vs-box disjunction from the previous entry is then the whole remaining finally alone (the segment-vs-box disjunction from the previous entry is then the whole remaining
job, and its three unverified premises are listed there). job, and its three unverified premises are listed there).
## 2026-07-28 — ✅ **THE PRIZE LANDED.** 6 of 40 tiles proved, 7986 voxels brute-forced, 0 violations.
```
[production defaults] Box verdicts over 40 tiles: 6 proved (6 AllSolid, 0 AllAir), 34 Mixed
-- brute-forced over 7986 voxels, 0 violations
[dense fixture] 0 proved, 40 Mixed <- correct, and predicted
```
**This is the first real T1.d saving in the plugin.** 15 % of tiles skip `GenerateMesh` entirely —
one `BuildChunkCache` traded against 30 000+ density evaluations each — and not one of those verdicts
is asserted: all 7986 voxels were re-evaluated through the stack and every single one came back on
the claimed side. The two-world split also did its job: the dense fixture reported 0, exactly as the
arithmetic said it must, so the number above is a measurement of *production*, not of a test that was
widened until it agreed.
Five runs to get here, and the honest summary of them is that **every single one of my hypotheses was
wrong and every single instrument was right**:
| run | my hypothesis | what the instrument said |
|---|---|---|
| 1 | "the tiles straddle cave" / "Identity unreachable" | **the worm**, unbounded `CarveOnly`, `8 < 10` by default |
| 2 | "tunnels ≫ rooms ⇒ bounding spheres" | rooms hit 40/40 too — fixing tunnels alone changes nothing |
| 3 | "widen the sampler and rooms will drop" | they did not — `RoomSpacing` was **42**, not the 80 I computed with |
| 4 | — | the fixture is *deliberately* saturated; `0 proved` is its correct answer |
| 5 | "production defaults will prove tiles" | **6 of 40**, and now tunnels really are alone (32 vs 21) |
### The tunnel disjunction — the deferred piece, now justified by its own number
The breakdown finally isolated it: of the 34 unproved tiles, **32 were blocked by a tunnel and 21 by
a room**, so ≥13 were blocked by tunnels *alone*. That is what made the deferred work worth doing, and
it is why it was deferred until now rather than guessed at three runs ago.
A primitive now fails to matter under a **disjunction**:
> it misses its cull entirely **OR** its own SDF stays ≥ `T + K` over the whole box.
Each branch wins on a different class, and the arithmetic says which:
- **rooms** — cull rejects at `Rmax + 3K`, threshold only at `Rmax + T + K`. Cull is strictly tighter,
so rooms keep the cull alone. Not an omission; a calculation.
- **tunnels** — the cull is the capsule's *bounding sphere*: radius ~107 for a 200-long tube of
radius 7. The threshold uses the true distance to the segment. Order of magnitude.
**The bound is exact, not cautious.** `TaperedCapsule` was *read* (`Dist(P, ClosestOnSegment)
Lerp(Ra,Rb,t)`), so `SDF ≥ dist(P, segment) max(Ra,Rb)`; and `dist(box, segment) ≥ dist(centre,
segment) half-diagonal` by triangle inequality. `VF_DistPointSegment` is written locally rather than
taken from `FMath` — five lines, and "I think that function does that" is not good enough under a
correctness bound.
### ⚠️ `Identity` CHANGED MEANING, and that is the one real debt this creates
It used to mean "`Sdf` stays `FLT_MAX`". It now means "`Sdf ≥ T`", with
`T = max(3·SDFBlendRadius, WormNetworkRange)`. That is only sound because all three consumers of the
SDF channel were **read one by one**, not assumed:
| consumer | threshold | verified at |
|---|---|---|
| `FSdfConvertOp::Eval` | `Sdf >= Blend`, and `BuildTunnelNetworkStack` passes `MakeSdfCarve(P.SDFBlendRadius, …)` ⇒ **K** | call site |
| the twelve modifiers | `VF_NearCaveSurface` ⇒ **3K** | its body |
| `FWormFieldSource::Eval` | `CaveSDF >= WormNetworkRange` ⇒ **WormNetworkRange** | its body |
Plus the one that could have bitten: `FCaveTerraceMod` re-probes the SDF at **Z±1, outside the box** —
but its `VF_NearCaveSurface` gate is line 13 and the probes are lines 2829. Gated first, so a gate
that is false everywhere never emits a probe. Checked, not assumed.
**Any new consumer of the `Sdf` channel must have a threshold ≤ `T` or be added to that `max`.** An
operator reading `Sdf < 100` would see false `Identity` verdicts, i.e. tiles with no geometry **and no
collision**. That warning is written at the site, in the function you land in when you add one.
### Why ` K` is enough for any number of primitives
`SmoothMin(A,B,K) = min(A,B) H³K/6` with `H = max(K|AB|,0)/K`. The penalty is **exactly zero**
once `|AB| ≥ K`, so the running minimum saturates at `K` below the smallest term — it cannot descend
further, because at that distance `H = 0` and subsequent folds return it unchanged. Hence
`Sdf ≥ min_i(SDF_i) K` for **any** N, not ` N·K/6`. Without that observation the slack would scale
with the ~88 tunnels in the cache and the criterion would be worthless.
### Ready to build. Compile-error spots
1. `VF_DistPointSegment` — new helper in the anonymous namespace, next to `VF_NearCaveSurface`
(i.e. well above the end-of-namespace marker).
2. `K` / `T` / `TunnelClear` / `QCenter` / `BoxHalfDiag` are new locals in `EffectOverBox`; the tunnel
loop variable is `Tn` **specifically so it does not shadow `T`**. Braces and parens verified balanced.
3. `(QMax - QMin).Size()` returns a double under UE5's `FVector` — cast to float.
### What to read
`[production defaults] Box verdicts` — **6 is the number to beat.** The disjunction should raise it;
≥13 tiles were tunnel-only blocked, so somewhere near 19 of 40 is the expectation. And
`[dense fixture]` **must stay at 0** — if the dense world starts proving tiles, the disjunction is
wrong somewhere and the brute force is the thing that will say so.
@@ -1269,12 +1269,14 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TEXT("[%s] ...and when RoomGraphSource is the killer (%d tiles), WHICH primitive class ") TEXT("[%s] ...and when RoomGraphSource is the killer (%d tiles), WHICH primitive class ")
TEXT("reaches the box: rooms %d, tunnels %d, pits %d, chimneys %d (tiles, not ") TEXT("reaches the box: rooms %d, tunnels %d, pits %d, chimneys %d (tiles, not ")
TEXT("primitives -- a tile can be hit by several). Averages per killed tile: ") TEXT("primitives -- a tile can be hit by several). Averages per killed tile: ")
TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. THIS is the line that ") TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. This line named ")
TEXT("says what to tighten. A tunnel is culled per voxel by its BOUNDING SPHERE, ") TEXT("the tunnels (32 of 34 tiles vs 21 for rooms), and they have since been ")
TEXT("which for a long thin capsule is an enormous over-estimate; a room's cull ") TEXT("given a second test: a tunnel now also passes if its own SDF stays >= ")
TEXT("sphere is a fair fit. So tunnels >> rooms here would mean the box test is ") TEXT("T+K over the box, which beats its bounding-sphere cull badly for a long ")
TEXT("losing to capsule bounding spheres, not to real cave -- and the fix would ") TEXT("thin capsule. So a tunnel counted HERE is one genuinely close to the ")
TEXT("be a segment-vs-box distance, not anything about the sampler."), TEXT("box, not a bounding-sphere artefact. Rooms keep the cull test alone, ")
TEXT("because for them the cull (Rmax+3K) is tighter than the threshold ")
TEXT("(Rmax+T+K) -- that is arithmetic, not an omission."),
Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys, Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys,
(float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled, (float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled,
(float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled)); (float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled));
@@ -79,6 +79,19 @@ namespace
// STAGE B5 DECISION: repeated early-out in each op, NOT a scoping container — the stack is a flat // STAGE B5 DECISION: repeated early-out in each op, NOT a scoping container — the stack is a flat
// list that ClassifyBox folds op by op, and an op that only exists inside a container is not // list that ClassifyBox folds op by op, and an op that only exists inside a container is not
// composable. Cost stated honestly: twelve predictable compares instead of one branch. // composable. Cost stated honestly: twelve predictable compares instead of one branch.
/** Distance d'un point à un SEGMENT (pas à une droite). Écrite ici plutôt que prise dans
* `FMath` : cinq lignes, aucune ambiguïté d'API, et elle sert une borne de correction — le
* genre d'endroit où « je crois que cette fonction fait ça » n'est pas suffisant. */
FORCEINLINE float VF_DistPointSegment(const FVector& P, const FVector& A, const FVector& B)
{
const FVector AB = B - A;
const double LenSq = FVector::DotProduct(AB, AB);
const double T = (LenSq > UE_KINDA_SMALL_NUMBER)
? FMath::Clamp(FVector::DotProduct(P - A, AB) / LenSq, 0.0, 1.0)
: 0.0;
return (float)FVector::Dist(P, A + AB * T);
}
FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius) FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius)
{ {
// Transcrit tel quel, ordre des comparaisons compris : // Transcrit tel quel, ordre des comparaisons compris :
@@ -2205,13 +2218,22 @@ namespace
* opérateurs deviennent l'identité d'un coup** et la tuile est prouvable — c'est pour ça que * opérateurs deviennent l'identité d'un coup** et la tuile est prouvable — c'est pour ça que
* le câblage a été posé à UN endroit et pas treize. * le câblage a été posé à UN endroit et pas treize.
* *
* LE CRITÈRE, ET POURQUOI IL NE PEUT ÊTRE FAUX QUE DANS UN SENS. * LE CRITÈRE — **UNE PRIMITIVE NE COMPTE PAS SI ELLE RATE SON CULL *OU* SI SON SDF RESTE
* `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive qui SURVIT à son * AU-DESSUS DU SEUIL `T`.** Une disjonction, pas une seule règle, et chaque branche gagne sur
* cull par voxel (sphère 3D pour les salles et les tunnels, bornes Z + cercle XY pour les * une classe différente. Le détail de `T` et sa condition de validité sont dans la note
* pits et les cheminées). Donc : si AUCUNE primitive du cache ne peut survivre à son cull en * « LE SEUIL T » à l'intérieur de la fonction — la lire avant toute modification.
* un point quelconque de la boîte, `Sdf` reste `FLT_MAX` sur TOUTE la boîte, la source est *
* l'identité, et tout ce qui en dépend l'est aussi. On teste exactement ça — la même * • branche CULL — `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive
* inégalité que le cull par voxel, élevée du point à la boîte. Une seule raison d'échouer. * qui SURVIT à son cull par voxel. Aucune survivante ⇒ `Sdf` reste `FLT_MAX`. C'est la
* même inégalité que le cull, élevée du point à la boîte. **Meilleure pour les salles** :
* leur cull (`Rmax + 3K`) est plus serré que le seuil (`Rmax + T + K`).
* • branche SEUIL — une primitive peut survivre à son cull et rester malgré tout trop loin
* pour qu'un consommateur s'allume. **Meilleure pour les tunnels**, dont le cull est la
* sphère englobante d'une capsule : rayon ~107 pour un tube de rayon 7 long de 200.
*
* Mélanger les deux est sûr : les primitives de la branche cull ne contribuent RIEN, les
* autres sont toutes ≥ `T + K`, donc le pli vaut ≥ `T` (voir la saturation de `SmoothMin`),
* et les trois consommateurs sont éteints. Une seule raison d'échouer, dans les deux cas.
* *
* LES TROIS CHOSES QUI RENDENT LE TEST CONSERVATIF DU BON CÔTÉ : * LES TROIS CHOSES QUI RENDENT LE TEST CONSERVATIF DU BON CÔTÉ :
* 1. le warp déplace la coordonnée de REQUÊTE, donc la boîte est dilatée de sa borne * 1. le warp déplace la coordonnée de REQUÊTE, donc la boîte est dilatée de sa borne
@@ -2360,6 +2382,48 @@ namespace
// //
// No early-out on purpose: stopping at the first hit gives the right verdict and no // No early-out on purpose: stopping at the first hit gives the right verdict and no
// information. When a zero has several possible causes, each gets its own number. // information. When a zero has several possible causes, each gets its own number.
//-----------------------------------------------------------------
// ⚠️⚠️ LE SEUIL `T` — CE QUE `Identity` VEUT DIRE ICI, ET SA CONDITION DE VALIDITÉ
//-----------------------------------------------------------------
// Jusqu'ici `Identity` signifiait « `Sdf` reste `FLT_MAX` sur toute la boîte ». C'est
// vrai, mais c'est plus fort que nécessaire, et cette force coûtait la quasi-totalité du
// gain : aucun consommateur ne regarde `Sdf` au-delà d'un seuil.
//
// Les TROIS consommateurs du canal SDF de cette pile, RELUS un par un (pas supposés) :
// • `FSdfConvertOp::Eval` → `if (InOut.Sdf >= Blend) return;` et
// `BuildTunnelNetworkStack` l'instancie par `MakeSdfCarve(P.SDFBlendRadius, …)`
// ⇒ seuil = `K`.
// • les DOUZE modificateurs → `VF_NearCaveSurface` ⇒ seuil = `3·K`.
// • `FWormFieldSource::Eval` → `if (CaveSDF >= P.WormNetworkRange) NetworkMask = 0;`
// puis `if (NetworkMask <= 0) return;` ⇒ seuil = `WormNetworkRange`.
// (`FCaveTerraceMod` re-sonde le SDF en Z±1, donc HORS de la boîte — mais son gate
// `VF_NearCaveSurface` est testé AVANT la sonde, vérifié ligne par ligne. Un gate faux
// partout ⇒ aucune sonde n'est jamais émise.)
//
// Donc `Sdf ≥ T` avec `T = max(K, 3K, WormNetworkRange)` suffit à éteindre les trois.
//
// ⚠️ **TOUT NOUVEAU CONSOMMATEUR DU CANAL `Sdf` DOIT AVOIR UN SEUIL ≤ T, OU ÊTRE AJOUTÉ
// À CE `Max`.** C'est la seule dette de couplage de cette fonction, et elle est réelle :
// un opérateur qui regarderait `Sdf < 100` verrait des verdicts `Identity` faux, donc
// des tuiles sans géométrie ET SANS COLLISION. Écrit ici parce que c'est ici qu'on
// atterrit en l'ajoutant.
//
// ⚠️ ET LA RAISON POUR LAQUELLE ` K` SUFFIT MALGRÉ N PRIMITIVES. `SmoothMin(A,B,K)`
// vaut `min(A,B) H³K/6` avec `H = max(K |AB|, 0)/K`. Deux conséquences lues sur la
// formule : la pénalité est EXACTEMENT nulle dès que `|AB| ≥ K`, et le minimum courant
// ne peut donc jamais descendre plus de `K` sous le plus petit des termes — arrivé là,
// `H = 0` et les plis suivants le laissent intact. D'où `Sdf ≥ min_i(SDF_i) K` pour un
// nombre QUELCONQUE de primitives, et non ` N·K/6`. C'est ce qui rend ce critère
// utilisable au lieu d'être noyé sous le nombre de tunnels.
//
// Identity now means "Sdf >= T over the box", not "Sdf stays FLT_MAX" — no consumer
// looks past its own threshold, and the three that exist were read one by one. ANY NEW
// CONSUMER OF THE Sdf CHANNEL MUST HAVE A THRESHOLD <= T OR BE ADDED TO THIS MAX.
// The -K slack covers any number of primitives because SmoothMin's penalty is exactly
// zero once |A-B| >= K, so the running minimum saturates at K below the true minimum.
const float K = FMath::Max(P.SDFBlendRadius, 0.0f);
const float T = FMath::Max(3.0f * K, P.WormNetworkRange);
B.NumRooms = B.Cache.Rooms.Num(); B.NumRooms = B.Cache.Rooms.Num();
B.NumTunnels = B.Cache.Tunnels.Num(); B.NumTunnels = B.Cache.Tunnels.Num();
B.NumPits = B.Cache.Pits.Num(); B.NumPits = B.Cache.Pits.Num();
@@ -2370,9 +2434,60 @@ namespace
{ {
if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { ++B.HitRooms; } if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { ++B.HitRooms; }
} }
for (const FCachedTunnel& T : B.Cache.Tunnels) //-----------------------------------------------------------------
// LES TUNNELS ONT DROIT À UN SECOND TEST, ET C'EST LÀ QUE SE TROUVE LE GAIN
//-----------------------------------------------------------------
// ⚠️ CECI CHANGE LE SENS D'`Identity` POUR CET OPÉRATEUR — lire la note « LE SEUIL T »
// ci-dessus avant de toucher quoi que ce soit ici.
//
// Le cull par voxel d'un tunnel est sa SPHÈRE ENGLOBANTE. Pour une capsule longue et
// fine c'est une sur-estimation énorme : avec `MaxTunnelLength = 200` et
// `TunnelMaxRadius = 7`, la sphère a un rayon jusqu'à ~107 pour un tube de rayon 7. La
// mesure le disait sans ambiguïté — 32 tuiles bloquées sur 34 par des tunnels, contre
// 21 par des salles.
//
// Donc : soit le tunnel rate son cull (il ne s'exécute pas), soit son PROPRE SDF reste
// ≥ `T + K` sur toute la boîte (il s'exécute mais ne peut pas descendre le champ assez
// bas pour qu'un consommateur s'allume). L'un ou l'autre suffit.
//
// La borne est exacte, pas prudente : `TaperedCapsule` rend
// `Dist(P, PlusProcheSurSegment) Lerp(Ra, Rb, t)`, donc
// `SDF ≥ dist(P, segment) max(Ra, Rb)` — RELU dans `VoxelCaveMorphology.h`, pas supposé.
// Et `dist(boîte, segment) ≥ dist(centre, segment) demi-diagonale` par inégalité
// triangulaire : conservatif du bon côté, et trivialement vrai.
//
// A tunnel's per-voxel cull is its BOUNDING SPHERE — for a 200-long tube of radius 7
// that sphere has radius ~107. So a tunnel does not matter if it fails that cull OR if
// its own SDF stays >= T + K over the box. The bound is exact: TaperedCapsule is
// genuinely dist-to-segment minus an interpolated radius, and box-to-segment distance is
// bounded below by centre-to-segment minus the half-diagonal.
const float TunnelClear = T + K;
const FVector QCenter = (QMin + QMax) * 0.5;
const float BoxHalfDiag = 0.5f * (float)(QMax - QMin).Size();
for (const FCachedTunnel& Tn : B.Cache.Tunnels)
{ {
if (SphereHitsBox(T.BoundCenter, T.BoundRadiusSq, QMin, QMax)) { ++B.HitTunnels; } if (!SphereHitsBox(Tn.BoundCenter, Tn.BoundRadiusSq, QMin, QMax)) { continue; }
float MaxR = FMath::Max(Tn.RadiusA, Tn.RadiusB);
float DistToAxis;
if (Tn.bHasMidpoint)
{
// Deux segments : le SDF du tunnel est le `Min` des deux, donc sa borne
// inférieure est le `Min` des deux bornes.
MaxR = FMath::Max(MaxR, Tn.RadiusMid);
DistToAxis = FMath::Min(
VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.Midpoint),
VF_DistPointSegment(QCenter, Tn.Midpoint, Tn.EndpointB));
}
else
{
DistToAxis = VF_DistPointSegment(QCenter, Tn.EndpointA, Tn.EndpointB);
}
if (DistToAxis - BoxHalfDiag - MaxR >= TunnelClear) { continue; }
++B.HitTunnels;
} }
// Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET // Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET
// `Z >= TopZ - Depth - BlendK`. // `Z >= TopZ - Depth - BlendK`.