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:
Fr0zkaandClaude Opus 5 committed 2026-07-27 18:28:06 +02:00
1 parent 96e75abe57
commit ef5bda3d8a
7 files changed
+963 -9

No files matched your search

@@ -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)
{