diff --git a/CODEMAP.md b/CODEMAP.md index 47b08a3..753034d 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -121,7 +121,8 @@ stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE` ⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack in its per-chunk refetch block and evaluates it *instead of* the `switch` only when `UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and** -archetype in the ported list (**Maze only**). Everything else still takes the `switch`, unchanged. +archetype in the ported list (**Maze, FlatPlain, CrystalChamber**). Everything else still takes the +`switch`, unchanged. **`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is Phase 2. ⛔ Never run both paths in one world, and never compare them for equality: the ~1 ULP residue is inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). @@ -134,6 +135,9 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). | `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. | | `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. | | `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. | +| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. | +| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. | +| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. | | `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | ### 3.3 Chunk identity @@ -293,7 +297,7 @@ Maps depth→strate at runtime; owns passages. | `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. | | `GetStrateForChunk` | 466 | Chunk → definition. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | -| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list. **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. | +| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list (Maze, FlatPlain, CrystalChamber). **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. | | `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | | `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. | | `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). | @@ -376,6 +380,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. | | `VoxelForgeDiffLayerTest.cpp` | `VoxelForge.Determinism.DiffLayerContention` | N readers running the worker call mix while the game thread writes and `Clear()`s. Survival + monotonic `ModsVersion`. | | `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. | +| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | ## 4. The density pipeline (most-edited hot path) diff --git a/OPSTACK-DECOMPOSITION.md b/OPSTACK-DECOMPOSITION.md index e1213a7..8ab06ca 100644 --- a/OPSTACK-DECOMPOSITION.md +++ b/OPSTACK-DECOMPOSITION.md @@ -263,7 +263,22 @@ FGridColumnMod Add world-grid jittered cylinders, infinit That is the entire archetype. Two of the eight collapse into one, and the ceiling's `abs(noise)` (formations hang down only, never punch up) is a two-line flag on the source. -### 3.1 A finding to raise with Jahni before porting +### 3.1 ✅ RESOLVED 2026-07-27 — Jahni: the Z term can go. Removed. + +**Decision:** the Z term was not intentional character. It is gone from `GetSlabDensity` (both +surfaces), `FSlabVoidSource` is XY-pure, and FlatPlain + CrystalChamber are ported and wired. + +**What that bought, and what it cost:** +- `IsXYPure() == true` ⇒ the T1.a column-cache treatment becomes available generically. +- An **exact `ClassifyBox` with no sampling**: `VoxelNoise::FBM`'s contract is `[-1,1]`, so both + surfaces live in Z bands with known bounds — a tile entirely below the floor band is provably + solid, a tile strictly between the bands is provably air. These two archetypes proved **zero** + tiles before. `VoxelForge.OpStack.SlabEquivalence` reports the count. +- **Cost: the world re-tunes once.** Dropping the term samples a different slice of the noise + field, so floor and ceiling shapes change (they do not degrade). Covered by §2.6's explicit + permission to re-tune. + +The original finding, kept because it explains why the answer mattered: `FSlabVoidSource` is **not XY-pure, and probably should be.** Both surfaces sample noise with a small Z term: @@ -285,6 +300,8 @@ for free, and gets an exact box classification — which means FlatPlain and Cry skipping trivial tiles, which they never have. That is a large win for a one-character change, so it is worth asking rather than assuming either way. +**Answered: it can go.** See the resolution above. + --- ## 4. Maze — **the Phase 1 port** diff --git a/OPSTACK-PLAN.md b/OPSTACK-PLAN.md index aa51481..7909e87 100644 --- a/OPSTACK-PLAN.md +++ b/OPSTACK-PLAN.md @@ -4,9 +4,11 @@ > *composable density pipeline*, so new world ideas become authoring instead of C++. Written > 2026-07-26 as a handoff for a future context — read this instead of re-deriving it. > -> **Status (2026-07-27):** Phase 0.5 (tests) and the Phase 1 skeleton header are WRITTEN AND -> COMMITTED but **NOT YET COMPILED**. No operator exists; `GetDensityAt`'s archetype `switch` is -> untouched. Live state and the next action live in +> **Status (2026-07-27):** **Phase 0.5 and Phase 1 are DONE and verified** — five green tests, Maze +> decomposed into seven ops, wired into `GetDensityAt` behind `bUseOperatorStack`, and the visual +> A/B passed (Jahni: *"pretty similar, if not entirely similar"*). **Phase 2 is in progress:** +> FlatPlain + CrystalChamber are ported into ONE op (`BuildSlabStack`), their §3.1 Z term is gone, +> and both are wired — **written, not yet compiled.** Live state and the next action live in > [OPSTACK-PROGRESS.md](OPSTACK-PROGRESS.md) — read its last entry first. The per-archetype > breakdown is in [OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md). > @@ -356,8 +358,8 @@ Lipschitz-1 off a lattice), and it's the least-used archetype so a mistake is ch Port each archetype **the next time a feature makes you open it anyway**. The switch shrinks on its own. Suggested order when there's a free choice — cheapest and least risky first: -`Maze` (P1) → `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two archetypes -collapse into one) → `SurfaceWorld` (biggest payoff, biggest care: the T1.a column cache and the exact- +✅ `Maze` (P1) → ✅ `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two +archetypes collapse into one; **done 2026-07-27**, `BuildSlabStack`, 8 archetypes → 7) → `SurfaceWorld` (biggest payoff, biggest care: the T1.a column cache and the exact- lattice `ClassifyTile` bound must both survive) → `VerticalShafts` → `FloatingIslands` → `TunnelNetwork` (**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate code in the plugin). diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 4854cd7..d2569c0 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -876,3 +876,69 @@ field rename would show up here. recognisably the same maze, judged on a screenshot). Then Phase 2, starting with the §3.1 question. --- + +## 2026-07-27 — PHASE 1 CLOSED (visual A/B passed). Phase 2 opened: the slab collapse. + +**Phase 1's acceptance bar is met.** Jahni built step 3, ticked `bUseOperatorStack` on the Maze +strate and compared: *"it's hard to see with our current maze (which is simple in architecture) but +seems like it's pretty similar, if not entirely similar."* That is §2.6's bar — recognisably the +same maze — and it is worth being precise about how much weight it carries. + +**The screenshot is the weakest evidence Phase 1 has, and that is fine, because it was never +carrying the argument.** A simple maze is a poor visual discriminator: "hard to tell apart" is +*exactly* what the measurements already predicted, since **0 of 20 000 samples cross the +isosurface** — no triangle can move. The A/B's job was to catch the class of error the numbers +cannot see (wrong params reaching the stack, wrong strate, wrong wiring), and it did that. The +geometric claim rests on the numbers, and always did. + +### Phase 2, first port: FlatPlain + CrystalChamber → ONE op + +**§3.1 answered by Jahni: the Z term can go.** So it is gone, and this is the change that makes the +rest worth doing. + +**Two separate changes landed together, deliberately, and the test is what keeps them attributable:** + +1. **The design change** — `GetSlabDensity`'s floor and ceiling noise lost their Z terms + (`WorldZ * FF * 0.05f` → `0.0f`; `WorldZ * CF * 0.08f + 3000.0f` → `3000.0f`, keeping the + decorrelation offset). The world **re-tunes once**: a different slice of the noise field means a + different floor/ceiling shape. Not a degradation — a different draw. +2. **The refactor** — the now-XY-pure function ported to `FSlabVoidSource` + `FGridColumnMod`, + plus the three structural ops. Five ops. + +`SlabEquivalence` compares the stack against `GetSlabDensity` **as it is now**, so: green ⇒ the port +is a pure refactor ⇒ **any visual delta is attributable to the Z-term removal and nothing else.** +That is why both could go in one build without losing the ability to say which one caused what — +the attribution comes from the test, not from the build order. + +**Why this port matters more than its size:** `BuildSlabStack` has **no branch on archetype**, +because `GetSlabDensity` never had one either — CrystalChamber IS FlatPlain with a bigger +`CeilingRoughness`. The test runs the identical battery on both slots, so "two archetypes are one +op" is demonstrated rather than asserted. **8 archetypes → 7.** + +**And the perf claim, which is what §3.1 was really about:** `FSlabVoidSource::ClassifyBox` is +**exact and needs no sampling**. `VoxelNoise::FBM` is contractually `[-1,1]`, so both surfaces live +in Z bands with known bounds — a tile below `FloorZ - FloorAmp` is provably solid, a tile strictly +between the bands is provably air. A slab strate is mostly solid rock below its floor, so this +should prove a large fraction of tiles. `ClassifyTile` proves **zero** today. The test prints the +count per archetype; that number is the whole return on the Z term. + +`FGridColumnMod` returning `Identity` when no column reaches the box is what lets the source's +`AllAir` survive the fold — otherwise columns would kill every air verdict in the strate. + +**UNVERIFIED: none of this is compiled.** Likely error spots, in order: +- `VoxelForgeOpStackSlabTest.cpp` is new — check it is picked up by the module's build. +- The lambda `RunForSlot` captures `World`/`Gen` by reference and calls `AddError`/`TestEqual` on + the test instance; `TestEqual`'s name argument is built with `*FString::Printf(...)`. +- `FGridColumnMod::GetCells` returns a reference to a `thread_local` — intentional (same pattern as + `FLatticeCorridorSource::GetCellEdges`), but it is `const` while mutating the thread_local. +- `static constexpr float ColBlend` used inside `FMath::Max`/comparisons — may need a definition + under older MSVC ODR rules if it is ever odr-used. +- `FSlabGenerationParams` must be complete in `VoxelDensityOpStack.h` (it comes via + `VoxelStrateTypes.h`, already included). + +**Next single action:** build, run `VoxelForge.OpStack.SlabEquivalence`, and **read the two +"proved uniform" numbers** — they are the measured payoff of §3.1. Then tick `bUseOperatorStack` on +a FlatPlain or CrystalChamber strate for the visual A/B. Expect the floor/ceiling shape to have +changed from the Z-term removal; the question is whether it still reads as the same *kind* of place. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp new file mode 100644 index 0000000..76b8b50 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp @@ -0,0 +1,312 @@ +// VoxelForgeOpStackSlabTest.cpp +// PHASE 2, PREMIER PORTAGE — la pile Slab contre GetSlabDensity, sur LES DEUX archétypes. +// PHASE 2'S FIRST PORT — the Slab operator stack against GetSlabDensity, on BOTH archetypes. +// +// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE +// Trois choses, et la troisième est la raison d'être du portage : +// +// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. Même barre que Maze : un changement de +// côté d'isosurface est un ÉCHEC DUR, un écart d'ULP est le plancher accepté. +// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET +// CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a +// besoin d'en faire un, la fusion est fausse et ce test le dit. +// 3. LE VERDICT DE BOÎTE — et c'est ici que §3.1 se paie. `ClassifyTile` prouve ZÉRO tuile pour +// FlatPlain et CrystalChamber aujourd'hui. Depuis que les deux surfaces sont XY-PURES, leurs +// bornes en Z sont connues exactement (contrat [-1,1] de FBM), donc toute tuile entièrement +// sous le sol ou entre les deux bandes se prouve SANS échantillonner. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// ⚠️ CE TEST NE PEUT PAS DÉTECTER LE RETRAIT DU TERME EN Z — et c'est voulu +// ───────────────────────────────────────────────────────────────────────────────────────── +// `GetSlabDensity` a perdu son terme en Z en même temps que ce portage était écrit +// (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni). La pile est comparée à la fonction TELLE +// QU'ELLE EST MAINTENANT, donc ce test dit « le portage est fidèle » et ne dit RIEN sur le +// changement de génération — c'est exactement la séparation voulue : +// +// • ce test vert ⇒ la pile == la fonction de référence. Le portage est un refactor pur. +// • le monde a changé ⇒ imputable au retrait du terme en Z, ET À RIEN D'AUTRE. +// +// Sans cette séparation, un écart visuel serait inattribuable entre « j'ai changé le design » et +// « j'ai raté le portage ». C'est le test qui fait l'attribution, pas l'ordre des builds. +// +// This test compares the stack against the reference function AS IT IS NOW, so green here means the +// port is a pure refactor and ANY visual delta is attributable to the Z-term removal alone. +// +// ⚠️ Et la règle de §C10 tient toujours : ne jamais faire tourner les deux chemins dans le même +// monde, ne jamais comparer leurs sorties pour égalité ailleurs qu'ici. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "Async/ParallelFor.h" +#include "HAL/PlatformMisc.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelDensityOpStack.h" + +#include + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeOpStackSlabTest, + "VoxelForge.OpStack.SlabEquivalence", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 NumSlabSamples = 20000; + constexpr int32 NumSlabTiles = 60; +} + +bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const UVoxelGenerator* Gen = World.Generator.Get(); + + //========================================================================= + // LA BATTERIE, PARAMÉTRÉE PAR ARCHÉTYPE + //========================================================================= + // Exécutée à l'identique sur FlatPlain et CrystalChamber. Si les deux passent avec la MÊME + // pile et la MÊME fabrique, la fusion des deux archétypes est démontrée plutôt qu'affirmée. + auto RunForSlot = [&](int32 SlotIndex, const TCHAR* SlotName) + { + int32 TopVoxelZ = 0, BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(SlotIndex, TopVoxelZ, BottomVoxelZ)) + { + AddError(FString::Printf( + TEXT("The fixture layout has no %s slot. Check FTestWorld::Build's Archetypes[] ") + TEXT("against FTestWorld::Slot%s."), SlotName, SlotName)); + return; + } + + const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE; + const FSlabGenerationParams SlabParams = + World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ)); + + // `GetSlabDensity` court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde + // appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate + // valide, et `GetDensityAt` retombe sur le `switch` dans ce cas. + if (SlabParams.StrateTopWorldZ - SlabParams.StrateBottomWorldZ <= 0.0f) + { + AddError(FString::Printf( + TEXT("%s has degenerate Z bounds (top %.1f, bottom %.1f), which sends GetSlabDensity ") + TEXT("down its early-out. The op stack has no such early-out by design."), + SlotName, SlabParams.StrateTopWorldZ, SlabParams.StrateBottomWorldZ)); + return; + } + + FVoxelOpStack Stack; + VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + + // La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels. + TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName), + Stack.Num(), 5); + + FVoxelOpContext Ctx; + Ctx.Seed = (uint32)World.Settings->Seed; + Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion(); + Ctx.StrateTopWorldZ = SlabParams.StrateTopWorldZ; + Ctx.StrateBottomWorldZ = SlabParams.StrateBottomWorldZ; + Stack.PrepareChunk(Ctx); + + TArray Points; + Points.Reserve(NumSlabSamples); + { + FRandomStream Rng(31337 + SlotIndex); + for (int32 i = 0; i < NumSlabSamples; ++i) + { + Points.Add(FVector( + (float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE), + (float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE), + (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ))); + } + } + + //===================================================================== + // 1. ÉQUIVALENCE — géométrie d'abord, bits ensuite. + //===================================================================== + int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0; + float WorstDelta = 0.0f; + for (int32 i = 0; i < NumSlabSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; + + const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide + const float New = Stack.EvalMC(X, Y, Z); + + if (!BitEqual(Old, New)) + { + ++NumDiff; + const float Delta = FMath::Abs(Old - New); + if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; } + + // Même forme que le carve de Maze : `(ColBlend - ColumnSDF)` annule au bord de la + // coquille de blend des colonnes, donc un ULP amont ressort amplifié. Marge + // généreuse mais BORNÉE — au-delà, c'est une vraie dérive de portage. + const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON; + if (Delta > UlpNoise) { ++NumBeyondUlpNoise; } + } + // Le mesher ne lit que le SIGNE. Un désaccord de CÔTÉ bouge la géométrie. + if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; } + } + + if (NumDiff == 0) + { + AddInfo(FString::Printf(TEXT("%s: bit-identical across %d samples."), + SlotName, NumSlabSamples)); + } + else if (NumBeyondUlpNoise == 0) + { + AddInfo(FString::Printf( + TEXT("%s: %d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at ") + TEXT("(%.0f, %.0f, %.0f)), and 0 cross the isosurface. Same accepted floor as Maze ") + TEXT("-- see AUDIT-2026-07.md C10 before hunting it."), + SlotName, NumDiff, NumSlabSamples, WorstDelta, + WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f)); + } + else + { + AddWarning(FString::Printf( + TEXT("%s: %d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor ") + TEXT("(largest |delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS is ") + TEXT("real port drift. Check, in order: the floor/ceiling noise offsets (7.3/11.1 and ") + TEXT("17.3+1000/19.7+2000/3000), the abs() on the ceiling noise, the ceiling clamp ") + TEXT("(FloorSurface + 2), the column blend (2.0) and the 0.15/0.7 jitter."), + SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise, WorstDelta, + WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f, + NumSolidDisagreements)); + } + + TestEqual(*FString::Printf( + TEXT("%s: no sample lands on the opposite side of the isosurface"), SlotName), + NumSolidDisagreements, 0); + + //===================================================================== + // 2. INVARIANCE DE FENÊTRE + //===================================================================== + // Le cache 3×3 des colonnes est `thread_local` et sa clé n'est PAS le chunk mais le jeu de + // params + le seed. Si cette clé est incomplète, la couture apparaît ici. + { + std::atomic Impure{ 0 }; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + + TArray Ref; + Ref.SetNumUninitialized(NumSlabSamples); + for (int32 i = 0; i < NumSlabSamples; ++i) + { + Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + } + + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray LocalOrder; + BuildShuffledOrder(NumSlabSamples, 700 + Block + SlotIndex * 32, LocalOrder); + for (const int32 i : LocalOrder) + { + const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); } + } + }); + + TestEqual(*FString::Printf( + TEXT("%s: the op stack is window-invariant across order and threads"), SlotName), + Impure.load(), 0); + } + + //===================================================================== + // 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté + //===================================================================== + { + int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; + FRandomStream Rng(24680 + SlotIndex); + + for (int32 t = 0; t < NumSlabTiles; ++t) + { + const int32 Step = 1, Cells = 8; + const int32 Extent = Step * Cells; + const FIntVector Origin( + Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-6, 6) * Extent, + FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); + + const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise + const FBox Box( + FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step), + FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step)); + + const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx); + if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; } + ++NumProved; + + const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); + for (int32 gz = -1; gz <= GridDim; ++gz) + for (int32 gy = -1; gy <= GridDim; ++gy) + for (int32 gx = -1; gx <= GridDim; ++gx) + { + const float X = (float)(Origin.X + gx * Step); + const float Y = (float)(Origin.Y + gy * Step); + const float Z = (float)(Origin.Z + gz * Step); + const float D = Stack.EvalMC(X, Y, Z); + if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) + { + if (NumUnsound == 0) + { + AddError(FString::Printf( + TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ") + TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ") + TEXT("ops is not conservative. Suspects, in order: the slab source's ") + TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ") + TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ") + TEXT("mod's reach (MaxRadius + blend)."), + SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + Origin.X, Origin.Y, Origin.Z, X, Y, Z, D, + (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); + } + ++NumUnsound; + gz = gy = gx = GridDim + 1; + } + } + } + + TestEqual(*FString::Printf( + TEXT("%s: every box verdict survives brute force (a false verdict is a hole)"), + SlotName), + NumUnsound, 0); + + AddInfo(FString::Printf( + TEXT("%s box verdicts over %d tiles: %d proved uniform, %d Mixed. Today's ") + TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ") + TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."), + SlotName, NumSlabTiles, NumProved, NumMixed)); + + if (NumProved == 0) + { + AddWarning(FString::Printf( + TEXT("%s proved no tile uniform. Not a correctness problem, but the entire perf ") + TEXT("case for dropping the Z term rests on this number being well above zero -- ") + TEXT("a slab is mostly solid rock below the floor. Check that the sampled tile Z ") + TEXT("range actually reaches below FloorZ - FloorAmp."), SlotName)); + } + } + }; + + RunForSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain")); + RunForSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber")); + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index bcce241..46b17ce 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -221,6 +221,152 @@ namespace float ExtraReach; }; + //========================================================================= + // RÔLE 1 — SOURCE : DALLE / SLAB VOID (FlatPlain ET CrystalChamber) + //========================================================================= + // Transcription littérale des ÉTAPES 1-3 de `GetSlabDensity` : surface de sol, surface de + // plafond, puis `Density = -min(distAuSol, distAuPlafond)`. + // + // DEUX archétypes, UN opérateur. `GetSlabDensity` est appelé pour FlatPlain et + // CrystalChamber sans le moindre branchement sur le type — CrystalChamber n'est rien d'autre + // que FlatPlain avec un `CeilingRoughness` plus grand. C'est le premier vrai gain du refactor + // (OPSTACK-PLAN §4) : deux des huit archétypes disparaissent dans un seul opérateur, et la + // différence entre eux redevient ce qu'elle a toujours été — un jeu de valeurs par défaut. + // + // Two archetypes, ONE op: GetSlabDensity is called for both with no branch on the type. + // CrystalChamber IS FlatPlain with a bigger CeilingRoughness. + // + // XY-PUR depuis §3.1 (le terme en Z des deux bruits est parti). C'est ce qui rend + // `ClassifyBox` exact plutôt qu'estimé — voir plus bas. + class FSlabVoidSource final : public IVoxelDensityOp + { + public: + FSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) + : SeedF((float)Seed) + , FloorRoughness(P.FloorRoughness) + , FloorFrequency(P.FloorRoughnessFrequency) + , CeilRoughness(P.CeilingRoughness) + , CeilFrequency(P.CeilingRoughnessFrequency) + { + const float StrateHeight = P.StrateTopWorldZ - P.StrateBottomWorldZ; + FloorZ = P.StrateBottomWorldZ + StrateHeight * P.FloorRelativeHeight; + CeilZ = P.StrateBottomWorldZ + StrateHeight * P.CeilingRelativeHeight; + + // Amplitudes MAXIMALES des deux bruits. Le contrat de `VoxelNoise::FBM` est [-1,1] + // (noté à sa définition), donc ces bornes sont des garanties, pas des estimations — + // c'est exactement ce qui autorise un verdict de boîte SÛR. + // FBM's contract is [-1,1], so these bounds are guarantees, not estimates. + FloorAmp = VOXEL_NOISE_SCALE * FMath::Max(FloorRoughness, 0.0f); + CeilAmp = VOXEL_NOISE_SCALE * FMath::Max(CeilRoughness, 0.0f); + } + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + void PrepareChunk(const FVoxelOpContext&) override {} + + // ⚠️ LE point de §3.1. Faux avant le retrait du terme en Z ; le déclarer alors aurait + // corrompu silencieusement toute la pile verticale de chunks (voir l'avertissement sur + // `IsXYPure` dans VoxelDensityOp.h). + bool IsXYPure() const override { return true; } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float FloorSurface = SurfaceFloor(WorldX, WorldY); + const float CeilSurface = SurfaceCeil(WorldX, WorldY, FloorSurface); + + const float DistAboveFloor = WorldZ - FloorSurface; + const float DistBelowCeil = CeilSurface - WorldZ; + const float VoidField = FMath::Min(DistAboveFloor, DistBelowCeil); + + InOut.Density = -VoidField; // Replace : interne, positif = solide + } + + //--------------------------------------------------------------------- + // LE VERDICT QUE FLATPLAIN N'A JAMAIS EU + //--------------------------------------------------------------------- + // `ClassifyTile` ne prouve AUCUNE tuile pour les archétypes de grotte aujourd'hui. Ici la + // preuve est immédiate et n'exige aucun échantillonnage : les deux surfaces vivent dans des + // BANDES en Z dont on connaît les bornes exactes, donc une boîte entièrement sous la bande + // du sol est solide, et une boîte entièrement entre les deux bandes est de l'air. + // + // ⚠️ Conservatif dans le bon sens : rendre `Mixed` ne coûte que du CPU, rendre le mauvais + // verdict est un TROU. Toutes les comparaisons ci-dessous sont donc strictes et prennent le + // pire cas des deux bruits. + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + const float ZMin = (float)VoxelBox.Min.Z; + const float ZMax = (float)VoxelBox.Max.Z; + + // Bornes de la surface de sol : FloorZ ± FloorAmp. + const float FloorLo = FloorZ - FloorAmp; + const float FloorHi = FloorZ + FloorAmp; + + // Bornes du plafond. `CeilNoise = |bruit| · rugosité` ∈ [0, CeilAmp] ⇒ la surface ne + // peut que DESCENDRE depuis CeilZ… sauf que le clamp `Max(…, FloorSurface + 2)` peut la + // remonter. Le majorant honnête est donc le max des deux possibilités. + const float CeilLo = CeilZ - CeilAmp; + const float CeilHi = FMath::Max(CeilZ, FloorHi + 2.0f); + + // Sous le sol le plus bas possible ⇒ distAuSol < 0 partout ⇒ densité > 0 ⇒ SOLIDE. + if (ZMax < FloorLo) { return EVoxelTileClass::AllSolid; } + + // Au-dessus du plafond le plus haut possible ⇒ distAuPlafond < 0 ⇒ SOLIDE. + if (ZMin > CeilHi) { return EVoxelTileClass::AllSolid; } + + // Strictement entre les deux bandes ⇒ les deux distances sont > 0 ⇒ densité < 0 ⇒ AIR. + // (Les colonnes peuvent re-remplir cet air : c'est FGridColumnMod qui le déclare, en + // rendant FillOnly quand une colonne atteint la boîte. Le pliage s'en charge.) + if (ZMin > FloorHi && ZMax < CeilLo) { return EVoxelTileClass::AllAir; } + + return EVoxelTileClass::Mixed; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant + } + + private: + // Les deux surfaces, transcrites au caractère près depuis GetSlabDensity — y compris le + // détour par FVector, qui est le même piège d'arrondi que dans FSdfRoughnessMod + // (float → double → float sous /fp:fast). Ne pas « simplifier ». + float SurfaceFloor(float WorldX, float WorldY) const + { + if (FloorRoughness <= 0.0f) { return FloorZ; } + const float FF = FloorFrequency; + const FVector NoisePos(WorldX * FF + SeedF * 7.3f, + WorldY * FF + SeedF * 11.1f, + 0.0f); + const float N = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + VoxelGenLOD::Eff(3), 2.0f, 0.5f) + * VOXEL_NOISE_SCALE * FloorRoughness; + return FloorZ + N; + } + + float SurfaceCeil(float WorldX, float WorldY, float FloorSurface) const + { + float CeilNoise = 0.0f; + if (CeilRoughness > 0.0f) + { + const float CF = CeilFrequency; + const FVector NoisePos(WorldX * CF + SeedF * 17.3f + 1000.0f, + WorldY * CF + SeedF * 19.7f + 2000.0f, + 3000.0f); + const float Raw = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + VoxelGenLOD::Eff(3), 2.0f, 0.5f) + * VOXEL_NOISE_SCALE; + // abs() ⇒ les formations ne pendent QUE vers le bas. + CeilNoise = FMath::Abs(Raw) * CeilRoughness; + } + return FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f); + } + + float SeedF; + float FloorZ = 0.0f, CeilZ = 0.0f; + float FloorRoughness, FloorFrequency; + float CeilRoughness, CeilFrequency; + float FloorAmp = 0.0f, CeilAmp = 0.0f; + }; + //========================================================================= // RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE SDF //========================================================================= @@ -275,6 +421,148 @@ namespace float ApplyWithin; }; + //========================================================================= + // RÔLE 3 — MODIFIER : COLONNES SUR GRILLE MONDE / WORLD-GRID COLUMNS + //========================================================================= + // ÉTAPE 4 de `GetSlabDensity`. Des cylindres de hauteur infinie posés sur une grille de + // `ColumnSpacing`, un tirage d'existence et un jitter par cellule. Le champ de vide décide déjà + // où est le solide, donc la colonne n'a qu'à AJOUTER de la densité le long de son XY — elle + // n'est visible que là où le vide avait creusé autour d'elle. + // + // Le cache 3×3 par cellule est repris tel quel (il était déjà `thread_local` dans l'original, + // et c'est exactement ce que la note de threading de VoxelDensityOp.h autorise). Sa clé + // contient tous les paramètres qui influent sur le résultat + le seed, donc un changement de + // layout qui change un param invalide bien ; un changement qui n'en touche aucun produirait + // des colonnes identiques (cf. AUDIT C2 — la clé est complète, pas seulement le coord). + class FGridColumnMod final : public IVoxelDensityOp + { + public: + explicit FGridColumnMod(const FSlabGenerationParams& P, int32 InSeed) + : Seed((uint32)InSeed) + , Spacing(P.ColumnSpacing) + , ColDensity(P.ColumnDensity) + , MinRadius(P.ColumnMinRadius) + , MaxRadius(P.ColumnMaxRadius) + , BaseDensity(P.BaseDensity) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + void PrepareChunk(const FVoxelOpContext&) override {} + bool IsXYPure() const override { return true; } // cylindres de hauteur infinie + + void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override + { + if (ColDensity <= 0.0f || Spacing <= 0.0f) { return; } + + const int32 ColCX = FMath::FloorToInt(WorldX / Spacing); + const int32 ColCY = FMath::FloorToInt(WorldY / Spacing); + + const TArray>& Cols = GetCells(ColCX, ColCY); + + float ColumnSDF = FLT_MAX; + for (const FSlabColumn& Col : Cols) + { + const float DX2D = WorldX - Col.X; + const float DY2D = WorldY - Col.Y; + ColumnSDF = FMath::Min(ColumnSDF, FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - Col.R); + } + + if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX) + { + float Fill = FMath::Clamp((ColBlend - ColumnSDF) / (ColBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + InOut.Density += Fill * BaseDensity * 1.5f; + } + } + + // N'AJOUTE que du solide ⇒ tue AllAir, jamais AllSolid. `Identity` dès qu'aucune colonne + // n'atteint la boîte — ce qui, pour un `ColumnDensity` de 0.08, est l'écrasante majorité du + // volume. C'est cet `Identity` qui laisse survivre le verdict AllAir de la source. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (ColDensity <= 0.0f || Spacing <= 0.0f) { return EVoxelOpEffect::Identity; } + + // Marge : le centre d'une colonne vit dans sa cellule, son influence porte au plus + // MaxRadius + ColBlend. Sur-estimer coûte du CPU ; sous-estimer serait un trou. + const float Reach = FMath::Max(MaxRadius, 0.0f) + ColBlend; + + const int32 CX0 = FMath::FloorToInt(((float)VoxelBox.Min.X - Reach) / Spacing); + const int32 CX1 = FMath::FloorToInt(((float)VoxelBox.Max.X + Reach) / Spacing); + const int32 CY0 = FMath::FloorToInt(((float)VoxelBox.Min.Y - Reach) / Spacing); + const int32 CY1 = FMath::FloorToInt(((float)VoxelBox.Max.Y + Reach) / Spacing); + + for (int32 CY = CY0; CY <= CY1; ++CY) + { + for (int32 CX = CX0; CX <= CX1; ++CX) + { + FSlabColumn Col; + if (!RollColumn(CX, CY, Col)) { continue; } + + // Cercle (rayon + blend) contre le rectangle XY de la boîte. + const float R = Col.R + ColBlend; + const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Col.X, + Col.X - (float)VoxelBox.Max.X)); + const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Col.Y, + Col.Y - (float)VoxelBox.Max.Y)); + if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; } + } + } + return EVoxelOpEffect::Identity; + } + + private: + struct FSlabColumn { float X, Y, R; }; + + static constexpr float ColBlend = 2.0f; // identique à GetSlabDensity + + /** Le tirage d'une cellule : existence, jitter, rayon. Fonction PURE de (cellule, seed, + * params) — donc `Eval` et `EffectOverBox` voient forcément la même colonne. */ + bool RollColumn(int32 CX, int32 CY, FSlabColumn& Out) const + { + const uint32 H = VoxelHash::Cell(CX, CY, Seed ^ 0xC01C01u); + if (VoxelHash::ToFloat01(H) > ColDensity) { return false; } + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + + Out.X = (CX + 0.15f + JX * 0.7f) * Spacing; + Out.Y = (CY + 0.15f + JY * 0.7f) * Spacing; + Out.R = FMath::Lerp(MinRadius, MaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + return true; + } + + /** Le voisinage 3×3 de la cellule centrale, mémoïsé par worker. */ + const TArray>& GetCells(int32 ColCX, int32 ColCY) const + { + thread_local TArray> SC_Cols; + thread_local int32 SC_CX = INT32_MAX, SC_CY = INT32_MAX; + thread_local uint32 SC_Seed = 0xFFFFFFFFu; + thread_local float SC_Spacing = -1.0f, SC_Dens = -1.0f, SC_MinR = -1.0f, SC_MaxR = -1.0f; + + if (ColCX != SC_CX || ColCY != SC_CY || Seed != SC_Seed || Spacing != SC_Spacing || + ColDensity != SC_Dens || MinRadius != SC_MinR || MaxRadius != SC_MaxR) + { + SC_CX = ColCX; SC_CY = ColCY; SC_Seed = Seed; SC_Spacing = Spacing; + SC_Dens = ColDensity; SC_MinR = MinRadius; SC_MaxR = MaxRadius; + SC_Cols.Reset(); + + for (int32 DY = -1; DY <= 1; DY++) + { + for (int32 DX = -1; DX <= 1; DX++) + { + FSlabColumn Col; + if (RollColumn(ColCX + DX, ColCY + DY, Col)) { SC_Cols.Add(Col); } + } + } + } + return SC_Cols; + } + + uint32 Seed; + float Spacing, ColDensity, MinRadius, MaxRadius, BaseDensity; + }; + //========================================================================= // RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE) //========================================================================= @@ -488,6 +776,30 @@ namespace VoxelDensityOps return MakeUnique(Blend, BaseDensity); } + TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) + { + return MakeUnique(P, Seed); + } + + TUniquePtr MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed) + { + return MakeUnique(P, Seed); + } + + void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + { + // DEUX archétypes entrent ici, aucun branchement ne les distingue — parce que + // `GetSlabDensity` n'en fait aucun non plus. FlatPlain et CrystalChamber ne diffèrent que + // par leurs valeurs par défaut, et c'est maintenant visible dans le code plutôt que dans + // un commentaire. 8 archétypes → 7. + OutStack.Add(MakeSlabVoidSource(P, Seed)); + OutStack.Add(MakeGridColumnMod(P, Seed)); + + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + } + void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) { diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 26e84cc..0dcec96 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -562,6 +562,22 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, Seed, OriginSpineRadius, StrateManager); break; + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + // UN SEUL cas pour les deux, comme le `switch` de production juste en dessous : + // `GetSlabDensity` ne les distingue pas non plus. Voir BuildSlabStack. + // Même garde de strate dégénérée : GetSlabDensity court-circuite sur `1.0f`. + if (CP_Slab.StrateTopWorldZ - CP_Slab.StrateBottomWorldZ <= 0.0f) + { + CP_UseOpStack = false; + break; + } + OpCtx.StrateTopWorldZ = CP_Slab.StrateTopWorldZ; + OpCtx.StrateBottomWorldZ = CP_Slab.StrateBottomWorldZ; + VoxelDensityOps::BuildSlabStack(CP_OpStack, CP_Slab, Seed, + OriginSpineRadius, StrateManager); + break; default: // UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc // on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch` @@ -1807,8 +1823,17 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, // Signed noise allows both hills (noise > 0 → floor rises) and // valleys (noise < 0 → floor dips) for natural rolling ground. // - // Z frequency is set very low (5% of XY) so the floor features are - // broad and horizontal — like natural geological ground, not bumpy walls. + // ⚠️ XY-PUR / XY-PURE (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni 2026-07-27). + // La 3e coordonnée était `WorldZ * FF * 0.05f` : une hauteur de sol qui dépendait de + // l'altitude d'où on la demandait. Le coefficient était minuscule, donc ça se lisait comme un + // léger étirement vertical plutôt que comme un bug — mais ça bloquait le cache de colonnes T1.a + // et rendait toute classification de boîte inexacte. Constante ⇒ la surface est une vraie + // fonction de (X,Y). Le monde se re-tune une fois : on échantillonne une autre tranche du champ + // de bruit, donc la forme du sol change (elle ne se dégrade pas). + // + // The 3rd coord was WorldZ * FF * 0.05f — a floor height that depended on the altitude you + // asked from. Now a constant, so the surface is a genuine function of (X,Y): the T1.a column + // cache and an exact box verdict both become available. Worlds re-tune once. const float FloorZ = Params.StrateBottomWorldZ + StrateHeight * Params.FloorRelativeHeight; @@ -1819,7 +1844,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, FloorNoise = FractalNoise3D(FVector( WorldX * FF + SeedF * 7.3f, WorldY * FF + SeedF * 11.1f, - WorldZ * FF * 0.05f // Very low Z freq → horizontal ground features + 0.0f // XY-pur : plus aucune dépendance en Z / no Z dependence ), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.FloorRoughness; } @@ -1836,9 +1861,10 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, // This asymmetry (only downward protrusions, never upward pockets) creates // the crystal-forest / stalactite silhouette from below. // - // Z frequency is also low so formations have horizontal extent — each - // "crystal" or "stalactite" is wide and sweeps across the ceiling, not - // a sharp spike (use high frequency for spike-like features if desired). + // XY-PUR, même raison que le sol ci-dessus (§3.1). Le `+ 3000.0f` RESTE : ce n'est pas un + // terme en Z, c'est le décalage qui décorrèle le champ du plafond de celui du sol. + // XY-pure for the same reason as the floor. The + 3000.0f STAYS — it is not a Z term, it is + // the offset that decorrelates the ceiling's noise field from the floor's. const float CeilZ = Params.StrateBottomWorldZ + StrateHeight * Params.CeilingRelativeHeight; @@ -1849,7 +1875,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, float RawNoise = FractalNoise3D(FVector( WorldX * CF + SeedF * 17.3f + 1000.0f, WorldY * CF + SeedF * 19.7f + 2000.0f, - WorldZ * CF * 0.08f + 3000.0f // Low Z freq → formations extend horizontally + 3000.0f // XY-pur : décalage de décorrélation seul / offset only ), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE; // abs() → formations ONLY hang down, never push ceiling up into solid rock. diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 879d3d1..8faadb0 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -571,8 +571,10 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord // and falls back to the switch, so the box can be ticked anywhere without breaking anything. switch (Def->GeneratorType) { - case ECaveGeneratorType::Maze: return true; - default: return false; + case ECaveGeneratorType::Maze: return true; // Phase 1 + case ECaveGeneratorType::FlatPlain: // Phase 2 — les deux partagent + case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack) + default: return false; } } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 8d23054..b3ae2ff 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -183,6 +183,28 @@ namespace VoxelDensityOps * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */ VOXELFORGE_API TUniquePtr MakeSdfCarve(float Blend, float BaseDensity); + /** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis + * OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un + * `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z + * bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */ + VOXELFORGE_API TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed); + + /** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒ + * `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */ + VOXELFORGE_API TUniquePtr MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed); + + /** + * FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** : + * SlabVoidSource → GridColumnMod → [structural post ×3] + * + * C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes + * disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans + * `GetSlabDensity` — un jeu de valeurs par défaut, pas du code. + */ + VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager); + /** * La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique : * ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]