feat: TunnelNetwork stage A — the SDF spine, wrapping BuildChunkCache

The last archetype is ~1080 lines with 13 detail modifiers, a two-region
cache and a per-room op override. Porting it whole before anything can be
verified is ~600 unverified lines on top of ~200 — the pattern this
refactor has dodged six times. So: three stages.

Stage A = vertical scale, base rock, cave warp, room graph (+ pits and
chimneys), carve, worms, structural post. 6 ops. It is verifiable NOW
because every detail modifier is amplitude-gated and FStrateGenerationParams
already defaults them all to zero — zeroing SurfaceRoughness sends the
ORIGINAL down exactly the path stage A ported.

TunnelNetwork stays OFF in UsesOperatorStackForChunk until stage C.

The decision that matters: FRoomGraphSource CALLS BuildChunkCache and
EvaluateSDFCached rather than transcribing them. That is where §8.4's
two-region window-invariance discipline lives; a transcription would fork
it, and the fork would be "validated" by a test comparing it to the
original. Only the ~60 lines of glue are transcribed.

FRAME ops are retired. All three candidates are now ported and none needed
one: CaveWarp's scope is exactly one operator (pits/chimneys read unwarped
coords), VerticalScale is a one-line pure function, and the island warp was
already local. Not missing infrastructure — one idea seen three times from
a distance.

