fix(opstack): three ExtraReach box verdicts used sup|FBM| = 1.0; the proved bound is 1.5

CORRECTNESS on a box verdict, not perf. This file derives |Perlin3D| <= 1.5
rigorously, exposes it as PerlinAbsBound, uses it for the tunnel warp, and says
outright that the header's "~[-1,1]" is an observation and that a box verdict
resting on one is a hole. Three ExtraReach formulas in the same file assumed
sup|FBM| <= 1.0 -- and VoxelNoise::FBM normalises (Total / MaxValue), so
sup|FBM| = sup|Perlin3D| exactly. The bound was wrong by 1.5x.

At shipped defaults, soundness needed B <= 1.27 (VerticalShafts, Rough 3.0) and
B <= 1.40 (Maze, Rough 2.0); both are violated at B = 1.5. Break-even roughness
is 1.6. FloatingIslands survives only because its SDFBlendRadius is 5 -- sound
by parameter luck, not construction.

Nothing in the running game was affected (no strate has bUseOperatorStack
ticked) and the empirical Perlin sup ~1.0-1.1 is why nothing surfaced. But the
tile scans SAMPLE, and for shafts they sampled a source that proved zero tiles
until e002bd4 -- which is what makes this reachable rather than latent.

Fix: PerlinAbsBound hoisted to file scope as VF_PerlinAbsBound (one definition,
not a class-static plus three implicit 1.0s) and multiplied into all three
reaches; the three comments now state the real justification instead of the
refuted one.

