From b2938d38f1d9126dab086ad1229f689bf43ceee1 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Wed, 29 Jul 2026 03:21:37 +0200 Subject: [PATCH] fix(opstack): the warp bound was the dominant term all along -- 2.0 -> 1.5, and MEASURE it Jahni asked whether I was in a loop. I was, and re-reading the original found it in one line. PerlinAbsBound was set to 2.0 in the first commit of the spatial EffectOverBox and never revisited. The dilation is CaveWarpStrength * VOXEL_NOISE_SCALE * PerlinAbsBound, and CaveWarpStrength is 8.0 by default: 8 * 1.25 * 2.0 = 20 voxels, applied +/- on every axis tile 10 voxels -> query box 50 per side -> 125x the tile volume So the tunnel test I called "an order of magnitude tighter" actually needed DistToAxis >= 78 against a cull rejecting at ~107. 27% tighter, not 10x -- and the run said so: tunnels reaching went 78.0 -> 58.5, a 25% cut. The instrument was right and I credited the tunnels. Four rounds -- worm, columns, sampler, tunnel disjunction -- each individually correct, every one of them tightening around a term nobody had measured. I kept instrumenting what I had just changed and never instrumented what I had assumed. What re-reading showed: BuildChunkCache is called everywhere with Expansion = CaveWarpStrength + 2.0f, which is only correct if |Perlin3D| <= 0.8. The whole plugin has always bet on 0.8. I picked 2.0 -- 2.5x more conservative than the assumption the cache's own correctness already rests on. The corrected bound is derived, not guessed. GradDot returns +-u +-v with u and v two DISTINCT components (checked on all four hash branches). Splitting the eight corners by i gives, per axis, sum w*|dx| = (1-su)*fx + su*(1-fx) <= 0.5 (max at fx = 0.5). Hence |Perlin3D| <= S_x + S_y + S_z <= 1.5, with no case analysis on the hashes. Dilation 20 -> 15. And the instrument that should have existed from the start: EffectOverBox now also runs every room/tunnel test with the warp dilation set to ZERO and reports both, so Hit* - Hit*NoWarp is exactly the blocking caused by my box rather than by geometry. The report says, in the output, that if that gap dominates the next move is the warp bound and not the primitives. Separately: the diagnostics had turned into narrative, printing hardcoded numbers from previous runs next to live ones ("32 of 34 tiles vs 21 for rooms" while the live figures said 21 of 28). Unreadable, and I wrote all of it. Diagnostics now report THIS run; the history stays in OPSTACK-PROGRESS. Unbuilt. Co-Authored-By: Claude Opus 5 --- OPSTACK-PROGRESS.md | 92 +++++++++++++++++++ .../Tests/VoxelForgeOpStackTunnelTest.cpp | 37 ++++---- .../Private/VoxelDensityOpStack.cpp | 62 ++++++++++++- .../VoxelForge/Public/VoxelDensityOpStack.h | 7 ++ 4 files changed, 179 insertions(+), 19 deletions(-) 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();