From 973317972394b2fc317bc786385bbee925fa5ec2 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 28 Jul 2026 18:45:56 +0200 Subject: [PATCH] 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 --- CODEMAP.md | 2 +- OPSTACK-PROGRESS.md | 92 ++++++++++++ .../Tests/VoxelForgeOpStackTunnelTest.cpp | 14 +- .../Private/VoxelDensityOpStack.cpp | 133 ++++++++++++++++-- 4 files changed, 225 insertions(+), 16 deletions(-) diff --git a/CODEMAP.md b/CODEMAP.md index a8144c2..cb91781 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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`. | | `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. | -| `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 `\|A−B\| ≥ 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. | | `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 4b–4h (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. | diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 6097f60..1818f17 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -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 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). + +## 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 28–29. 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−|A−B|,0)/K`. The penalty is **exactly zero** +once `|A−B| ≥ 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. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index ef2e21c..b9bb1ec 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1269,12 +1269,14 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) 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("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("says what to tighten. A tunnel is culled per voxel by its BOUNDING SPHERE, ") - TEXT("which for a long thin capsule is an enormous over-estimate; a room's cull ") - TEXT("sphere is a fair fit. So tunnels >> rooms here would mean the box test is ") - TEXT("losing to capsule bounding spheres, not to real cave -- and the fix would ") - TEXT("be a segment-vs-box distance, not anything about the sampler."), + TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. This line named ") + TEXT("the tunnels (32 of 34 tiles vs 21 for rooms), and they have since been ") + TEXT("given a second test: a tunnel now also passes if its own SDF stays >= ") + TEXT("T+K over the box, which beats its bounding-sphere cull badly for a long ") + TEXT("thin capsule. So a tunnel counted HERE is one genuinely close to the ") + 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, (float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled, (float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled)); diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 1d2500a..5102864 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -79,6 +79,19 @@ namespace // 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 // 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) { // 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 * 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. - * `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive qui SURVIT à son - * cull par voxel (sphère 3D pour les salles et les tunnels, bornes Z + cercle XY pour les - * pits et les cheminées). Donc : si AUCUNE primitive du cache ne peut survivre à son cull en - * 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 - * inégalité que le cull par voxel, élevée du point à la boîte. Une seule raison d'échouer. + * LE CRITÈRE — **UNE PRIMITIVE NE COMPTE PAS SI ELLE RATE SON CULL *OU* SI SON SDF RESTE + * AU-DESSUS DU SEUIL `T`.** Une disjonction, pas une seule règle, et chaque branche gagne sur + * une classe différente. Le détail de `T` et sa condition de validité sont dans la note + * « LE SEUIL T » à l'intérieur de la fonction — la lire avant toute modification. + * + * • branche CULL — `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive + * 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É : * 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 // 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 − |A−B|, 0)/K`. Deux conséquences lues sur la + // formule : la pénalité est EXACTEMENT nulle dès que `|A−B| ≥ 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.NumTunnels = B.Cache.Tunnels.Num(); B.NumPits = B.Cache.Pits.Num(); @@ -2370,9 +2434,60 @@ namespace { 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 // `Z >= TopZ - Depth - BlendK`.