diff --git a/CODEMAP.md b/CODEMAP.md index eb48812..611719b 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -138,6 +138,9 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). | `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. | +| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. | +| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. | +| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | | `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.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp` diff --git a/OPSTACK-PLAN.md b/OPSTACK-PLAN.md index 2c4b856..2c96455 100644 --- a/OPSTACK-PLAN.md +++ b/OPSTACK-PLAN.md @@ -4,11 +4,20 @@ > *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 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 +> **Status (2026-07-27):** **Phases 0.5 and 1 DONE and verified. Phase 2 is 5 of 8 archetypes in**, +> all bit-identical to their originals and all wired behind `bUseOperatorStack`: +> **Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes included) · VerticalShafts.** +> Remaining: `FloatingIslands`, `TunnelNetwork` (**last** — it owns the §8.4 window-invariance +> discipline), `Underwater` (TunnelNetwork + a flag). +> +> Two things came out of Phase 2 that were not in the original design: **height space** +> (`VoxelHeightOp.h`, a second operator family — some things are not another channel but another +> *space*) and **`IVoxelBiomeField`** (ops depend on a capability, never on the generator, which is +> what lets them become assets in Phase 3). Both are described in `OPSTACK-DECOMPOSITION §5`. +> +> **Known open:** generation is measurably slower on the op path (one fix landed — the column memo +> was discarding itself every chunk; virtual dispatch and the hashed lookup remain). Deferred by +> Jahni until the transition is complete. 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). > @@ -391,7 +400,7 @@ Port each archetype **the next time a feature makes you open it anyway**. The sw 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; **done 2026-07-27**, `BuildSlabStack`, 8 archetypes → 7) → `SurfaceWorld` (biggest payoff, biggest care: the T1.a column cache and the exact- +archetypes collapse into one; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld` (**done**, incl. biomes — needed a whole second op family, `VoxelHeightOp.h`) → ✅ `VerticalShafts` (**done**, 3 ops reused from Maze unchanged) (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 48e3e03..8cbe799 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1676,3 +1676,50 @@ cache's direct indexing vs. my hash, then per-voxel virtual calls. the flag off. Then `VerticalShafts` (§6). --- + +## 2026-07-27 — VerticalShafts ported. 5 of 8, and operator reuse is now MEASURED. + +**The port that tests the thesis rather than the fidelity.** Every previous port asked "does the +decomposition reproduce the original?". This one asks **"do operators actually get reused across +archetypes?"** — which is `OPSTACK-PLAN §2.5`'s claim and the only reason to do this refactor rather +than tidy the `switch`. + +**Three of the five ops are Maze's, reused without a line changed:** `ConstantRock`, `SdfRoughness`, +`SdfCarve`. In the `switch`, `GetMazeDensity` and `GetVerticalShaftDensity` are two ~100-line +functions with nothing visibly in common. As operators they are the **same three ops with a +different source and different tuning** — frequency 0.1 instead of 0.12, window `rough + 4` instead +of `R + rough + 2`. If the test is bit-identical, reuse stops being an intention and becomes a +measurement. + +**Two new ops:** `FShaftFieldSource` (infinite cylinders + hash-gated connectors → SDF) and +`FShaftLedgeMod` (banded shelves on the +X/+Y half only, so the shaft stays climbable). + +**Deviation from `§6`, stated:** it suggested splitting the source in two (XY-pure cylinders + +connectors) so the cylinder half could get an exact XY box verdict. Kept as one op, because the +connectors derive from the *same* 3×3 roll as the shafts and the ledge mod needs the shaft list +anyway — splitting would mean rolling twice or sharing a cache between two ops. What is forfeited is +the exact verdict on the cylinder half alone; what is kept is a conservative `EffectOverBox` that +tests circles *and* connector reach. Revisit if the profile says it matters. + +**One subtlety worth flagging:** `FShaftLedgeMod` gates on `InOut.Sdf < 0` — the SDF **after** +roughness, as the pile left it. Re-deriving the SDF there would give the pre-roughness value and +shift every ledge. The op reads the channel rather than recomputing, which is exactly what the +two-channel `FVoxelOpSample` is for. + +**Compile fix on the way in:** `FCells` was declared at the bottom of the class but returned by +functions above it. Member *bodies* are deferred; *return types* are not — C4430 plus an unreadable +cascade from a trivial cause. Moved up beside `FShaft`/`FConn`, with a note. + +**Ported: Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes incl.) · VerticalShafts — 5 of 8.** +Remaining: `FloatingIslands` (§7), `Underwater` (§8, TunnelNetwork + a flag), and `TunnelNetwork` +(§2) **last**, because it owns `BuildChunkCache`'s two-region window-invariance discipline (§8.4), +the most delicate code in the plugin. + +**UNVERIFIED:** the shaft port is not compiled past the `FCells` fix. + +**Next single action:** build, run the `VoxelForge` filter — 11 tests now, the new one is +`VoxelForge.OpStack.VerticalShaftEquivalence`. Then `FloatingIslands`. **Perf is deliberately parked +until the transition is complete** (Jahni's call); the open item is that the op path is slower, with +virtual dispatch and the hashed column lookup as the remaining suspects. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp new file mode 100644 index 0000000..a1fb92a --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp @@ -0,0 +1,271 @@ +// VoxelForgeOpStackShaftTest.cpp +// VerticalShafts — le portage qui teste la RÉUTILISATION, pas seulement la fidélité. +// VerticalShafts — the port that tests REUSE, not just fidelity. +// +// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES +// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci +// demande **« les opérateurs se RÉUTILISENT-ils vraiment entre archétypes ? »**, qui est la thèse +// de `OPSTACK-PLAN §2.5` et la seule raison de faire ce refactor plutôt que de nettoyer le `switch`. +// +// Trois des cinq opérateurs de VerticalShafts sont ceux de Maze, **repris sans une ligne de +// changement** : `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, `GetMazeDensity` et +// `GetVerticalShaftDensity` sont deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil. +// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages +// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`). +// +// **Si ce test passe en bit-à-bit, la réutilisation n'est plus une intention : c'est une mesure.** +// +// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10). Un écart +// est une vraie trouvaille, pas du bruit d'arrondi. + +#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( + FVoxelForgeOpStackShaftTest, + "VoxelForge.OpStack.VerticalShaftEquivalence", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 NumShaftSamples = 20000; + + /** Les ledges et les connecteurs sont éteints ou discrets par défaut. Un test sur les seuls + * défauts vérifierait les cylindres et laisserait les DEUX opérateurs intéressants au repos — + * le même piège que `WaterLevelRelative` et la fenêtre d'overhang. */ + void EnableShaftFeatures(FVerticalShaftParams& P) + { + P.CrossConnectChance = 0.65f; // des connecteurs, donc des capsules dans le SDF + P.ConnectorRadius = 3.5f; + P.LedgeSpacing = 11.0f; // des étagères, donc l'op forçant s'exécute + P.LedgeDepth = 2.5f; + P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze + } +} + +bool FVoxelForgeOpStackShaftTest::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(); + + int32 TopVoxelZ = 0, BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("The fixture layout has no VerticalShafts slot. Check FTestWorld::Build's ") + TEXT("Archetypes[] against FTestWorld::SlotVerticalShafts.")); + return false; + } + + const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE; + FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk( + FIntVector(0, 0, MidChunkZ)); + + if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f) + { + AddError(TEXT("The VerticalShafts strate has degenerate Z bounds, which sends ") + TEXT("GetVerticalShaftDensity down its early-out. The op stack has none by design.")); + return false; + } + + EnableShaftFeatures(P); + + FVoxelOpStack Stack; + VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + + // rock + shafts + roughness + carve + ledges + 3 structurels. + TestEqual(TEXT("the shaft stack is decomposed into 8 ops"), Stack.Num(), 8); + + FVoxelOpContext Ctx; + Ctx.Seed = (uint32)World.Settings->Seed; + Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion(); + Ctx.StrateTopWorldZ = P.StrateTopWorldZ; + Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ; + Stack.PrepareChunk(Ctx); + + TArray Points; + Points.Reserve(NumShaftSamples); + { + FRandomStream Rng(80486); + for (int32 i = 0; i < NumShaftSamples; ++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 + //========================================================================= + int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0; + float WorstDelta = 0.0f; + + for (int32 i = 0; i < NumShaftSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; + + const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P); + const float New = Stack.EvalMC(X, Y, Z); + + if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère + + if (!BitEqual(Old, New)) + { + ++NumDiff; + const float D = FMath::Abs(Old - New); + if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; } + } + if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; } + } + + if (NumDiff == 0) + { + AddInfo(FString::Printf( + TEXT("VerticalShafts: bit-identical across %d samples (%d of them inside a shaft, so ") + TEXT("the cylinders, connectors, roughness, carve and ledges were all exercised). ") + TEXT("THREE of the five ops here are Maze's, reused unchanged -- operator reuse across ") + TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."), + NumShaftSamples, NumInsideShaft)); + } + else + { + AddError(FString::Printf( + TEXT("VerticalShafts: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ") + TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ") + TEXT("this is a real port error. Check, in order: the roughness FREQUENCY (0.1 here, ") + TEXT("NOT Maze's 0.12) and window (rough + 4, not R + rough + 2), the 'Shft' salt ") + TEXT("(0x53686674), the connector pair hash and its Z lerp between sealed bounds, and ") + TEXT("the ledge gate reading the POST-roughness Sdf rather than re-deriving it."), + NumDiff, NumShaftSamples, WorstDelta, + WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f, + NumSideDisagree)); + } + + TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0); + + if (NumInsideShaft == 0) + { + AddWarning(TEXT("No sample landed inside a shaft, so the source, carve and ledge ops were ") + TEXT("never meaningfully exercised. Raise ShaftDensity or ShaftMaxRadius.")); + } + + //========================================================================= + // 2. INVARIANCE DE FENÊTRE + //========================================================================= + // La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est + // exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2). + { + std::atomic Impure{ 0 }; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + + TArray Ref; + Ref.SetNumUninitialized(NumShaftSamples); + for (int32 i = 0; i < NumShaftSamples; ++i) + { + Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + } + + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray LocalOrder; + BuildShuffledOrder(NumShaftSamples, 2200 + Block, 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(TEXT("the shaft stack is window-invariant across order and threads"), + Impure.load(), 0); + } + + //========================================================================= + // 3. LE VERDICT DE BOÎTE + //========================================================================= + { + int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; + FRandomStream Rng(13579); + + for (int32 t = 0; t < 60; ++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; + 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: the shaft stack claimed %s for the box at (%d,%d,%d) but ") + TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ") + TEXT("order: the shaft source's ExtraReach (does it cover the roughness ") + TEXT("amplitude AND the carve blend?), then the connector sweep (a ") + TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ") + TEXT("op's FillOnly."), + 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(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0); + + AddInfo(FString::Printf( + TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ") + TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ") + TEXT("prouvable en v1\"."), + NumProved, NumMixed)); + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 598db25..933c222 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -1138,6 +1138,264 @@ namespace float Base, Seal; }; + //========================================================================= + // RÔLE 1 — SOURCE : PUITS VERTICAUX / VERTICAL SHAFTS + //========================================================================= + // Cylindres infinis sur une grille XY jitterée + connecteurs horizontaux entre paires proches, + // ouverts par un hash de paire symétrique. Écrit le canal SDF uniquement. + // + // ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §6`, qui suggérait DEUX sources (colonnes XY-pures + // + connecteurs) pour que la moitié cylindrique reçoive le traitement du cache de colonne et un + // `ClassifyBox` exact en XY. Gardé en UN opérateur, et voici pourquoi : + // • les connecteurs se dérivent de la MÊME liste 3×3 que les puits (il faut les paires), donc + // séparer imposerait soit de rouler les cellules deux fois, soit un cache partagé entre + // deux ops — c'est-à-dire la complexité qu'on voulait éviter ; + // • le `FShaftLedgeMod` en aval a de toute façon besoin de la liste des puits, donc il faut + // l'exposer depuis une source ; l'exposer depuis deux serait pire. + // Ce qui est perdu : le verdict de boîte exact sur la seule moitié cylindrique. Ce qui est + // gardé : un `EffectOverBox` conservatif qui teste cercles ET capsules, ce que la version + // séparée aurait dû faire aussi. À revoir si le profil montre que ça compte. + // + // Kept as ONE op against §6's suggestion: the connectors derive from the same 3×3 roll as the + // shafts, and the downstream ledge mod needs the shaft list anyway. What is forfeited is an + // exact XY box verdict on the cylinder half alone. + class FShaftFieldSource final : public IVoxelDensityOp + { + public: + FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InExtraReach) + : P(InP), Salt((uint32)Seed ^ 0x53686674u) // 'Shft' — identique à GetVerticalShaftDensity + , ExtraReach(InExtraReach) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + void PrepareChunk(const FVoxelOpContext&) override {} + + struct FShaft { float X, Y, R; }; + struct FConn { FVector A, B; }; + + // ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie. Un type imbriqué doit exister au + // moment où le COMPILATEUR lit la SIGNATURE — les corps de méthodes sont différés, pas les + // types de retour. La mettre en bas de la classe donne un C4430 « int par défaut » suivi + // d'une cascade illisible, ce qui masque une cause pourtant triviale. + // Declared here, before any function returning it: a nested type must exist when the + // compiler reads the SIGNATURE — bodies are deferred, return types are not. + struct FCells + { + TArray> Shafts; + TArray> Conns; + }; + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const FCells& C = GetCells(WorldX, WorldY); + + float CaveSDF = FLT_MAX; + for (const FShaft& Sh : C.Shafts) + { + const float DX = WorldX - Sh.X; + const float DY = WorldY - Sh.Y; + CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R); + } + const FVector Pos(WorldX, WorldY, WorldZ); + for (const FConn& Cn : C.Conns) + { + CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, Cn.A, Cn.B, P.ConnectorRadius)); + } + InOut.Sdf = CaveSDF; + } + + /** La liste des puits proches — `FShaftLedgeMod` doit trouver le PLUS PROCHE pour ne + * poser d'étagère que sur sa moitié +X/+Y. Même motif que colonne → overhang. */ + const FCells& GetCellsAt(float WorldX, float WorldY) const { return GetCells(WorldX, WorldY); } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + // La source répond pour la paire source+carve (SIMPLIFICATION DE PHASE 1) : `CarveOnly` + // si une primitive atteint la boîte, `Identity` sinon. `ExtraReach` couvre la rugosité + // et le blend en aval — le sous-estimer serait un TROU. + const float Pad = FMath::Max(P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach; + const FBox Padded = VoxelBox.ExpandBy(Pad); + + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const int32 CX0 = FMath::FloorToInt((float)Padded.Min.X / Spacing); + const int32 CX1 = FMath::FloorToInt((float)Padded.Max.X / Spacing); + const int32 CY0 = FMath::FloorToInt((float)Padded.Min.Y / Spacing); + const int32 CY1 = FMath::FloorToInt((float)Padded.Max.Y / Spacing); + + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + FShaft Sh; + if (!RollShaft(cx, cy, Sh)) { continue; } + // Cercle (rayon + marge) contre le rectangle XY : un cylindre est infini en Z, donc + // la question est purement XY. + const float R = Sh.R + ExtraReach; + const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Sh.X, + Sh.X - (float)VoxelBox.Max.X)); + const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Sh.Y, + Sh.Y - (float)VoxelBox.Max.Y)); + if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; } + } + + // ⚠️ Les connecteurs ne sont PAS testés ici, et c'est délibérément conservatif dans le + // mauvais sens si on n'y prend pas garde : un connecteur ne peut exister qu'entre deux + // puits d'un voisinage, donc si AUCUN puits n'atteint la boîte élargie de `Spacing*1.6` + // (la portée max d'une paire), aucun connecteur ne peut l'atteindre non plus. + const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad); + const int32 KX0 = FMath::FloorToInt((float)ConnBox.Min.X / Spacing); + const int32 KX1 = FMath::FloorToInt((float)ConnBox.Max.X / Spacing); + const int32 KY0 = FMath::FloorToInt((float)ConnBox.Min.Y / Spacing); + const int32 KY1 = FMath::FloorToInt((float)ConnBox.Max.Y / Spacing); + for (int32 cy = KY0; cy <= KY1; ++cy) + for (int32 cx = KX0; cx <= KX1; ++cx) + { + FShaft Sh; + if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent + } + + return EVoxelOpEffect::Identity; + } + + private: + /** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne + * peuvent pas voir des puits différents. */ + bool RollShaft(int32 nx, int32 ny, FShaft& Out) const + { + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const uint32 Hh = VoxelHash::Cell(nx, ny, Salt); + if (VoxelHash::ToFloat01(Hh) > P.ShaftDensity) { return false; } + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + Out.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); + return true; + } + + /** Le voisinage 3×3 + ses connecteurs, mémoïsés par worker. Clé = cellule + tous les params + * qui influent (comme l'original) : stable à travers les reconstructions de pile, ce qui + * est la leçon retenue du mémo de colonne de SurfaceWorld. */ + const FCells& GetCells(float WorldX, float WorldY) const + { + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const int32 CX = FMath::FloorToInt(WorldX / Spacing); + const int32 CY = FMath::FloorToInt(WorldY / Spacing); + + thread_local FCells Cache; + thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX; + thread_local uint32 VS_Salt = 0xFFFFFFFFu; + thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, + VS_MaxR = -1.0f, VS_Cross = -1.0f, + VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX, VS_Seal = -1.0f; + + if (CX != VS_CX || CY != VS_CY || Salt != VS_Salt || Spacing != VS_Spacing || + P.ShaftDensity != VS_Dens || P.ShaftMinRadius != VS_MinR || P.ShaftMaxRadius != VS_MaxR || + P.CrossConnectChance != VS_Cross || + P.StrateBottomWorldZ != VS_BotZ || P.StrateTopWorldZ != VS_TopZ || + P.BoundarySealThickness != VS_Seal) + { + VS_CX = CX; VS_CY = CY; VS_Salt = Salt; VS_Spacing = Spacing; + VS_Dens = P.ShaftDensity; VS_MinR = P.ShaftMinRadius; VS_MaxR = P.ShaftMaxRadius; + VS_Cross = P.CrossConnectChance; + VS_BotZ = P.StrateBottomWorldZ; VS_TopZ = P.StrateTopWorldZ; + VS_Seal = P.BoundarySealThickness; + Cache.Shafts.Reset(); + Cache.Conns.Reset(); + + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + FShaft Sh; + if (RollShaft(CX + dx, CY + dy, Sh)) { Cache.Shafts.Add(Sh); } + } + + if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2) + { + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; + for (int32 i = 0; i < Cache.Shafts.Num(); i++) + for (int32 j = i + 1; j < Cache.Shafts.Num(); j++) + { + const FShaft& A = Cache.Shafts[i]; + const FShaft& B = Cache.Shafts[j]; + const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); + if (DSq > FMath::Square(Spacing * 1.6f)) { continue; } + + const uint32 PH = VoxelHash::Pair( + FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), + FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu); + if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; } + + const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); + Cache.Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc) }); + } + } + } + return Cache; + } + + FVerticalShaftParams P; + uint32 Salt; + float ExtraReach; + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : ÉTAGÈRES DE PUITS / SHAFT LEDGES + //========================================================================= + // Des tablettes fines à intervalles réguliers en Z, posées UNIQUEMENT sur la moitié +X/+Y du + // puits le plus proche — pour que la moitié opposée reste libre et le puits franchissable. + // C'est un op FORÇANT (`Max`) : il ne peut qu'ajouter du solide. + class FShaftLedgeMod final : public IVoxelDensityOp + { + public: + FShaftLedgeMod(const FVerticalShaftParams& InP, const FShaftFieldSource* InField) + : P(InP), Field(InField) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (P.LedgeSpacing <= 0.0f || P.LedgeDepth <= 0.0f || Field == nullptr) { return; } + // ⚠️ La garde de l'original est `CaveSDF < 0` — le SDF APRÈS rugosité, tel que la pile + // l'a laissé. C'est bien `InOut.Sdf` ici, pas une re-évaluation : re-calculer donnerait + // le SDF SANS rugosité et déplacerait les étagères. + if (InOut.Sdf >= 0.0f) { return; } + + const float Phase = FMath::Frac((WorldZ - P.StrateBottomWorldZ) / P.LedgeSpacing); + const float BandT = FMath::Min(Phase, 1.0f - Phase) * P.LedgeSpacing; + if (BandT >= P.LedgeDepth) { return; } + + const FShaftFieldSource::FCells& C = Field->GetCellsAt(WorldX, WorldY); + if (C.Shafts.Num() == 0) { return; } + + const FShaftFieldSource::FShaft* Near = nullptr; + float BestSq = FLT_MAX; + for (const FShaftFieldSource::FShaft& Sh : C.Shafts) + { + const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); + if (D2 < BestSq) { BestSq = D2; Near = &Sh; } + } + if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 0.0f) + { + const float Shelf = 1.0f - SmoothStep01(BandT / P.LedgeDepth); + InOut.Density = FMath::Max(InOut.Density, Shelf * P.BaseDensity); + } + } + + // N'ajoute que du solide ⇒ tue AllAir, jamais AllSolid. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.LedgeSpacing > 0.0f && P.LedgeDepth > 0.0f) + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + private: + FVerticalShaftParams P; + const FShaftFieldSource* Field; // NON possédant : la pile possède la source + }; + } // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. // Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas // (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade @@ -1236,6 +1494,42 @@ namespace VoxelDensityOps P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); } + void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + { + // ⚠️ LA PREUVE QUE L'ABSTRACTION EST RÉELLE, et elle vaut d'être dite : TROIS des cinq + // opérateurs ci-dessous sont ceux de Maze, **repris sans une ligne de changement** — + // `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, Maze et VerticalShafts sont + // deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil ; en opérateurs, ce sont + // les MÊMES trois ops avec une source différente. C'est exactement ce que `§2.5` prédisait + // et ce que la Phase 1 avait parié. + // + // THREE of the five ops below are Maze's, reused without a line changed. In the switch, + // Maze and VerticalShafts are two unrelated ~100-line functions; as operators they are the + // same three ops with a different source. + 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; + + TUniquePtr ShaftSource = MakeUnique(P, Seed, ExtraReach); + const FShaftFieldSource* ShaftPtr = ShaftSource.Get(); + + OutStack.Add(MakeConstantRockSource(P.BaseDensity)); + OutStack.Add(MoveTemp(ShaftSource)); + // Fréquence 0.1 et fenêtre `SurfaceRoughness + 4` — les constantes de + // `GetVerticalShaftDensity`, PAS celles de Maze (0.12 / `R + rough + 2`). Même opérateur, + // réglages différents : c'est le point. + OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.1f, 3, P.SurfaceRoughness + 4.0f)); + OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); + OutStack.Add(MakeUnique(P, ShaftPtr)); + + 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 bbb506b..e8078a8 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -652,6 +652,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co PerBiome, MoveTemp(Field)); break; } + + case ECaveGeneratorType::VerticalShafts: + if (CP_Vert.StrateTopWorldZ - CP_Vert.StrateBottomWorldZ <= 0.0f) + { + CP_UseOpStack = false; + break; + } + OpCtx.StrateTopWorldZ = CP_Vert.StrateTopWorldZ; + OpCtx.StrateBottomWorldZ = CP_Vert.StrateBottomWorldZ; + VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, 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` diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 196bcfa..12fab3b 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -583,6 +583,8 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord // exactly as the original path does. return true; + case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels + default: return false; } } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index f4b518c..d8910fe 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -230,6 +230,19 @@ namespace VoxelDensityOps int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager); + /** + * VerticalShafts — 8 ops, et **TROIS viennent de Maze sans une ligne de changement** : + * ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×3] + * + * C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont + * deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes + * trois ops avec une source différente et d'autres réglages (fréquence 0.1 au lieu de 0.12, + * fenêtre `rough + 4` au lieu de `R + rough + 2`). + */ + VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& 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]