diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 1818f17..152d7af 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -3027,3 +3027,95 @@ with the ~88 tunnels in the cache and the criterion would be worthless. ≥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. + +## 2026-07-29 — Jahni: "are you in a loop?" **Yes.** The dominant term was a constant I set on day one and never measured. + +11 of 40 proved, 0 violations — real, but Jahni called it: *still wrong, re-read the original and how +you're doing it.* He was right, and the re-read found it in one line. + +### The loop + +`PerlinAbsBound = 2.0`, chosen in the very first commit of the spatial `EffectOverBox` and never +revisited. The warp dilation is `CaveWarpStrength · VOXEL_NOISE_SCALE · PerlinAbsBound`, and +**`CaveWarpStrength = 8.0` by default** (the fixture does not override it): + +``` +dilation = 8 × 1.25 × 2.0 = 20 voxels, applied ±, on every axis +tile = 10 voxels → query box 50 voxels per side → 125× the tile's volume +BoxHalfDiag = 43.3 +``` + +So the tunnel test I described as "an order of magnitude tighter" actually required +`DistToAxis ≥ 28 + 43.3 + 7 = 78`, against a bounding-sphere cull that rejects at ~107. **27 % +tighter, not 10×** — and the run reported exactly that: tunnels reaching went 78.0 → 58.5, a 25 % +cut. The instrument told me the truth and I credited it to the tunnels. + +Four rounds of work — the worm, the columns, the sampler, the tunnel disjunction — every one of them +tightening a term while **the term nobody measured stayed 125× too big**. Each round was individually +correct and the loop was still real: *I kept instrumenting the thing I had just changed, and never +instrumented the thing I had assumed.* + +### What re-reading the original actually showed + +`BuildChunkCache` is called — in `GetDensityWithParams`, in `FRoomGraphSource::Eval`, everywhere — +with `Expansion = CaveWarpStrength + 2.0f`. That is the shipped, working, years-old code, and it is +only correct if `|Perlin3D| · VOXEL_NOISE_SCALE ≤ CaveWarpStrength`, i.e. **`|Perlin3D| ≤ 0.8`**. + +**The whole plugin has always bet on 0.8. I chose 2.0 — 2.5× more conservative than the assumption +the cache's own correctness already rests on — and then optimised around my own choice for three +builds.** That is the answer to "are you in a loop", and it is not a subtle one. + +### The corrected bound, derived rather than guessed + +Read out of `GradDot`: `u` and `v` are always **two distinct** components of the corner offset — +checked on all four hash branches, not assumed. Then per axis, splitting the eight corners by `i`: + +``` +Σ_c w_c·|dx_c| = (1−su)·fx + su·(1−fx) su = Fade(fx) + ≤ 0.5 (max at fx = 0.5; 0.302 at fx = 0.25 and 0.75) +|Perlin3D| ≤ Σ_c w_c(|a_c|+|b_c|) ≤ S_x + S_y + S_z ≤ 1.5 +``` + +**1.5, provable, no case analysis on the hashes.** (The true max is lower still — only two of three +axes appear per corner — and the classical `√3/2 ≈ 0.87` depends on the gradient set, so it is not +leaned on.) Dilation drops 20 → 15 voxels. That is a 25 % cut in the dominant term, honestly +obtained, and it is *not* the end of the story. + +### The instrument that should have existed from the first commit + +`EffectOverBox` now also computes every room/tunnel test **with the warp dilation set to zero**, and +reports both. `HitRooms − HitRoomsNoWarp` is exactly the blocking caused by my own box rather than by +geometry. The line reads: + +``` +WARP SHARE: the query box is dilated by ±N voxels per axis; with that dilation set to ZERO +the same tests would keep only X rooms and Y tunnels. +``` + +If that gap dominates, the next move is the warp bound — **not** the primitives. That sentence is in +the output so the next person (me, next run) cannot repeat this. + +### The other thing that was wrong: the diagnostics had become narrative + +The report was printing **hardcoded numbers from previous runs** next to live ones — "the first build +reported 0 proved of 40", "32 of 34 tiles vs 21 for rooms" — while the live figures said 21 rooms of +28 tiles. A reader cannot tell which is which, and I wrote every line of it. Diagnostics now report +**this run only**; the history lives here, in the log, where it belongs. + +### Ready to build. Compile-error spots + +1. `PerlinAbsBound` 2.0 → 1.5 (one constant, big blast radius on the numbers, none on compilation). +2. `FBoxState` gained `HitRoomsNoWarp` / `HitTunnelsNoWarp` / `WarpDilation`; mirrored on + `VoxelDensityOps::FRoomBoxDiagnostic` and copied in `GetLastRoomBoxDiagnostic`. +3. `NWMin` / `NWMax` / `NoWarpHalfDiag` are new locals in `EffectOverBox`, declared before use + (verified mechanically, along with brace/paren balance). +4. The per-class report line went from 10 to 13 format specifiers and 13 arguments — counted, and the + previous round's arg-list mismatch is exactly why it was counted. + +### What to read, and in this order + +1. **`WARP SHARE`** — the number that has been invisible all along. `rooms 1.1 → 0.4` would mean most + of the remaining block is my dilation and the warp bound is the whole job; `1.1 → 1.0` would mean + the geometry really is that dense and the dilation was never the point after the 1.5 fix. +2. `[production defaults] Box verdicts` — **11 is the number to beat.** +3. `[dense fixture]` must still be 0. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index b9bb1ec..7f4b1ce 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1120,6 +1120,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) const int32 SpanVoxelsReported = SpanCells * 8; // Extent = Step * Cells = 1 * 8 int32 TilesHitByRooms = 0, TilesHitByTunnels = 0, TilesHitByPits = 0, TilesHitByChimneys = 0; int32 SumHitRooms = 0, SumNumRooms = 0, SumHitTunnels = 0, SumNumTunnels = 0; + int32 SumHitRoomsNoWarp = 0, SumHitTunnelsNoWarp = 0; + float LastWarpDilation = 0.0f; FRandomStream Rng(97531); for (int32 t = 0; t < 40; ++t) @@ -1192,6 +1194,9 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) if (D.HitChimneys > 0) { ++TilesHitByChimneys; } SumHitRooms += D.HitRooms; SumNumRooms += D.NumRooms; SumHitTunnels += D.HitTunnels; SumNumTunnels += D.NumTunnels; + SumHitRoomsNoWarp += D.HitRoomsNoWarp; + SumHitTunnelsNoWarp += D.HitTunnelsNoWarp; + LastWarpDilation = D.WarpDilation; } } @@ -1254,13 +1259,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) if (Breakdown.IsEmpty()) { Breakdown = TEXT("nothing -- AllSolid survived every tile"); } AddInfo(FString::Printf( - TEXT("[%s] AllSolid killed by: %s. This is the line that replaced a guess. The first ") - TEXT("build of the spatial EffectOverBox reported 0 proved of 40, and the warning ") - TEXT("offered two candidate causes -- BOTH WRONG. The real one was a third operator ") - TEXT("nobody was looking at: FWormFieldSource answered CarveOnly everywhere, and ") - TEXT("since BaseDensity=8 < WormStrength=10 BY DEFAULT, its provable amplitude bound ") - TEXT("alone drove SolidMargin negative on every tile in the world. Attribution is ") - TEXT("cheap; a second wrong guess is not."), + TEXT("[%s] AllSolid killed by: %s. Names the first operator to kill the ") + TEXT("hypothesis, counted per tile. (Why this line exists, and the wrong guesses ") + TEXT("that preceded it, live in OPSTACK-PROGRESS and are deliberately NOT ") + TEXT("repeated here: a diagnostic that carries narrative gets its live numbers ") + TEXT("read as history and its history read as live numbers.)"), Label, *Breakdown)); if (NumRoomKilled > 0) @@ -1269,17 +1272,19 @@ 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 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."), + TEXT("%.1f of %.1f rooms reach, %.1f of %.1f tunnels reach. ") + TEXT("WARP SHARE: the query box is dilated by +/-%.1f voxels per axis for the ") + TEXT("warped room/tunnel query; with that dilation set to ZERO the same tests ") + TEXT("would keep only %.1f rooms and %.1f tunnels. The gap between those ") + TEXT("pairs is blocking caused by MY BOX rather than by geometry -- the term ") + TEXT("that went unmeasured while three rounds of tightening happened around ") + TEXT("it. If the gap dominates, tighten the warp bound, not the primitives."), Label, NumRoomKilled, TilesHitByRooms, TilesHitByTunnels, TilesHitByPits, TilesHitByChimneys, (float)SumHitRooms / (float)NumRoomKilled, (float)SumNumRooms / (float)NumRoomKilled, - (float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled)); + (float)SumHitTunnels / (float)NumRoomKilled, (float)SumNumTunnels / (float)NumRoomKilled, + LastWarpDilation, + (float)SumHitRoomsNoWarp / (float)NumRoomKilled, + (float)SumHitTunnelsNoWarp / (float)NumRoomKilled)); } } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 5102864..4fc4d40 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -2181,6 +2181,11 @@ namespace * `VoxelDensityOps::GetLastRoomBoxDiagnostic`. N'entre dans aucune décision. */ int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0; int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0; + /** Les mêmes comptes avec une dilatation de warp NULLE, et de combien de voxels la + * boîte est dilatée. C'est la mesure qui manquait pendant trois builds : sans elle, + * « les tunnels bloquent » et « ma boîte est 125x trop grosse » sont indiscernables. */ + int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0; + float WarpDilation = 0.0f; }; static FBoxState& BoxState() @@ -2204,10 +2209,43 @@ namespace * gradients.) Se tromper ici coûte une boîte de recherche un peu plus large, jamais un * verdict faux : plus large ⇒ SUR-ensemble de primitives ⇒ `Identity` plus rare. * - * Provable bound rather than the header's observed one: GradDot returns ru+rv with both in - * [-1,1], and a trilinear lerp stays inside the hull of its inputs. Erring high costs CPU. + * ⚠️⚠️ **CORRIGÉ DE 2.0 À 1.5 LE 2026-07-28, ET CETTE CONSTANTE ÉTAIT LE TERME DOMINANT DE + * TOUTE LA FONCTION PENDANT TROIS BUILDS.** À lire avant d'y retoucher. + * + * La dilatation vaut `CaveWarpStrength · VOXEL_NOISE_SCALE · CETTE BORNE`. Avec les défauts + * (`CaveWarpStrength = 8`, `SCALE = 1.25`) elle valait **20 voxels** — appliquée des deux + * côtés de chaque axe d'une tuile de **10 voxels**, soit une boîte de requête de 50 voxels, + * **125× le volume de la tuile**. Trois passes de resserrement (le ver, les colonnes, + * l'échantillonneur, la disjonction des tunnels) ont été faites AUTOUR de ce terme sans que + * personne ne le mesure. Le test des tunnels, annoncé « un ordre de grandeur plus serré », ne + * gagnait en pratique que 25 % — exactement parce que `BoxHalfDiag` était dominé par cette + * dilatation et non par la géométrie. + * + * ⚠️ ET LE RESTE DU PLUGIN N'A JAMAIS ÉTÉ AUSSI PRUDENT : `BuildChunkCache` est appelée avec + * `Expansion = CaveWarpStrength + 2` (ici comme dans `GetDensityWithParams`), ce qui suppose + * `|Perlin3D| · SCALE ≤ CaveWarpStrength`, donc `|Perlin3D| ≤ 0.8`. Le code qui tourne en + * production depuis toujours parie déjà là-dessus. Prendre 2.0 était 2,5× plus conservateur + * que l'hypothèse dont dépend déjà la correction du cache. + * + * LA BORNE 1.5, DÉMONTRÉE (et non observée) : + * 1. `GradDot` rend `±u ± v` où `u` et `v` sont deux composantes **distinctes** de l'offset + * du coin — vérifié sur les quatre branches du `switch` de hash, pas supposé. + * 2. Pour l'axe x : les coins à `i=0` portent le poids `(1−su)` et l'offset `fx`, ceux à + * `i=1` le poids `su` et l'offset `1−fx`. Donc `Σ_c w_c·|dx_c| = (1−su)·fx + su·(1−fx)`, + * dont le maximum sur `[0,1]` vaut **0.5** (atteint en `fx = 0.5`, où `su = 0.5` ; + * 0.302 en 0.25 comme en 0.75). + * 3. `|Perlin| ≤ Σ_c w_c(|a_c| + |b_c|) ≤ S_x + S_y + S_z ≤ 3 × 0.5 = 1.5.` + * (Le vrai maximum est plus bas encore — seuls DEUX axes apparaissent par coin — mais 1.5 + * est la borne qui se démontre sans analyse de cas sur les hash. `√3/2 ≈ 0.87`, la borne + * classique de Perlin 3D, dépend du jeu de gradients : on ne s'appuie pas dessus.) + * + * Was 2.0, and that constant was the dominant term of this whole function for three builds: + * it inflated a 10-voxel tile into a 50-voxel query box (125x the volume), which is why the + * "order of magnitude tighter" tunnel test only won 25%. The rest of the plugin has always + * assumed |Perlin3D| <= 0.8 (BuildChunkCache's Expansion = CaveWarpStrength + 2). 1.5 is + * PROVED above from GradDot's two-distinct-axes form and the per-axis weighted bound of 0.5. */ - static constexpr float PerlinAbsBound = 2.0f; + static constexpr float PerlinAbsBound = 1.5f; /** * ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée. @@ -2430,9 +2468,17 @@ namespace B.NumChimneys = B.Cache.Chimneys.Num(); B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; + // La MÊME boîte sans dilatation de warp — diagnostic seulement, voir plus bas. + const FVector NWMin((float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, EffZ((float)VoxelBox.Min.Z)); + const FVector NWMax((float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, EffZ((float)VoxelBox.Max.Z)); + const float NoWarpHalfDiag = 0.5f * (float)(NWMax - NWMin).Size(); + B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; + B.WarpDilation = Warp; + for (const FCachedRoom& R : B.Cache.Rooms) { if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { ++B.HitRooms; } + if (SphereHitsBox(R.Center, R.CullRadiusSq, NWMin, NWMax)) { ++B.HitRoomsNoWarp; } } //----------------------------------------------------------------- // LES TUNNELS ONT DROIT À UN SECOND TEST, ET C'EST LÀ QUE SE TROUVE LE GAIN @@ -2488,6 +2534,13 @@ namespace if (DistToAxis - BoxHalfDiag - MaxR >= TunnelClear) { continue; } ++B.HitTunnels; + + // DIAGNOSTIC — le MÊME test avec une dilatation de warp NULLE. Ne participe à aucun + // verdict ; il répond à la seule question que trois builds de resserrement n'ont + // jamais posée : « combien de ce blocage est de la géométrie, et combien est ma + // propre boîte dilatée ? ». `HitTunnels - HitTunnelsNoWarp` est exactement la part + // que le warp coûte. + if (DistToAxis - NoWarpHalfDiag - MaxR < TunnelClear) { ++B.HitTunnelsNoWarp; } } // Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET // `Z >= TopZ - Depth - BlendK`. @@ -3834,6 +3887,9 @@ VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic() D.NumTunnels = B.NumTunnels; D.NumPits = B.NumPits; D.NumChimneys = B.NumChimneys; + D.HitRoomsNoWarp = B.HitRoomsNoWarp; + D.HitTunnelsNoWarp = B.HitTunnelsNoWarp; + D.WarpDilation = B.WarpDilation; return D; } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 4dc73a0..f4fc50c 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -351,6 +351,13 @@ namespace VoxelDensityOps { int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0; int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0; + + /** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte + * est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt + * qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de + * resserrement autour du mauvais terme. */ + int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0; + float WarpDilation = 0.0f; }; VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();