feat(opstack B2): port Terrace, LayerLines, Ribbing (STEP 4c) + hoist the room source's per-worker state

STAGE B group 2 of 5. The stack is now 11 ops:
  ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness -> Terrace -> LayerLines -> Ribbing
  -> Worms -> [structural x3]
Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.

STRUCTURAL CHANGE THIS GROUP FORCED
FRoomGraphSource's thread_local SDF cache moved out of Eval into a `static FState& State()`
accessor, because terracing re-queries the cache at Z+-1 and (from B4 on) columns walk it and the
room-relative ops need NearestRoomIdx. Kept `static`, i.e. SHARED BETWEEN INSTANCES, on purpose:
that is what the original does, and the test's stale-cache check (check 3) rests on two stacks
sharing this cache while the params CRC in the key keeps them from serving each other's rooms.
Making it a member would have turned that check green for the wrong reason.
This is the established pattern in this file, not a new one: FOverhangShelfMod reads
FSurfaceColumnSource, FShaftLedgeMod reads FShaftFieldSource, both via a non-owning pointer
handed over at build time. Terrace now reads FRoomGraphSource the same way.

TRANSCRIBED
- FCaveTerraceMod: the Z-only SDF gradient orientation gate, the optional noise displacement of Z
  before the staircase, the Edge = lerp(0.45, 0.02, Hardness) stair profile, the quadratic fade.
- FLayerLineMod: sine along Z, half-wave rectified, CUBED, subtracted.
- FRibbingMod: same sine shifted by PI/2, rectified, SQUARED, added.

THINGS THAT LOOK WRONG AND WERE PORTED AS-IS
- Terrace's two gradient probes use UNWARPED X/Y and RAW Z, while the field they probe was
  evaluated at WARPED coords and effective Z, and they exclude pits/chimneys. So the probe does
  not sample exactly the field whose slope it measures. Real, in the original, load-bearing for
  the look; noted in OPSTACK-PROGRESS rather than fixed.