Cannot change density: ExtraReach is read only inside EffectOverBox, verified
mechanically -- no Eval/GetCells line appears in the diff. Direction is strictly
conservative, so it can only cost CPU. Expect FEWER proved tiles for Maze and
VerticalShafts; that is the correct outcome, not a regression.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:36:04 +02:00
parent b5294b2d5b
commit 7dbdf51b44
3 changed files with 253 additions and 62 deletions
@@ -35,6 +35,59 @@
namespace
{
/**
* BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0.
*
* L'en-tête de `VoxelNoise::Perlin3D` annonce « ~[-1,1] (typiquement [-0.7,0.7]) ». Le `~`
* est un aveu : c'est une observation, pas un théorème, et un verdict de boîte fondé sur une
* observation est exactement le genre de trou que ce fichier passe son temps à éviter.
*
* Ce qui EST démontrable, en lisant `GradDot` : il rend `ru + rv` où `ru` et `rv` sont des
* composantes de l'offset fractionnaire, donc chacune dans `[-1, 1]` ⇒ `|GradDot| ≤ 2`. La
* valeur finale est une interpolation trilinéaire de huit `GradDot`, et une interpolation
* convexe ne sort jamais de l'enveloppe de ses entrées ⇒ `|Perlin3D| ≤ 2`. (La vraie borne
* de Perlin 3D est `√3/2 ≈ 0.87` ; on ne s'appuie pas dessus, elle dépend du jeu de
* 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.
*
* ⚠️⚠️ **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 `(1su)` et l'offset `fx`, ceux à
* `i=1` le poids `su` et l'offset `1fx`. Donc `Σ_c w_c·|dx_c| = (1su)·fx + su·(1fx)`,
* 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 VF_PerlinAbsBound = 1.5f;
/** La même enveloppe que `FractalNoise3D` de VoxelGenerator.cpp (qui y est `static`, donc
* invisible ici). Le détour par `FVector` est délibéré — voir l'en-tête de ce fichier. */
FORCEINLINE float HFractal3D(const FVector& Position, int32 Octaves = 4,
@@ -2282,59 +2335,6 @@ namespace
return S;
}
/**
* BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0.
*
* L'en-tête de `VoxelNoise::Perlin3D` annonce « ~[-1,1] (typiquement [-0.7,0.7]) ». Le `~`
* est un aveu : c'est une observation, pas un théorème, et un verdict de boîte fondé sur une
* observation est exactement le genre de trou que ce fichier passe son temps à éviter.
*
* Ce qui EST démontrable, en lisant `GradDot` : il rend `ru + rv` où `ru` et `rv` sont des
* composantes de l'offset fractionnaire, donc chacune dans `[-1, 1]` ⇒ `|GradDot| ≤ 2`. La
* valeur finale est une interpolation trilinéaire de huit `GradDot`, et une interpolation
* convexe ne sort jamais de l'enveloppe de ses entrées ⇒ `|Perlin3D| ≤ 2`. (La vraie borne
* de Perlin 3D est `√3/2 ≈ 0.87` ; on ne s'appuie pas dessus, elle dépend du jeu de
* 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.
*
* ⚠️⚠️ **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 `(1su)` et l'offset `fx`, ceux à
* `i=1` le poids `su` et l'offset `1fx`. Donc `Σ_c w_c·|dx_c| = (1su)·fx + su·(1fx)`,
* 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 = 1.5f;
/**
* ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée.
*
@@ -2449,7 +2449,7 @@ namespace
// 3. LE CACHE POUR LA BOÎTE INTERROGÉE
//-----------------------------------------------------------------
const float Warp = (P.CaveWarpStrength > 0.0f)
? P.CaveWarpStrength * VOXEL_NOISE_SCALE * PerlinAbsBound
? P.CaveWarpStrength * VOXEL_NOISE_SCALE * VF_PerlinAbsBound
: 0.0f;
// `+ 2` : la même marge de gradient que la boîte de recherche de `Eval`.
@@ -4101,9 +4101,11 @@ namespace VoxelDensityOps
constexpr float CarveBlend = 2.0f;
// Portée que la source doit déclarer pour la paire source+carve : la rugosité peut élargir
// le puits (FBM ∈ [-1,1] ⇒ ±Strength·VOXEL_NOISE_SCALE), puis le blend du carve. Sur-estimer
// coûte du CPU ; sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE + CarveBlend + 1.0f;
// `FBM` est normalisé (`Total / MaxValue`), donc sup|FBM| = sup|Perlin3D| = la borne
// prouvée `VF_PerlinAbsBound` ; la rugosité peut élargir le puits, puis vient le blend du
// carve. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
* VF_PerlinAbsBound + CarveBlend + 1.0f;
TUniquePtr<FShaftFieldSource> ShaftSource = MakeUnique<FShaftFieldSource>(P, Seed, ExtraReach);
const FShaftFieldSource* ShaftPtr = ShaftSource.Get();
@@ -4210,9 +4212,12 @@ namespace VoxelDensityOps
const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f);
// Portée que la source doit déclarer pour la paire source+fill : la rugosité peut abaisser
// le SDF de `Rough·VOXEL_NOISE_SCALE` (FBM ∈ [-1,1]), le SmoothMin de `K/6` de plus, et le
// fill s'applique dès `Sdf < BlendK`. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
// `FBM` est normalisé (`Total / MaxValue`), donc sup|FBM| = sup|Perlin3D| = la borne
// prouvée `VF_PerlinAbsBound` ; le SDF peut être abaissé par la rugosité, puis par le
// SmoothMin de `K/6`, et le fill s'applique dès `Sdf < BlendK`. Sur-estimer coûte du CPU ;
// sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
* VF_PerlinAbsBound
+ BlendK * 2.0f + 1.0f;
OutStack.Add(MakeConstantVoidSource(P.BaseDensity));
@@ -4242,9 +4247,11 @@ namespace VoxelDensityOps
const float RoughApplyWithin = R + P.SurfaceRoughness + 2.0f;
// Portée que la source doit déclarer pour la paire source+carve : le rayon du couloir peut
// être élargi par la rugosité (FBM ∈ [-1,1] ⇒ ±Strength·VOXEL_NOISE_SCALE) puis par le blend
// du carve. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE + CarveBlend + 1.0f;
// `FBM` est normalisé (`Total / MaxValue`), donc sup|FBM| = sup|Perlin3D| = la borne
// prouvée `VF_PerlinAbsBound` ; le rayon du couloir peut être élargi par la rugosité puis
// par le blend du carve. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
* VF_PerlinAbsBound + CarveBlend + 1.0f;
OutStack.Add(MakeConstantRockSource(P.BaseDensity));
OutStack.Add(MakeLatticeCorridorSource(P, Seed, ExtraReach));