diff --git a/CODEX-TASK-003-extrareach-perlin-bound.md b/CODEX-TASK-003-extrareach-perlin-bound.md new file mode 100644 index 0000000..b3d8c92 --- /dev/null +++ b/CODEX-TASK-003-extrareach-perlin-bound.md @@ -0,0 +1,109 @@ +# Codex task 003 — three `ExtraReach` formulas use an FBM bound this file already proved wrong + +**Owner:** Codex (Model Luna, xHigh) · **Orchestrator:** Claude · **Branch:** `experimental` +**Status:** specified, not started +**Kind:** ⚠️ **correctness of a box verdict** — the class of bug that deletes collision. Not a perf task. + +--- + +## Why this exists + +`VoxelDensityOpStack.cpp` contains a rigorous, written derivation that `|Perlin3D| ≤ 1.5`, exposes it +as `PerlinAbsBound` (line ~2336), and uses it correctly for the tunnel warp dilation (~2452). The +comment there is explicit that the loose "~[-1,1]" figure from the noise header is **not** to be +relied on, and `OPSTACK-HANDOFF.md` records the standard: *a bound in a box verdict must be PROVED, +not observed — over-estimating costs CPU, under-estimating deletes collision.* + +**Three `ExtraReach` formulas in the same file silently assume `sup|FBM| ≤ 1.0`.** Each carries the +comment "FBM ∈ [-1,1]", which is exactly the claim the file disproves 1800 lines earlier. + +And `VoxelNoise::FBM` **is normalised** — it returns `Total / MaxValue` where `MaxValue = Σ Amp` +(`VoxelNoise.h` ~272). So `sup|FBM| = sup|Perlin3D|` exactly: **1.5, not 1.0.** The octave sum +neither amplifies nor attenuates the bound. + +### What that costs, per archetype, at the shipped defaults + +`Identity` from these sources means "no primitive within `ExtraReach` of the box", i.e. `Sdf ≥ +ExtraReach` throughout. Roughness then does `Sdf += FBM · VOXEL_NOISE_SCALE · Strength` +(`FSdfRoughnessMod::Eval`), so worst case `Sdf' ≥ ExtraReach − B·1.25·|Roughness|`. Soundness +requires `Sdf'` to stay at or above the downstream carve/fill threshold. + +| archetype | `ExtraReach` at defaults | downstream threshold | needs `B ≤` | verdict at `B = 1.5` | +|---|---|---|---|---| +| **VerticalShafts** (`Rough 3.0`) | `1.25·3 + 2 + 1` = **6.75** | carve blend **2.0** | **1.27** | ⛔ **UNSOUND** (margin −0.875) | +| **Maze** (`Rough 2.0`) | `1.25·2 + 2 + 1` = **5.5** | carve blend **2.0** | **1.40** | ⛔ **UNSOUND** (margin −0.25) | +| **FloatingIslands** (`Rough 4.0`, `K 5.0`) | `1.25·4 + 2·5 + 1` = **16.0** | fill `K` **5.0** (+ `K/6` SmoothMin dip) | **2.03** | ✅ sound — but only because `K` is large. Sound by parameter luck, not by construction. | + +Break-even roughness for the two carve archetypes is `|Rough| ≤ 1.6`; they ship at 3.0 and 2.0. + +**How alarmed to be, stated honestly.** No strate has `bUseOperatorStack` ticked, so nothing in the +running game is affected today. The brute-force tile scans report 0 violations — but they *sample*, +and they were sampling against a shaft source that proved **zero** tiles until `e002bd4`, so the +shaft path has never been exercised at all. The empirical sup of this Perlin is estimated at +~1.0–1.1, which is *below* the 1.27 the shafts need — which is why nothing has been seen yet, and +also why the margin is uncomfortably thin. The bug is that the verdict rests on an unproved bound, +which is the thing this codebase has already decided it does not do. + +## The fix + +1. **Hoist `PerlinAbsBound` to file scope and rename it `VF_PerlinAbsBound`**, so there is **one** + definition rather than a class-static plus three implicit `1.0`s. Keep the existing derivation + comment with it — it is the justification, not decoration. + + **Naming, resolved:** the file-scope helpers in this file are all `VF_`-prefixed + (`VF_NearCaveSurface`, `VF_DistPointSegment`, `VF_NoCaveOverBox`), so a file-scope constant takes + the same prefix. That means this is a **rename**, not just a move: + - delete the `static constexpr float PerlinAbsBound = 1.5f;` class-static inside `FRoomGraphSource` + (~2336), moving its whole derivation comment with it; + - **update `FRoomGraphSource`'s own use at ~2452** (`P.CaveWarpStrength * VOXEL_NOISE_SCALE * + PerlinAbsBound`) to the new name. This is the one place where the warp dilation is computed and + it must keep computing the identical value — the rename must not change its arithmetic. + - after the edit, `grep -n "PerlinAbsBound" ` must show **only** `VF_PerlinAbsBound` occurrences. +2. **Multiply the roughness term by it in all three `ExtraReach` formulas** (~4106 VerticalShafts, + ~4215 FloatingIslands, ~4247 Maze): + + ```cpp + // before + FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE + // after + FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + ``` +3. **Fix the three comments.** Each says "FBM ∈ [-1,1]". Replace with the real statement: `FBM` is + normalised (`Total / MaxValue`), so `sup|FBM| = sup|Perlin3D| =` the proved `PerlinAbsBound`. + A comment that states a refuted bound is how this happened in the first place. + +## ⚠️ Invariants + +1. **This must not change density by one bit.** `ExtraReach` is read **only** inside + `EffectOverBox` (verified: every other occurrence is a comment or the `float ExtraReach;` member + declaration — no `Eval`, no `GetCells`). The eight equivalence tests compare `Eval` bit for bit + and must stay green. **If you find yourself editing an `Eval`, stop — you have the wrong site.** +2. **The change direction is strictly conservative**: larger `ExtraReach` ⇒ more `CarveOnly`, fewer + `Identity` ⇒ *fewer* tiles proved uniform. It can only cost CPU, never open a hole. Do not + "balance" it by tightening something else in the same edit. +3. **Anonymous-namespace placement.** Put the hoisted constant **above the labelled end of the + anonymous namespace**, not anchored on the FACTORIES banner — anchoring there puts it outside and + the brace added with it closes nothing. This mistake has been made twice in this file and the + file says so. +4. **`FRoomGraphSource`'s warp dilation changes NAME ONLY.** It already uses the bound correctly and + is the reference implementation for this fix; the value it computes must be bit-identical after + the rename. Do not alter its formula, its `√2` factor, or anything else in that function. +5. Comments are French + English; match the surrounding file. + +## Acceptance + +- `git diff --stat` shows **one** file: `Source/VoxelForge/Private/VoxelDensityOpStack.cpp`. +- All three `ExtraReach` definitions include the bound; no fourth site exists (`BuildTunnelNetworkStack` + has no `ExtraReach` — it uses `PerlinAbsBound` directly for the warp). +- No `Eval` body changed. +- After the build: the eight equivalence tests stay green (density unchanged), and the box-verdict + lines for **Maze** and **VerticalShafts** may report *fewer* proved tiles than before. **A drop + there is the expected, correct outcome, not a regression** — it is the cost of a sound bound. + Record the before/after in `OPSTACK-PROGRESS.md`. + +## Notes for the reviewer (Claude) + +- Confirm the constant is genuinely at file scope inside the anonymous namespace and that the + class-static is gone, not shadowed — two definitions that can drift is the failure this fixes. +- Confirm all three call sites got it. Two out of three is worse than none, because it looks done. +- Confirm no `Eval`, `GetCells`, or `GetCellsAt` body appears in the diff. diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 7c2480e..9357d05 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -3553,3 +3553,78 @@ absolute percentage.** The overhang and cliff mods call `GetColumn` again at the - `GetGenerationParams` is flagged as the safest of the six if any is ever wanted. Nothing here needs a decision from Jahni; it all needs the same next build. + +## 2026-08-16 (e) — ⛔ A REAL BUG: three box-verdict reaches used a bound this file had already refuted + +Found by auditing the invariant the handoff calls "the one that can delete collision", extended to +the archetypes it does *not* cover. `CODEX-TASK-003`, written, executed by Codex, reviewed. **Not a +perf change — a correctness change to a box verdict.** + +### The finding + +This file derives `|Perlin3D| ≤ 1.5` rigorously (the `GradDot` two-distinct-axes form and the +per-axis weighted bound of 0.5), exposes it as `PerlinAbsBound`, uses it correctly for the tunnel +warp dilation — and states outright that the noise header's "~[-1,1]" is an observation, not a +theorem, and that a box verdict resting on one is a hole. + +**Three `ExtraReach` formulas in the same file assumed `sup|FBM| ≤ 1.0`**, each with the comment +"FBM ∈ [-1,1]" — the exact claim disproved 1800 lines above. And `VoxelNoise::FBM` **normalises** +(`return Total / MaxValue`, `VoxelNoise.h` ~272), so `sup|FBM| = sup|Perlin3D|` **exactly**: the +octave sum neither amplifies nor attenuates it. The bound was simply wrong, by 1.5×. + +`Identity` from these sources means `Sdf ≥ ExtraReach` over the box; roughness then does +`Sdf += FBM · VOXEL_NOISE_SCALE · Strength`, so soundness needs +`ExtraReach − B·1.25·|Rough| ≥ (carve/fill threshold)`: + +| archetype | `ExtraReach` (defaults) | threshold | needs `B ≤` | at proved `B = 1.5` | +|---|---|---|---|---| +| **VerticalShafts** (`Rough 3.0`) | 6.75 | carve 2.0 | 1.27 | ⛔ **UNSOUND**, margin −0.875 | +| **Maze** (`Rough 2.0`) | 5.5 | carve 2.0 | 1.40 | ⛔ **UNSOUND**, margin −0.25 | +| **FloatingIslands** (`Rough 4.0`, `K 5.0`) | 16.0 | fill 5.0 + `K/6` | 2.03 | ✅ sound — **by parameter luck**, not construction | + +Break-even roughness for the two carve archetypes is `1.6`; they ship at **3.0** and **2.0**. + +### How alarmed to be — stated honestly, because overstating this would be its own failure + +**Nothing in the running game was ever affected**: no strate has `bUseOperatorStack` ticked. The +brute-force tile scans report 0 violations, but they **sample**, and for shafts they were sampling a +source that proved **zero** tiles until `e002bd4` — so that path had never been exercised at all. +The empirical sup of this Perlin is ~1.0–1.1, *below* the 1.27 the shafts needed, which is why +nothing surfaced. **The defect is that the verdict rested on an unproved bound** — precisely the +standard this codebase already adopted and wrote down. + +⚠️ And note what made it findable: `e002bd4` (still unbuilt) is what first lets the shaft source +return `Identity` at all. It converted a latent unsoundness into a reachable one. + +### The fix, and why it is safe + +`PerlinAbsBound` hoisted to file scope as `VF_PerlinAbsBound` (**one** definition, not a class-static +plus three implicit `1.0`s — the same "one definition, not two kept in sync" rule that produced +`VF_BuildOpStackForChunk`), and multiplied into all three reaches. The three lying comments now state +the real justification. + +**It cannot change density by one bit.** `ExtraReach` is read *only* inside `EffectOverBox` — every +other occurrence in the file is a comment or the `float ExtraReach;` member declaration. Verified +mechanically after the edit: no `Eval` / `GetCells` / `GetCellsAt` line appears in the diff. The +eight bit-for-bit equivalence tests are therefore unaffected by construction, not by hope. + +The direction is strictly conservative: larger reach ⇒ more `CarveOnly`, fewer `Identity` ⇒ *fewer* +tiles proved. It can only cost CPU. + +New values at defaults: shafts **6.75 → 8.625** (+28 %), maze **5.5 → 6.75** (+23 %), islands +**16.0 → 18.5** (+16 %). + +### ⚠️ What to expect in the build, so a correct result is not misread as a regression + +**The Maze and VerticalShafts box-verdict lines may prove FEWER tiles than before. That is the +correct outcome and the price of a sound bound — do not "fix" it.** Record the before/after. +`violations` must stay 0, as always. The eight equivalence tests must stay green; if any of them +moves, the change touched density and the diff is wrong. + +### Process note — Codex refused the first run, correctly + +The first attempt stopped without editing: my spec wrote `VF_PerlinAbsBound` in its code snippet and +`PerlinAbsBound` in its prose, and it asked which was intended rather than picking one. That is +exactly the behaviour the spec template asks for ("if the code contradicts the spec, stop and say so +rather than guessing"), and it is worth recording that it works — the cost was one clarification +instead of a plausible-looking wrong rename. diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index e606181..371f790 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -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 `(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 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 `(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 = 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 ShaftSource = MakeUnique(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));