feat: port VerticalShafts — three ops reused from Maze unchanged

The port that tests the thesis rather than the fidelity. Previous ports asked whether
the decomposition reproduces the original; this one asks whether operators actually
get reused across archetypes, which is section 2.5's claim and the only reason to do
this refactor instead of tidying the switch.

ConstantRock, SdfRoughness and SdfCarve are Maze's, reused without a line changed.
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 (freq 0.1 vs 0.12, window rough+4 vs R+rough+2).

New: FShaftFieldSource (infinite cylinders + hash-gated connectors into the SDF
channel) and FShaftLedgeMod (banded shelves on the +X/+Y half so the shaft stays
climbable).

Deviation from section 6, stated: it suggested splitting the source so the XY-pure
cylinder half could get an exact box verdict. Kept as one op because the connectors
derive from the same 3x3 roll and the ledge mod needs the shaft list anyway, so
splitting means rolling twice or sharing a cache between two ops. Forfeited: the exact
verdict on the cylinder half. Kept: a conservative EffectOverBox testing circles and
connector reach.

FShaftLedgeMod gates on the POST-roughness Sdf as the stack left it; re-deriving it
would use the pre-roughness value and shift every ledge. Reading the channel rather
than recomputing is what the two-channel sample is for.

Compile fix: FCells was declared below the functions returning it. Member bodies are
deferred, return types are not.

Ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld (biomes included),
VerticalShafts — 5 of 8.

UNVERIFIED: not compiled past the FCells fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:11:07 +02:00
parent f3faa3b5c2
commit 3acb3fbc6b
8 changed files with 657 additions and 6 deletions
+3
View File
@@ -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** (topbottom ≤ 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`
+15 -6
View File
@@ -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).
+47
View File
@@ -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.
---
@@ -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 <atomic>
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<FVector> 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<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> 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<int32> 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
@@ -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<FShaft, TInlineAllocator<9>> Shafts;
TArray<FConn, TInlineAllocator<8>> 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<FShaftFieldSource> ShaftSource = MakeUnique<FShaftFieldSource>(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<FShaftLedgeMod>(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)
{
@@ -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`
@@ -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;
}
}
@@ -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]