Also: check 3 was going to compare two interleaved param sets against the
original, which would have FAILED — the original's SDF cache key has no
params, so it serves B the rooms it built for A. Comparing there measures
its bug, not the port. Rewritten against each stack evaluated alone. The
same reasoning suggests a live production staleness across Gradient
transitions; filed in AUDIT §C2 as SUSPECTED with the check that would
confirm it, since it rests on a premise I have not verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 18:28:06 +02:00
parent 96e75abe57
commit ef5bda3d8a
7 changed files with 963 additions and 9 deletions
@@ -0,0 +1,380 @@
// VoxelForgeOpStackTunnelTest.cpp
// TunnelNetwork — ÉTAPE A : le squelette SDF, sans les modificateurs de détail.
// TunnelNetwork — STAGE A: the SDF spine, without the detail modifiers.
//
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
// `GetDensityWithParams` fait ~1080 lignes et treize modificateurs de détail. Tout porter avant de
// pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées —
// exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite.
//
// La sortie : **tous les modificateurs de détail sont pilotés par une amplitude**, et
// `FStrateGenerationParams` les laisse déjà TOUS à zéro par défaut (`BuildParamsFromDefinition` ne
// les fusionne plus globalement — ils viennent d'ops par salle). Une seule exception,
// `SurfaceRoughness = 5`. Les mettre à zéro fait passer l'ORIGINAL par exactement le chemin que
// l'étape A a porté, donc l'étape A est vérifiable AUJOURD'HUI, bit à bit, contre la vraie fonction.
// Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise
// dans l'autre sens.
//
// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les 13 modificateurs, rien sur l'override d'op
// par salle, et rien sur le saut de tuile — `FRoomGraphSource::EffectOverBox` rend `Both`, donc
// aucun verdict n'est prouvable à ce stade. Ces trois manques sont l'étape B et l'étape C.
//
// ⚠️ ÉCHANTILLONNAGE PAR GRAPPES, PAS UNIFORME. Le cache SDF se reconstruit quand la requête sort de
// sa boîte de recherche ; 20 000 points uniformément aléatoires feraient ~20 000 `BuildChunkCache`
// par chemin, et un test qui dure trois minutes est un test qu'on finit par ne plus lancer. On tire
// donc N chunks et M points DANS chacun — ce qui est aussi plus représentatif du vrai motif d'accès
// (un mesher parcourt une tuile, il ne saute pas au hasard).
#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(
FVoxelForgeOpStackTunnelTest,
"VoxelForge.OpStack.TunnelNetworkSpineEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumTunnelChunks = 24;
constexpr int32 PointsPerChunk = 250;
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
/**
* Met à zéro tout ce que l'étape A n'a pas encore porté, pour que l'original prenne le même
* chemin. Tout sauf `SurfaceRoughness` est DÉJÀ à zéro par défaut ; on l'écrit quand même, parce
* qu'un test qui dépend d'un défaut se casse le jour où le défaut change, et silencieusement.
*/
void DisableStageBModifiers(FStrateGenerationParams& P)
{
P.SurfaceRoughness = 0.0f; // le seul non nul par défaut (5.0)
P.DomainWarpStrength = 0.0f;
P.TerraceStepHeight = 0.0f;
P.TerraceNoiseDisplacement = 0.0f;
P.LayerLineSpacing = 0.0f;
P.RibbingSpacing = 0.0f;
P.OverhangStrength = 0.0f;
P.CliffStrength = 0.0f;
P.ScallopStrength = 0.0f;
P.ArchDensity = 0.0f;
P.ColumnDensity = 0.0f; // ⚠️ celui-ci se cuit dans SDFCache.Columns, pas un `if`
P.DomeDensity = 0.0f;
P.PinchDensity = 0.0f;
P.FloorBias = 0.0f;
}
/** Pits et cheminées sont à 0 par défaut — or ce sont précisément les deux boucles que `§2`
* annonçait comme « le plus retors de toute la décomposition » (coordonnées NON warpées
* mélangées au SDF warpé). Les laisser au repos testerait tout sauf le morceau difficile. */
void EnableTunnelFeatures(FStrateGenerationParams& P)
{
P.PitDensity = 0.55f;
P.ChimneyDensity = 0.55f;
P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout
}
}
bool FVoxelForgeOpStackTunnelTest::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::SlotTunnelNetwork, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no TunnelNetwork slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotTunnelNetwork."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FStrateGenerationParams P = World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The TunnelNetwork strate has degenerate Z bounds."));
return false;
}
DisableStageBModifiers(P);
EnableTunnelFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + roomgraph + carve + worms + 3 structurels. Les 13 modificateurs de détail viendront
// s'insérer entre le carve et les vers — ce nombre DOIT bouger à l'étape B.
TestEqual(TEXT("the stage-A tunnel stack is decomposed into 6 ops"), Stack.Num(), 6);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
// Grappes : N chunks, M points dans chacun. Voir l'en-tête — un tirage uniforme ferait
// reconstruire le cache SDF à presque chaque point, sur les DEUX chemins.
TArray<FVector> Points;
Points.Reserve(NumTunnelSamples);
{
FRandomStream Rng(1080601);
const int32 ChunkZ0 = BottomVoxelZ / CHUNK_SIZE;
const int32 ChunkZ1 = FMath::Max(ChunkZ0, (TopVoxelZ / CHUNK_SIZE) - 1);
for (int32 c = 0; c < NumTunnelChunks; ++c)
{
const int32 CX = Rng.RandRange(-3, 3);
const int32 CY = Rng.RandRange(-3, 3);
const int32 CZ = Rng.RandRange(ChunkZ0, ChunkZ1);
for (int32 i = 0; i < PointsPerChunk; ++i)
{
Points.Add(FVector(
(float)(CX * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
(float)(CY * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
(float)FMath::Clamp(CZ * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1),
BottomVoxelZ, TopVoxelZ)));
}
}
}
//=========================================================================
// 1. ÉQUIVALENCE
//=========================================================================
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
int32 NumInCave = 0, NumInRock = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetDensityWithParams(X, Y, Z, P);
const float New = Stack.EvalMC(X, Y, Z);
const bool bInterior = (Z > InnerBot && Z < InnerTop);
if (bInterior && Old >= 0.0f) { ++NumInCave; } // air loin des seals ⇒ salle/tunnel/ver
if (bInterior && Old < 0.0f) { ++NumInRock; }
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("TunnelNetwork STAGE A: bit-identical across %d samples in %d chunks (%d in open ")
TEXT("cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, so ")
TEXT("effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ")
TEXT("the SHARED BuildChunkCache, pits and chimneys at UNWARPED coords, the carve with ")
TEXT("its floored divisor, and the worm carve with its network mask. NOT covered: the ")
TEXT("13 detail modifiers, the per-room op override, and any tile verdict."),
NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock));
}
else
{
AddError(FString::Printf(
TEXT("TunnelNetwork STAGE A: %d of %d samples differ (largest |delta| %.9g at ")
TEXT("(%.0f, %.0f, %.0f)); %d cross the isosurface. Check, in order: the carve's ")
TEXT("MinDivisor (TunnelNetwork floors Blend*2 at 1.0 and the other archetypes do NOT ")
TEXT("-- getting this wrong only shows up when SDFBlendRadius*2 < 1), then EffectiveZ ")
TEXT("(VerticalScale must divide BEFORE the warp and the worms, and must NOT touch pit ")
TEXT("or chimney Z), then the pit/chimney loops reading UNWARPED coords while the room ")
TEXT("SDF reads warped ones, then the SDF cache key (it now includes a params ")
TEXT("fingerprint the original lacks -- that can cost a rebuild, never a wrong room), ")
TEXT("then the worm early-out on N1 >= threshold."),
NumDiff, NumTunnelSamples, 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 (NumInCave == 0)
{
AddWarning(TEXT("No sample landed in open cave away from the seal bands: the room graph, ")
TEXT("the carve, the pits and the worms were never meaningfully exercised, so ")
TEXT("the equivalence above mostly compares solid rock to solid rock. Raise ")
TEXT("RoomDensity or lower RoomSpacing."));
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE — le test qui compte le plus sur cet archétype
//=========================================================================
// `BuildChunkCache` porte la discipline à deux régions de `ARCHITECTURE §8.4` : c'est LE endroit
// du plugin où un cache mal clé produit une couture visible entre deux tuiles. La pile ajoute sa
// propre clé par-dessus (boîte + strate + seed + empreinte de params + version de layout), donc
// c'est cette clé-là que ce bloc met à l'épreuve : mêmes points, ordre mélangé, N threads.
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumTunnelSamples);
for (int32 i = 0; i < NumTunnelSamples; ++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(NumTunnelSamples, 4400 + 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 tunnel stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE CACHE NE PEUT PAS SERVIR LES PARAMS DU VOISIN
//=========================================================================
// La régression d'overhang du 2026-07-27 : deux piles dans la MÊME strate, au MÊME seed, ne
// différant QUE par des params, partageaient un cache `thread_local` dont la clé ignorait les
// params — et la seconde lisait les salles de la première. La pile clé donc aussi sur une
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
// qui fait mentir une clé incomplète.
//
// ⚠️⚠️ ON NE COMPARE **PAS** À L'ORIGINAL ICI, ET C'EST LE POINT LE PLUS IMPORTANT DE CE TEST.
// `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed) — **sans les params**. En
// alternance il rendrait donc, pour B, les salles de A : l'original ÉCHOUERAIT ce contrôle. Le
// comparer à lui ici ne mesurerait pas mon opérateur, ça mesurerait son bug. On compare donc
// chaque pile à ELLE-MÊME évaluée seule — un oracle qui ne partage pas le défaut testé.
//
// ⚠️ ET CE N'EST PEUT-ÊTRE PAS QU'UN ARTEFACT DE TEST — à vérifier, pas à croire. En production
// `GetGenerationParams` MÉLANGE les params entre strates voisines (transitions Gradient), donc
// deux chunks de Z différents dans la même strate peuvent avoir des params différents, avec la
// même boîte XY, le même index de strate et le même seed ⇒ aucune reconstruction. Si c'est
// exact, un worker qui descend une bande de transition sert les salles du chunk précédent.
// Noté dans `AUDIT §C2` comme SUSPECTÉ, avec le test qui le confirmerait — pas comme prouvé.
//
// We compare each stack to ITSELF evaluated alone, not to the original: the original keys its
// SDF cache without the params and would fail this check, so comparing against it would measure
// its bug rather than this operator.
{
FStrateGenerationParams P2 = P;
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
P2.RoomDensity = FMath::Min(P.RoomDensity * 1.7f, 1.0f);
FVoxelOpStack Stack2;
VoxelDensityOps::BuildTunnelNetworkStack(Stack2, P2, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
Stack2.PrepareChunk(Ctx);
// Chaque pile compte 2 reconstructions de cache par point en alternance (elles partagent le
// `thread_local`), donc on reste modeste sur le nombre de sondes : `BuildChunkCache` est la
// fonction la plus chère du plugin.
const int32 Probe = FMath::Min(400, NumTunnelSamples);
TArray<float> SoloA, SoloB;
SoloA.SetNumUninitialized(Probe);
SoloB.SetNumUninitialized(Probe);
for (int32 i = 0; i < Probe; ++i)
{
SoloA[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
for (int32 i = 0; i < Probe; ++i)
{
SoloB[i] = Stack2.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
int32 NumWrong = 0, NumActuallyDifferent = 0;
for (int32 i = 0; i < Probe; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float GotA = Stack.EvalMC(X, Y, Z);
const float GotB = Stack2.EvalMC(X, Y, Z);
if (!BitEqual(GotA, SoloA[i]) || !BitEqual(GotB, SoloB[i])) { ++NumWrong; }
if (!BitEqual(SoloA[i], SoloB[i])) { ++NumActuallyDifferent; }
}
TestEqual(TEXT("two tunnel stacks with different params never serve each other's rooms"),
NumWrong, 0);
AddInfo(FString::Printf(
TEXT("Params-fingerprint check: %d of %d probe points genuinely differ between the two ")
TEXT("param sets, and %d were served wrong under A/B interleaving. A zero in the FIRST ")
TEXT("number would mean the check proved nothing -- the two param sets must actually ")
TEXT("produce different rock for a stale cache to be detectable."),
NumActuallyDifferent, Probe, NumWrong));
if (NumActuallyDifferent == 0)
{
AddWarning(TEXT("The two param sets produced identical density at every probe point, so ")
TEXT("this check cannot distinguish a correct cache from a stale one. Make ")
TEXT("P2 differ more."));
}
}
//=========================================================================
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
//=========================================================================
{
int32 NumProved = 0, NumMixed = 0;
FRandomStream Rng(97531);
for (int32 t = 0; t < 40; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-4, 4) * Extent,
Rng.RandRange(-4, 4) * 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));
if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; }
else { ++NumProved; }
}
AddInfo(FString::Printf(
TEXT("Box verdicts over 40 TunnelNetwork tiles: %d proved, %d Mixed. %d proved is the ")
TEXT("EXPECTED result at stage A and not a defect: the room source answers Both (its ")
TEXT("bounds live in the SDF cache, which it would have to build for the queried box), ")
TEXT("and the worm source answers CarveOnly EVERYWHERE because a fielded noise carve ")
TEXT("has no spatial bound at all. Recovering these needs the numeric amplitude cap in ")
TEXT("OPSTACK-DECOMPOSITION 0.2 -- the largest single perf item in the whole plan, and ")
TEXT("the reason this archetype currently skips zero tiles."),
NumProved, NumMixed, NumProved));
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -25,6 +25,7 @@
#include "VoxelGenerator.h" // VoxelGenLOD::Eff
#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks
#include "VoxelNoise.h" // VoxelNoise::FBM
#include "VoxelStrateDefinition.h" // TerrainOperations — le pool que BuildChunkCache tire par salle
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
@@ -1036,8 +1037,8 @@ namespace
class FSdfConvertOp final : public IVoxelDensityOp
{
public:
FSdfConvertOp(float InBlend, float InBaseDensity, float InSign)
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {}
FSdfConvertOp(float InBlend, float InBaseDensity, float InSign, float InMinDivisor)
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign), MinDivisor(InMinDivisor) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; }
void PrepareChunk(const FVoxelOpContext&) override {}
@@ -1045,7 +1046,14 @@ namespace
void Eval(float, float, float, FVoxelOpSample& InOut) const override
{
if (InOut.Sdf >= Blend) { return; }
float T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
// ⚠️ `MinDivisor` n'est PAS une précaution ajoutée : TunnelNetwork écrit
// `/ FMath::Max(SDFBlendRadius * 2, 1.0f)` là où Maze/Shafts/Islands écrivent `/ (Blend*2)`.
// Les deux formules DIVERGENT dès que `Blend·2 < 1`, donc les confondre serait une faute
// de portage silencieuse. Avec `MinDivisor = 0` et un Blend positif, `Max(x, 0) == x`
// exactement — les trois portages déjà verts ne bougent pas d'un bit.
// Not a safety tweak: TunnelNetwork genuinely floors this divisor at 1 and the others
// do not. Max(x, 0) is exactly x for positive Blend, so existing ports are untouched.
float T = FMath::Clamp((Blend - InOut.Sdf) / FMath::Max(Blend * 2.0f, MinDivisor), 0.0f, 1.0f);
T = SmoothStep01(T);
InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
}
@@ -1056,7 +1064,7 @@ namespace
}
private:
float Blend, BaseDensity, Sign;
float Blend, BaseDensity, Sign, MinDivisor;
};
//=========================================================================
@@ -1691,6 +1699,370 @@ namespace
float ExtraReach;
};
//=========================================================================
// RÔLE 1 — SOURCE : GRAPHE DE SALLES / ROOM GRAPH (TunnelNetwork)
//=========================================================================
// ⚠️⚠️ CET OPÉRATEUR N'A PAS RÉÉCRIT `BuildChunkCache` / `EvaluateSDFCached` : IL LES APPELLE.
//
// C'est LA décision de ce portage, et elle mérite d'être dite explicitement parce que la
// tentation inverse est forte : les six autres portages sont des transcriptions littérales.
// Celui-ci ne peut pas l'être. `BuildChunkCache` porte la discipline d'invariance de fenêtre à
// deux régions (ARCHITECTURE §8.4) — la région COLLECT (plus large, décide QUELLES primitives
// existent) et la région STORE (ce qu'on garde) — et c'est le code le plus délicat du plugin.
// Le transcrire, ce serait le FORKER : deux copies d'un invariant qui dérivent, dont l'une n'est
// testée que par un test d'équivalence qui compare... la copie à l'original.
//
// Ce qui EST transcrit ici, c'est la glu autour : la mémo d'index de strate, la clé de cache par
// BOÎTE DE RECHERCHE (pas par chunk — voir plus bas), le warp, et les boucles pits/cheminées.
// ~60 lignes déjà relues, contre ~400 lignes d'algorithme qu'on ne touche pas.
//
// This op CALLS the morphology cache rather than transcribing it: BuildChunkCache carries the
// two-region window-invariance discipline (§8.4) and forking it would be the worst possible
// outcome of a refactor whose whole point is to have ONE definition of each idea.
class FRoomGraphSource final : public IVoxelDensityOp
{
public:
FRoomGraphSource(const FStrateGenerationParams& InP, int32 InSeed,
const UVoxelStrateManager* InManager)
: P(InP), Seed(InSeed), SeedU((uint32)InSeed), Manager(InManager)
, ParamsFingerprint(FCrc::MemCrc32(&InP, sizeof(InP)))
{}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext& Ctx) override
{
// La seule chose vraiment constante par chunk ET dépendante du contexte. Le reste
// (index de strate, pool d'ops) est résolu paresseusement dans `Eval` comme l'original,
// parce que le cache SDF se ré-clé sur une BOÎTE, pas sur un chunk.
LayoutVersion = Ctx.LayoutVersion;
}
/** Le Z « effectif » : `VerticalScale` étire le monde AVANT le bruit. Pure fonction de Z et
* d'un param — c'est pourquoi ce portage n'a PAS eu besoin d'un opérateur « frame »
* (voir la note de conception dans BuildTunnelNetworkStack). */
FORCEINLINE float EffZ(float WorldZ) const
{
return (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) ? (WorldZ / P.VerticalScale)
: WorldZ;
}
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX
const float EffectiveZ = EffZ(WorldZ);
//---------------------------------------------------------------
// WARP DE CAVE — coordonnées de REQUÊTE uniquement
//---------------------------------------------------------------
// ⚠️ Le warp ne s'applique QU'À la requête du graphe de salles. Les pits et les cheminées
// plus bas lisent les coordonnées RÉELLES, et c'est délibéré dans l'original : leurs
// ancres viennent de centres de salles NON warpés. C'est aussi pourquoi le warp n'est pas
// un « frame » : sa portée est exactement UN opérateur, donc elle appartient à cet
// opérateur.
float WarpedX = WorldX, WarpedY = WorldY, WarpedZ = EffectiveZ;
if (P.CaveWarpStrength > 0.0f)
{
const float WF = P.CaveWarpFrequency;
const float WS = P.CaveWarpStrength;
WarpedX += VoxelNoise::Perlin3D(FVector(
WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f),
WorldY * WF + 1.3f,
EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS;
WarpedY += VoxelNoise::Perlin3D(FVector(
WorldX * WF + 7.1f,
WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f),
EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS;
WarpedZ += VoxelNoise::Perlin3D(FVector(
WorldX * WF + 11.3f,
WorldY * WF + 9.7f,
EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS;
}
//---------------------------------------------------------------
// LE CACHE PAR BOÎTE DE RECHERCHE
//---------------------------------------------------------------
// ⚠️ CLÉ PAR BOÎTE, PAS PAR CHUNK, et c'est un INVARIANT DE PERF (§8.10) : les
// échantillons de gradient interrogent `WorldX ± 1` et le warp déplace encore, donc une
// clé « égalité de chunk » se retournait à chaque cellule de bord et reconstruisait le
// cache (coûteux) en boucle. Comme le cache couvre la boîte + MaxInfluence, toute requête
// DANS la boîte est correcte. Ne pas « simplifier » en clé de chunk.
thread_local FChunkSDFCache SDFCache;
thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // invalide au départ
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
thread_local int32 CachedStrate = INT32_MIN;
thread_local uint32 CachedSeed = 0;
// ⚠️ AJOUTÉ PAR RAPPORT À L'ORIGINAL — la leçon du 2026-07-27 (régression d'overhang).
// L'original ne clé QUE sur (boîte, strate, seed) : deux jeux de params différents dans
// la MÊME strate au MÊME seed se servent mutuellement leur cache. En production
// `RebuildStrates` masque le trou en bougeant la strate ; en test, deux piles construites
// côte à côte le déclenchent immédiatement. Empreinte CRC des params + LayoutVersion.
// `FStrateGenerationParams` est du POD pur (aucun TArray/FString/pointeur), donc une CRC
// mémoire ne peut pas donner un FAUX POSITIF ; au pire un padding donne un faux MANQUE,
// c'est-à-dire un recalcul. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
// Index de strate — mémo (chunk-Z, version de layout), transcrit tel quel. La requête
// vise le CENTRE de la bande, donc le résultat est une fonction pure de la clé.
int32 StrateIdx = 0;
if (Manager)
{
thread_local int32 SI_ChunkZ = INT32_MAX;
thread_local uint32 SI_Version = 0xFFFFFFFFu;
thread_local int32 SI_Index = 0;
const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE);
const uint32 LV = Manager->GetLayoutVersion();
if (QZ != SI_ChunkZ || LV != SI_Version)
{
SI_ChunkZ = QZ;
SI_Version = LV;
SI_Index = Manager->GetStrateIndex(((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
}
StrateIdx = SI_Index;
}
const bool bNeedRebuild =
StrateIdx != CachedStrate || SeedU != CachedSeed ||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
if (bNeedRebuild)
{
const int32 CacheChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE);
const int32 CacheChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE);
const float ChunkMinX = CacheChunkX * (float)CHUNK_SIZE;
const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE;
const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE;
const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE;
const float Expansion = P.CaveWarpStrength + 2.0f;
const float SMinX = ChunkMinX - Expansion;
const float SMinY = ChunkMinY - Expansion;
const float SMaxX = ChunkMaxX + Expansion;
const float SMaxY = ChunkMaxY + Expansion;
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr;
if (Manager)
{
const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE);
UVoxelStrateDefinition* Def = Manager->GetStrateForChunk(
FIntVector(CacheChunkX, CacheChunkY, ChunkZ));
if (Def) { TerrainOps = &Def->TerrainOperations; }
}
VoxelCaveMorphology::BuildChunkCache(
SDFCache, SMinX, SMinY, SMaxX, SMaxY, P, SeedU, StrateIdx, TerrainOps);
CachedSMinX = SMinX; CachedSMaxX = SMaxX;
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
CachedStrate = StrateIdx;
CachedSeed = SeedU;
CachedFingerprint = ParamsFingerprint;
CachedLayout = LayoutVersion;
}
int32 NearestRoom = -1;
float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached(
WarpedX, WarpedY, WarpedZ, SDFCache, P.SDFBlendRadius, &NearestRoom);
//---------------------------------------------------------------
// PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF
//---------------------------------------------------------------
// C'est le point que `OPSTACK-DECOMPOSITION §2` annonçait comme « le plus retors de toute
// la décomposition » : deux primitives qui écrivent le MÊME canal que le graphe de salles
// mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir
// du frame tout en gardant le canal — exprimable, mais tordu. Dans un opérateur unique la
// difficulté disparaît : le warp est une variable locale, pas un contexte hérité.
for (const FCachedPit& Pit : SDFCache.Pits)
{
const float DZ = WorldZ - Pit.TopZ;
if (DZ >= Pit.BlendK) { continue; }
if (-DZ > Pit.Depth + Pit.BlendK) { continue; }
const float DX = WorldX - Pit.CenterX;
const float DY = WorldY - Pit.CenterY;
const float XYDistSq = DX * DX + DY * DY;
if (XYDistSq > Pit.BoundXYRadiusSq) { continue; }
float PitSDF;
if (DZ <= 0.0f)
{
const float DepthBelow = -DZ;
float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f);
FlareFactor = FlareFactor * FlareFactor;
const float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor;
PitSDF = FMath::Sqrt(XYDistSq) - EffRadius;
}
else
{
PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra);
}
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK);
}
for (const FCachedChimney& Chim : SDFCache.Chimneys)
{
const float DZ = WorldZ - Chim.BottomZ;
if (-DZ >= Chim.BlendK) { continue; }
if (DZ > Chim.Height + Chim.BlendK) { continue; }
const float DX = WorldX - Chim.CenterX;
const float DY = WorldY - Chim.CenterY;
const float XYDistSq = DX * DX + DY * DY;
if (XYDistSq > Chim.BoundXYRadiusSq) { continue; }
float ChmSDF;
if (DZ >= 0.0f)
{
float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f);
FlareFactor = FlareFactor * FlareFactor;
const float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor;
ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius;
}
else
{
ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra);
}
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK);
}
InOut.Sdf = CaveSDF;
}
/**
* ⚠️ `Both` POUR L'INSTANT, ET C'EST UNE DETTE ASSUMÉE, PAS UN OUBLI.
*
* Les bornes existent pourtant : `FCachedRoom` / `FCachedTunnel` portent déjà leurs
* `Bound*` (c'est ce dont `§2` dit qu'il rend le bedrock profond prouvable, « le plus gros
* poste de perf de tout le plan »). Ce qui manque, c'est que répondre honnêtement demande de
* consulter le cache — donc de le CONSTRUIRE pour la boîte interrogée, sur le thread qui
* interroge, ce qui n'est raisonnable qu'une fois `ClassifyBox` réellement branché dans
* `ClassifyTile` (il ne l'est toujours pas). Rendre `Both` coûte du CPU et ne peut pas faire
* de trou ; rendre le mauvais en ferait un.
*
* Conservative placeholder: the room/tunnel bounds needed for a real answer are already in
* the cache, but answering means building that cache for the queried box, which only pays
* once ClassifyTile actually consumes ClassifyBox. Both is always safe.
*/
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f) ? EVoxelOpEffect::Both
: EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
int32 Seed;
uint32 SeedU;
const UVoxelStrateManager* Manager; // NON possédant
uint32 ParamsFingerprint;
uint32 LayoutVersion = 0;
};
//=========================================================================
// RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork)
//=========================================================================
// Un carve par SEUIL sur du bruit 3D, masqué par la distance au réseau de salles. Il écrit la
// DENSITÉ directement (pas le canal SDF) : c'est une source « fieldée », pas une primitive
// placée — la distinction que `AUDIT §6.2` pose et que `OPSTACK-DECOMPOSITION §0.2` chiffre.
//
// ⚠️ IL LIT `InOut.Sdf` : le masque de réseau est une fonction de `CaveSDF` APRÈS pits et
// cheminées. C'est encore le canal SDF utilisé comme ce pour quoi il existe — transporter une
// information géométrique entre deux opérateurs au lieu de la recalculer.
class FWormFieldSource final : public IVoxelDensityOp
{
public:
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed)
: P(InP), SeedU((uint32)Seed) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return; }
const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f)
? (WorldZ / P.VerticalScale) : WorldZ;
const float CaveSDF = InOut.Sdf;
float NetworkMask = 1.0f;
if (P.WormNetworkRange > 0.0f)
{
if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX)
{
NetworkMask = 0.0f;
}
else if (CaveSDF > 0.0f)
{
NetworkMask = 1.0f - SmoothStep01(CaveSDF / P.WormNetworkRange);
}
}
if (NetworkMask <= 0.0f) { return; }
const float WormZFreq = P.WormFrequency * P.WormHorizontalBias;
const float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector(
WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f),
WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f),
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f)
)) * VOXEL_NOISE_SCALE);
// N2 ≥ 0, donc si N1 dépasse déjà le seuil la somme ne peut plus creuser — on saute le
// second Perlin (le cas courant ; sortie bit-identique). Transcrit tel quel.
if (N1 >= P.WormThreshold) { return; }
const float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector(
WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f,
WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f,
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f
)) * VOXEL_NOISE_SCALE);
const float WormValue = N1 + N2;
if (WormValue < P.WormThreshold)
{
const float t = 1.0f - (WormValue / P.WormThreshold);
InOut.Density -= t * P.WormStrength * NetworkMask;
}
}
/**
* ⚠️ `CarveOnly` PARTOUT quand les vers sont actifs — et c'est exactement le problème que
* `OPSTACK-DECOMPOSITION §0.2` isole : un carve fieldé n'a AUCUNE borne spatiale, donc il tue
* l'hypothèse `AllSolid` sur CHAQUE tuile de CHAQUE strate à vers. La direction seule ne peut
* pas le récupérer.
*
* **Mais l'amplitude, elle, est bornée et triviale** : `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`,
* donc ce ver ne peut déplacer la densité vers l'air que de `WormStrength` au plus. Dès que
* le pliage saura porter un INTERVALLE numérique et pas seulement une direction, « le rocher
* est solide de plus que la somme des carves restants » redevient prouvable — et c'est le
* plus gros poste de perf du plan. Noté ici, au point exact où la borne manque.
*/
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? EVoxelOpEffect::CarveOnly
: EVoxelOpEffect::Identity;
}
/** L'amplitude max de carve, en unités de densité. Pas encore consommée par le pliage —
* posée ici pour que la borne de `§0.2` ait déjà un domicile quand les intervalles
* arriveront. / The bound §0.2 needs, given a home before it has a consumer. */
float MaxCarveAmplitude() const
{
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;
}
private:
FStrateGenerationParams P;
uint32 SeedU;
};
} // ⚠️ 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
@@ -1742,14 +2114,14 @@ namespace VoxelDensityOps
return MakeUnique<FSdfRoughnessMod>(Strength, Frequency, BaseOctaves, ApplyWithin);
}
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity)
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity, float MinDivisor)
{
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f);
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f, MinDivisor);
}
TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity)
{
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f);
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f, 0.0f);
}
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
@@ -1837,6 +2209,52 @@ namespace VoxelDensityOps
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
}
void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{
// ⚠️ ÉTAPE A SUR TROIS — LA PILE EST INCOMPLÈTE, ET DÉLIBÉRÉMENT.
// Sont portés : l'échelle verticale, le roc de base, le warp, le graphe de salles (+ pits
// + cheminées), le carve, les vers, le post structurel. **NE SONT PAS ENCORE PORTÉS** les
// treize modificateurs de détail de l'étape 4b-4h (rugosité, terrasses, lignes de strates,
// nervures, surplombs, falaise, festons, arches, colonnes, dômes, pincement, biais de sol),
// ni l'override d'op PAR SALLE.
//
// C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork :
// brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare
// avec ces amplitudes MISES À ZÉRO, donc l'étape A est entièrement vérifiable dès
// maintenant au lieu d'attendre ~600 lignes de plus — c'est la même discipline que la passe
// « défauts puis tous les ops ON » du test de la pile de hauteur.
//
// STAGE A OF THREE, deliberately incomplete: the 13 detail modifiers and the per-room op
// override are not ported yet, which is why the archetype is still off in
// UsesOperatorStackForChunk. The test zeroes those amplitudes so stage A is verifiable now.
//
//---------------------------------------------------------------------
// ⚠️ CE PORTAGE RETIRE L'IDÉE DE « FRAME OPS » (OPSTACK-DECOMPOSITION §1)
//---------------------------------------------------------------------
// `§2` décrivait deux frames imbriqués : `VerticalScale` et `CaveWarp`. En les portant pour
// de vrai, les deux se sont dissous :
// • `CaveWarp` a une portée d'EXACTEMENT UN opérateur (le graphe de salles — pits et
// cheminées lisent explicitement les coordonnées non warpées). Une transformation qui
// n'enveloppe qu'un opérateur n'est pas un frame, c'est une variable locale.
// • `VerticalScale` est `Z / Scale` : une fonction PURE d'un scalaire et d'un param, que
// chaque opérateur qui en a besoin recalcule en une ligne. Un frame ne ferait
// qu'ajouter un canal pour éviter une division.
// Il restait le warp d'îles (§7), déjà gardé local pour la même raison. **Zéro frame sur
// trois candidats** : ce n'était pas une infrastructure manquante, c'était trois fois la
// même chose vue de loin. Noté ici plutôt que laissé en TODO permanent.
constexpr float CarveMinDivisor = 1.0f; // TunnelNetwork plancher son diviseur, cf. FSdfConvertOp
OutStack.Add(MakeConstantRockSource(P.BaseDensity));
OutStack.Add(MakeUnique<FRoomGraphSource>(P, Seed, StrateManager));
OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor));
// [ÉTAPE B ira ici : les 13 modificateurs de détail, gated sur `Sdf < SDFBlendRadius·3`]
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
}
void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{
+25 -2
View File
@@ -193,8 +193,13 @@ namespace VoxelDensityOps
int32 BaseOctaves, float ApplyWithin);
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts.
* @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original
* écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où
* `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`,
* donc ce paramètre est une vraie différence, pas une précaution. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity,
float MinDivisor = 0.0f);
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
@@ -260,6 +265,24 @@ namespace VoxelDensityOps
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* TunnelNetwork — **ÉTAPE A SUR TROIS, PILE INCOMPLÈTE** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → Worms → [structural ×3]
*
* ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les 13
* modificateurs de détail (4b4h) et l'override d'op par salle ne sont pas portés, donc le monde
* y perdrait tout son détail. Le test compare avec ces amplitudes à zéro.
*
* ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit
* pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE
* §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est
* d'avoir UNE définition de chaque idée.
*/
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
const FStrateGenerationParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]