- Terrace/LayerLines/Ribbing use raw WorldZ while roughness uses EffectiveZ. Deliberate in the
  original (geology stays horizontal whatever the strate's vertical scale).
- `&& CaveSDF < FLT_MAX` is redundant under the bNearCaveSurface gate; kept.
- NearestRoom is now reset to -1 unconditionally, which the original does not do (its thread_local
  keeps the previous voxel's value when RoomDensity <= 0). Unobservable there because no consumer
  runs; here the consumers are separate objects, and a stale value crossing an operator boundary
  is not something you find later.

Ribbing and LayerLines are FillOnly / CarveOnly rather than Both -- two detail modifiers that keep
a usable direction for the fold, which is more than the roughness or the terrace can say.

TEST (same commit): op count 8 -> 11; the three params move from DisableStageBModifiers into
EnableTunnelFeatures with spacings deliberately small against the sample window; three MORE
coverage probes, one per operator rather than one per group -- a single "B2" probe would stay
green while two of the three were wrong.

IF THIS GROUP IS WRONG: diffs cluster where |SDF| < spacing*1.5 near horizontal surfaces
(terrace) or in thin Z bands (lines/ribs). If instead the whole equivalence collapses everywhere,
suspect the FState hoist, not the three new ops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 03:22:21 +02:00
parent 6e29cbfe4c
commit ab1a996cc5
3 changed files with 346 additions and 43 deletions
@@ -18,9 +18,10 @@
// L'ÉTAPE B REMONTE CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté sort // L'ÉTAPE B REMONTE CES AMPLITUDES UN GROUPE À LA FOIS, dans l'autre sens : chaque groupe porté sort
// de `DisableStageBModifiers` et entre dans `EnableTunnelFeatures`, avec (i) une sonde de couverture // de `DisableStageBModifiers` et entre dans `EnableTunnelFeatures`, avec (i) une sonde de couverture
// qui prouve qu'il a réellement bougé quelque chose et (ii) le compte d'ops de la pile qui augmente. // qui prouve qu'il a réellement bougé quelque chose et (ii) le compte d'ops de la pile qui augmente.
// • B1 (ce commit) : rugosité de paroi, STEP 4b. // • B1 : rugosité de paroi, STEP 4b.
// • B2 (ce commit) : terrasses, lignes de strates, nervures — STEP 4c.
// //
// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les onze modificateurs restants (4c4h), rien // CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les huit modificateurs restants (4c4h), rien
// sur l'override d'op par salle, et rien sur le saut de tuile — `FRoomGraphSource::EffectOverBox` // 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. Ce sont les étapes B2B5 et C. // rend `Both`, donc aucun verdict n'est prouvable à ce stade. Ce sont les étapes B2B5 et C.
// //
@@ -66,10 +67,6 @@ namespace
*/ */
void DisableStageBModifiers(FStrateGenerationParams& P) void DisableStageBModifiers(FStrateGenerationParams& P)
{ {
P.TerraceStepHeight = 0.0f;
P.TerraceNoiseDisplacement = 0.0f;
P.LayerLineSpacing = 0.0f;
P.RibbingSpacing = 0.0f;
P.OverhangStrength = 0.0f; P.OverhangStrength = 0.0f;
P.CliffStrength = 0.0f; P.CliffStrength = 0.0f;
P.ScallopStrength = 0.0f; P.ScallopStrength = 0.0f;
@@ -115,6 +112,18 @@ namespace
P.RoughnessNoiseType = EVoxelNoiseType::FBM; // les 4 types sont balayés au contrôle 1c P.RoughnessNoiseType = EVoxelNoiseType::FBM; // les 4 types sont balayés au contrôle 1c
P.DomainWarpStrength = 3.0f; // ≠ 0 ⇒ le chemin de warp de domaine est pris P.DomainWarpStrength = 3.0f; // ≠ 0 ⇒ le chemin de warp de domaine est pris
P.DomainWarpFrequency = 0.03f; P.DomainWarpFrequency = 0.03f;
// ── ÉTAPE B2 : les trois remaniements « sédimentaires » (4c) ──────────────────────────
// Espacements CHOISIS PETITS devant la fenêtre d'échantillonnage : `LineRange` vaut
// `Spacing · 1.5`, donc un espacement de 30 ne laisserait presque aucun point dans la
// fenêtre et la sonde de couverture rapporterait un quasi-zéro pour la mauvaise raison.
P.TerraceStepHeight = 6.0f;
P.TerraceHardness = 0.6f;
P.TerraceNoiseDisplacement = 0.5f; // ≠ 0 ⇒ le bruit de déplacement du palier est pris
P.LayerLineSpacing = 5.0f;
P.LayerLineDepth = 0.35f;
P.RibbingSpacing = 4.0f;
P.RibbingDepth = 0.4f;
} }
/** /**
@@ -202,6 +211,15 @@ namespace
{ {
{ TEXT("B1 surface roughness (STEP 4b)"), { TEXT("B1 surface roughness (STEP 4b)"),
[](FStrateGenerationParams& Q) { Q.SurfaceRoughness = 0.0f; } }, [](FStrateGenerationParams& Q) { Q.SurfaceRoughness = 0.0f; } },
{ TEXT("B2 terracing (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.TerraceStepHeight = 0.0f; } },
{ TEXT("B2 layer lines (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.LayerLineSpacing = 0.0f; } },
{ TEXT("B2 ribbing (STEP 4c)"),
[](FStrateGenerationParams& Q) { Q.RibbingSpacing = 0.0f; } },
// ⚠️ UNE SONDE PAR OPÉRATEUR, PAS UNE PAR GROUPE. Le groupe B2 en contient trois ; une seule
// sonde « B2 » serait verte tant qu'UN des trois tire, et les deux autres pourraient être
// faux sans que rien ne le dise. La granularité de la sonde est la granularité de la preuve.
}; };
//========================================================================= //=========================================================================
@@ -290,11 +308,12 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed, VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get()); Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + roomgraph + carve + **rugosité (B1)** + worms + 3 structurels = 8. // rock + roomgraph + carve + **rugosité + terrasses + lignes + nervures (B1, B2)** + worms
// Les onze modificateurs restants viendront s'insérer entre la rugosité et les vers, donc ce // + 3 structurels = 11.
// Les huit modificateurs restants viendront s'insérer entre les nervures et les vers, donc ce
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une // nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté. // formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
TestEqual(TEXT("the stage-A+B1 tunnel stack is decomposed into 8 ops"), Stack.Num(), 8); TestEqual(TEXT("the stage-A+B1+B2 tunnel stack is decomposed into 11 ops"), Stack.Num(), 11);
FVoxelOpContext Ctx; FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed; Ctx.Seed = (uint32)World.Settings->Seed;
+314 -30
View File
@@ -1783,6 +1783,74 @@ namespace
LayoutVersion = Ctx.LayoutVersion; LayoutVersion = Ctx.LayoutVersion;
} }
//---------------------------------------------------------------------
// L'ÉTAT PAR WORKER, ET POURQUOI IL EST SORTI DE `Eval`
//---------------------------------------------------------------------
// Les modificateurs de détail de l'étape B ont besoin de ce que CETTE source a produit pour
// CE voxel : le cache SDF (les terrasses le ré-interrogent en Z±1, les colonnes le
// parcourent) et l'index de la salle la plus proche (arches, dômes, pincement, biais de sol).
// Dans l'original tout cela vit dans des `thread_local` d'une seule fonction de 1080 lignes ;
// ici la source les possède et les expose.
//
// ⚠️ `static` (donc PARTAGÉ ENTRE INSTANCES), pas un membre : c'est exactement ce que fait
// l'original, et le contrôle 3 du test en DÉPEND — deux piles construites côte à côte se
// partagent ce cache, et c'est l'empreinte de params dans la clé (pas une copie par pile) qui
// les empêche de se servir mutuellement leurs salles. En faire un membre ferait passer ce
// contrôle pour de mauvaises raisons.
//
// ⚠️ MÊME MOTIF QUE `FOverhangShelfMod` ← `FSurfaceColumnSource` et `FShaftLedgeMod` ←
// `FShaftFieldSource` : un opérateur possède l'état, les autres le lisent par pointeur non
// possédant remis à la construction. C'est un motif ÉTABLI dans ce fichier, pas une invention.
//
// Per-worker state, deliberately `static` (shared between instances) because that is what the
// original does and what the test's stale-cache check rests on. Detail ops read it through a
// non-owning pointer, the same way the surface and shaft ports already do.
struct FState
{
FChunkSDFCache Cache;
float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // invalide au départ (min > max)
float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
int32 CachedStrate = INT32_MIN;
uint32 CachedSeed = 0;
uint32 CachedFingerprint = 0xFFFFFFFFu;
uint32 CachedLayout = 0xFFFFFFFFu;
/** La salle de SDF minimal pour le dernier voxel évalué. -1 = aucune. */
int32 NearestRoom = -1;
};
static FState& State()
{
thread_local FState S;
return S;
}
/** Le cache que la source vient de bâtir/servir pour ce voxel. Lu par les colonnes (4d). */
const FChunkSDFCache& GetCache() const { return State().Cache; }
/** L'index de la salle la plus proche pour le dernier voxel évalué. -1 = aucune.
* Lu par les arches, les dômes, le pincement et le biais de sol. */
int32 GetNearestRoomIdx() const { return State().NearestRoom; }
/**
* Une requête SDF supplémentaire dans le cache courant, pour les sondes de gradient des
* terrasses.
*
* ⚠️ TRANSCRIT TEL QUEL, Y COMPRIS CE QUI SEMBLE INCOHÉRENT : l'original sonde en
* `(WorldX, WorldY, WorldZ ± 1)` — coordonnées NON warpées et Z NON divisé par
* `VerticalScale` — alors que le champ qu'il sonde a été évalué en coordonnées WARPÉES et en
* Z effectif. La sonde ne voit donc pas exactement le champ dont elle mesure la pente, et
* elle ignore aussi les pits et les cheminées. C'est un écart réel de l'original ; le
* corriger changerait le monde, donc il est NOTÉ (OPSTACK-PROGRESS) et porté à l'identique.
*
* Transcribed as-is including what looks wrong: the probe uses unwarped X/Y and raw Z while
* the field it probes was evaluated warped, and it excludes pits/chimneys.
*/
float ProbeSdfUnwarped(float X, float Y, float Z) const
{
return VoxelCaveMorphology::EvaluateSDFCached(X, Y, Z, State().Cache, P.SDFBlendRadius);
}
/** Le Z « effectif » : `VerticalScale` étire le monde AVANT le bruit. Pure fonction de Z et /** 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 » * 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). */ * (voir la note de conception dans BuildTunnelNetworkStack). */
@@ -1794,6 +1862,16 @@ namespace
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{ {
FState& S = State();
// ⚠️ REMIS À -1 INCONDITIONNELLEMENT, ce que l'original ne fait pas : chez lui
// `NearestRoomIdx` est un `thread_local` qui, quand `RoomDensity <= 0`, garde la valeur
// du voxel PRÉCÉDENT. Inobservable là-bas (sans salles, `CaveSDF` reste FLT_MAX, donc
// `bNearCaveSurface` est faux et aucun consommateur ne tourne) — mais ici les
// consommateurs sont des objets séparés, et une valeur périmée qui traverse une frontière
// d'opérateur est le genre de chose qu'on ne retrouve pas. On paie une écriture.
S.NearestRoom = -1;
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX
const float EffectiveZ = EffZ(WorldZ); const float EffectiveZ = EffZ(WorldZ);
@@ -1833,11 +1911,9 @@ namespace
// clé « égalité de chunk » se retournait à chaque cellule de bord et reconstruisait le // 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 // 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. // DANS la boîte est correcte. Ne pas « simplifier » en clé de chunk.
thread_local FChunkSDFCache SDFCache; // (Les champs de cache vivent maintenant dans `FState`, au-dessus, pour que les
thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // invalide au départ // modificateurs de détail de l'étape B puissent les lire. Même durée de vie, même
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f; // partage entre instances qu'avant : ce sont les mêmes `thread_local`, déménagés.)
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). // ⚠️ 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 // 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 // la MÊME strate au MÊME seed se servent mutuellement leur cache. En production
@@ -1846,8 +1922,6 @@ namespace
// `FStrateGenerationParams` est du POD pur (aucun TArray/FString/pointeur), donc une CRC // `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, // 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. // 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 // 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é. // vise le CENTRE de la bande, donc le résultat est une fonction pure de la clé.
@@ -1869,10 +1943,10 @@ namespace
} }
const bool bNeedRebuild = const bool bNeedRebuild =
StrateIdx != CachedStrate || SeedU != CachedSeed || StrateIdx != S.CachedStrate || SeedU != S.CachedSeed ||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout || ParamsFingerprint != S.CachedFingerprint || LayoutVersion != S.CachedLayout ||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX || WarpedX < S.CachedSMinX || WarpedX > S.CachedSMaxX ||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY; WarpedY < S.CachedSMinY || WarpedY > S.CachedSMaxY;
if (bNeedRebuild) if (bNeedRebuild)
{ {
@@ -1899,19 +1973,18 @@ namespace
} }
VoxelCaveMorphology::BuildChunkCache( VoxelCaveMorphology::BuildChunkCache(
SDFCache, SMinX, SMinY, SMaxX, SMaxY, P, SeedU, StrateIdx, TerrainOps); S.Cache, SMinX, SMinY, SMaxX, SMaxY, P, SeedU, StrateIdx, TerrainOps);
CachedSMinX = SMinX; CachedSMaxX = SMaxX; S.CachedSMinX = SMinX; S.CachedSMaxX = SMaxX;
CachedSMinY = SMinY; CachedSMaxY = SMaxY; S.CachedSMinY = SMinY; S.CachedSMaxY = SMaxY;
CachedStrate = StrateIdx; S.CachedStrate = StrateIdx;
CachedSeed = SeedU; S.CachedSeed = SeedU;
CachedFingerprint = ParamsFingerprint; S.CachedFingerprint = ParamsFingerprint;
CachedLayout = LayoutVersion; S.CachedLayout = LayoutVersion;
} }
int32 NearestRoom = -1;
float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached(
WarpedX, WarpedY, WarpedZ, SDFCache, P.SDFBlendRadius, &NearestRoom); WarpedX, WarpedY, WarpedZ, S.Cache, P.SDFBlendRadius, &S.NearestRoom);
//--------------------------------------------------------------- //---------------------------------------------------------------
// PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF // PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF
@@ -1921,7 +1994,7 @@ namespace
// mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir // 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 // 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é. // difficulté disparaît : le warp est une variable locale, pas un contexte hérité.
for (const FCachedPit& Pit : SDFCache.Pits) for (const FCachedPit& Pit : S.Cache.Pits)
{ {
const float DZ = WorldZ - Pit.TopZ; const float DZ = WorldZ - Pit.TopZ;
if (DZ >= Pit.BlendK) { continue; } if (DZ >= Pit.BlendK) { continue; }
@@ -1949,7 +2022,7 @@ namespace
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK);
} }
for (const FCachedChimney& Chim : SDFCache.Chimneys) for (const FCachedChimney& Chim : S.Cache.Chimneys)
{ {
const float DZ = WorldZ - Chim.BottomZ; const float DZ = WorldZ - Chim.BottomZ;
if (-DZ >= Chim.BlendK) { continue; } if (-DZ >= Chim.BlendK) { continue; }
@@ -2182,6 +2255,207 @@ namespace
uint32 SeedU; uint32 SeedU;
}; };
//=========================================================================
// RÔLE 3 — MODIFIER : TERRASSES / TERRACING (TunnelNetwork, STEP 4c)
//=========================================================================
// Un escalier lissé en Z : `Offset = staircase(Z) Z` ajouté à la densité. Positif ⇒ marche
// solide sur laquelle marcher, négatif ⇒ vide sous la marche du dessus.
//
// ⚠️ LE SEUL MODIFICATEUR QUI RE-INTERROGE LE CHAMP SDF. Le facteur d'orientation vient de deux
// sondes en Z±1 : un SDF a un gradient ≈ unitaire, donc |dSDF/dZ| EST déjà la composante
// verticale normalisée — proche de 1 = sol/plafond, proche de 0 = paroi. Sans ce facteur, les
// terrasses posent des bourrelets horizontaux dans les puits verticaux.
//
// C'est pour cette re-interrogation que `FRoomGraphSource` expose `ProbeSdfUnwarped` (et donc
// que son cache est sorti de `Eval`) : le refaire ici voudrait dire un second cache SDF.
class FCaveTerraceMod final : public IVoxelDensityOp
{
public:
FCaveTerraceMod(const FStrateGenerationParams& InP, int32 Seed, const FRoomGraphSource* InRooms)
: P(InP), SeedU((uint32)Seed), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (Rooms == nullptr) { return; }
// `&& CaveSDF < FLT_MAX` est redondant sous le gate (qui le teste déjà) mais l'original
// l'écrit, et une transcription littérale ne fait pas le tri.
if (!(P.TerraceStepHeight > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float StepH = P.TerraceStepHeight;
const float DistFromSurface = FMath::Abs(CaveSDF);
const float TerraceRange = StepH * 3.0f;
if (!(DistFromSurface < TerraceRange)) { return; }
// Deux évaluations SDF de plus par voxel, seulement près d'une surface. Approximation
// en Z seul : le gradient complet à 6 échantillons coûtait 3× pour le même signal.
const float SDF_Zp1 = Rooms->ProbeSdfUnwarped(WorldX, WorldY, WorldZ + 1.0f);
const float SDF_Zm1 = Rooms->ProbeSdfUnwarped(WorldX, WorldY, WorldZ - 1.0f);
const float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f;
const float SurfaceHorizontality = FMath::Clamp(FMath::Abs(GZ), 0.0f, 1.0f);
const float TerraceOrientFactor = FMath::Clamp((SurfaceHorizontality - 0.3f) / 0.4f, 0.0f, 1.0f);
// ⚠️ Z BRUT, PAS `EffectiveZ` : les terrasses sont géologiques, elles restent
// horizontales quelle que soit l'échelle verticale de la strate. Idem pour les lignes de
// strates et les nervures plus bas. La rugosité (4b), elle, utilise EffectiveZ. C'est
// délibéré dans l'original et ça se lit dans son commentaire d'en-tête du STEP 3.
float NoisedZ = WorldZ;
if (P.TerraceNoiseDisplacement > 0.0f)
{
const float DispNoise = HFractal3D(FVector(
WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f),
WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f),
WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f)
), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE;
NoisedZ += DispNoise * P.TerraceNoiseDisplacement * StepH;
}
const float K = NoisedZ / StepH;
const float FloorK = FMath::FloorToFloat(K);
const float Frac = K - FloorK; // toujours [0, 1)
const float Edge = FMath::Lerp(0.45f, 0.02f, P.TerraceHardness);
float StairValue;
if (Frac < 0.5f - Edge)
{
StairValue = 0.0f;
}
else if (Frac > 0.5f + Edge)
{
StairValue = 1.0f;
}
else
{
const float T = (Frac - (0.5f - Edge)) / (2.0f * Edge);
StairValue = SmoothStep01(T);
}
const float TerracedZ = (FloorK + StairValue) * StepH;
const float Offset = TerracedZ - NoisedZ; // ≈ [-StepH/2, +StepH/2]
float Fade = 1.0f - (DistFromSurface / TerraceRange);
Fade = Fade * Fade;
InOut.Density += Offset * Fade * TerraceOrientFactor;
}
/** `Both` : `Offset` change de signe d'une demi-marche à l'autre. Amplitude bornée par
* `StepH/2` — troisième client du pliage numérique de `OPSTACK-DECOMPOSITION §0.2`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.TerraceStepHeight > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
uint32 SeedU;
const FRoomGraphSource* Rooms; // NON possédant : la pile possède la source
};
//=========================================================================
// RÔLE 3 — MODIFIER : LIGNES DE STRATES / LAYER LINES (TunnelNetwork, STEP 4c)
//=========================================================================
// Rainures horizontales dans les parois — la strate sédimentaire vue en coupe. Une sinusoïde en
// Z, rectifiée puis CUBÉE : les bosses larges du sinus deviennent des pointes fines, donc des
// rainures étroites au lieu d'une ondulation.
class FLayerLineMod final : public IVoxelDensityOp
{
public:
explicit FLayerLineMod(const FStrateGenerationParams& InP) : P(InP) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float LineRange = P.LayerLineSpacing * 1.5f;
if (!(DistFromSurface < LineRange)) { return; }
const float LinePhase = WorldZ * (2.0f * PI) / P.LayerLineSpacing;
float LineValue = FMath::Sin(LinePhase);
LineValue = FMath::Max(LineValue, 0.0f);
LineValue = LineValue * LineValue * LineValue; // affûtage cubique
float Fade = 1.0f - (DistFromSurface / LineRange);
Fade = Fade * Fade;
InOut.Density -= LineValue * P.LayerLineDepth * Fade;
}
/** Ne SOUSTRAIT que (`LineValue ≥ 0`, `Depth ≥ 0`) ⇒ `CarveOnly`, jamais `Both`. Un des
* rares modificateurs de détail qui garde une DIRECTION exploitable par le pliage. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.LayerLineSpacing > 0.0f && P.LayerLineDepth > 0.0f)
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
};
//=========================================================================
// RÔLE 3 — MODIFIER : NERVURES / RIBBING (TunnelNetwork, STEP 4c)
//=========================================================================
// La MÊME sinusoïde en Z que les lignes de strates, décalée d'un quart de période
// (`+ PI · 0.5`), rectifiée puis CARRÉE au lieu de cubée, et AJOUTÉE au lieu d'être soustraite :
// des bourrelets arrondis (tube de lave) au lieu de rainures fines.
//
// ⚠️ Deux opérateurs, pas un avec un signe : l'exposant diffère (3 contre 2), la phase diffère,
// et le paramètre d'espacement est indépendant. Les fusionner demanderait trois paramètres pour
// économiser dix lignes, et rendrait la correspondance avec l'original illisible.
class FRibbingMod final : public IVoxelDensityOp
{
public:
explicit FRibbingMod(const FStrateGenerationParams& InP) : P(InP) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float RibRange = P.RibbingSpacing * 1.5f;
if (!(DistFromSurface < RibRange)) { return; }
const float RibPhase = WorldZ * (2.0f * PI) / P.RibbingSpacing + PI * 0.5f;
float RibValue = FMath::Sin(RibPhase);
RibValue = FMath::Max(RibValue, 0.0f);
RibValue = RibValue * RibValue; // profil de bosse arrondi
float Fade = 1.0f - (DistFromSurface / RibRange);
Fade = Fade * Fade;
InOut.Density += RibValue * P.RibbingDepth * Fade;
}
/** N'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.RibbingSpacing > 0.0f && P.RibbingDepth > 0.0f)
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
};
//========================================================================= //=========================================================================
// RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork) // RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork)
//========================================================================= //=========================================================================
@@ -2430,12 +2704,12 @@ namespace VoxelDensityOps
void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{ {
// ⚠️ ÉTAPES A + B1 — LA PILE EST ENCORE INCOMPLÈTE, ET DÉLIBÉRÉMENT. // ⚠️ ÉTAPES A + B1 + B2 — LA PILE EST ENCORE INCOMPLÈTE, ET DÉLIBÉRÉMENT.
// Sont portés : l'échelle verticale, le roc de base, le warp, le graphe de salles (+ pits // Sont portés : l'échelle verticale, le roc de base, le warp, le graphe de salles (+ pits
// + cheminées), le carve, **la rugosité de paroi (4b)**, les vers, le post structurel. // + cheminées), le carve, **la rugosité (4b), les terrasses, les lignes de strates, les
// **NE SONT PAS ENCORE PORTÉS** les onze modificateurs restants de l'étape 4c-4h (terrasses, // nervures**, les vers, le post structurel. **NE SONT PAS ENCORE PORTÉS** les huit
// lignes de strates, nervures, surplombs, falaise, festons, arches, colonnes, dômes, // modificateurs restants (surplombs, falaise, festons, arches, colonnes, dômes, pincement,
// pincement, biais de sol), ni l'override d'op PAR SALLE. // biais de sol), ni l'override d'op PAR SALLE.
// //
// C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork : // 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 // brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare
@@ -2463,14 +2737,24 @@ namespace VoxelDensityOps
// même chose vue de loin. Noté ici plutôt que laissé en TODO permanent. // 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 constexpr float CarveMinDivisor = 1.0f; // TunnelNetwork plancher son diviseur, cf. FSdfConvertOp
// La source de salles est retenue par pointeur non possédant : les terrasses re-interrogent
// son cache SDF en Z±1. Même motif que `FShaftFieldSource` → `FShaftLedgeMod`.
TUniquePtr<FRoomGraphSource> RoomSource = MakeUnique<FRoomGraphSource>(P, Seed, StrateManager);
const FRoomGraphSource* RoomPtr = RoomSource.Get();
OutStack.Add(MakeConstantRockSource(P.BaseDensity)); OutStack.Add(MakeConstantRockSource(P.BaseDensity));
OutStack.Add(MakeUnique<FRoomGraphSource>(P, Seed, StrateManager)); OutStack.Add(MoveTemp(RoomSource));
OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor)); OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor));
// ── ÉTAPE B : les modificateurs de détail (4b4h), chacun gated sur // ── ÉTAPE B : les modificateurs de détail (4b4h), chacun gated sur
// `Sdf < SDFBlendRadius·3` via VF_NearCaveSurface. Voir la note de l'étape B5 là-bas. // `Sdf < SDFBlendRadius·3` via VF_NearCaveSurface. Voir la note de l'étape B5 là-bas.
// L'ORDRE EST CELUI DE L'ORIGINAL et il compte : chacun lit la densité que le précédent
// a laissée (le biais de sol, en particulier, existe pour rattraper la rugosité).
OutStack.Add(MakeUnique<FCaveRoughnessMod>(P, Seed)); // 4b OutStack.Add(MakeUnique<FCaveRoughnessMod>(P, Seed)); // 4b
// [ÉTAPES B2B4 iront ici : terrasses, lignes, nervures, surplombs, falaise, festons, OutStack.Add(MakeUnique<FCaveTerraceMod>(P, Seed, RoomPtr)); // 4c — terrasses
// arches, colonnes, dômes, pincement, biais de sol] OutStack.Add(MakeUnique<FLayerLineMod>(P)); // 4c — lignes de strates
OutStack.Add(MakeUnique<FRibbingMod>(P)); // 4c — nervures
// [ÉTAPES B3B4 iront ici : surplombs, falaise, festons, arches, colonnes, dômes,
// pincement, biais de sol]
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed)); OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
@@ -266,11 +266,11 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager); const UVoxelStrateManager* StrateManager);
/** /**
* TunnelNetwork — **ÉTAPES A + B1, PILE ENCORE INCOMPLÈTE** : * TunnelNetwork — **ÉTAPES A + B1 + B2, PILE ENCORE INCOMPLÈTE** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b) * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
* → Worms → [structural ×3] * → Terrace → LayerLines → Ribbing → Worms → [structural ×3]
* *
* ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les onze * ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les huit
* modificateurs de détail restants (4c4h) et l'override d'op par salle ne sont pas portés, donc * modificateurs de détail restants (4c4h) et l'override d'op par salle ne sont pas portés, donc
* le monde y perdrait du détail. Le test compare avec ces amplitudes à zéro. * le monde y perdrait du détail. Le test compare avec ces amplitudes à zéro.
* *