feat(opstack C1): per-room op override -- eleven detail ops read LocalParams() from the room source

STAGE C group 1. No operator added: nineteen ops before, nineteen after. What changed is what
ELEVEN of them READ.

THE MECHANISM, AND WHY §2's "NO CLEAN HOME" PROBLEM DISSOLVED
FRoomGraphSource gains LocalParams(): the strate params with the nearest room's hash-rolled
UVoxelTerrainOpDefinition applied on top, exactly as the original's
`FStrateGenerationParams LocalTerrainParams = Params; ... NR.RoomOp->ApplyTo(...)` shadow does.
Every detail op that read the shadowed copy now reads that.

OPSTACK-DECOMPOSITION §2 called this the piece with no clean home and proposed giving each modifier
an "only inside room N" predicate. The difficulty came entirely from assuming each modifier must
OWN its params. One op owns the shared state, the rest read it -- and that pattern was already
established here twice (FOverhangShelfMod <- FSurfaceColumnSource, FShaftLedgeMod <-
FShaftFieldSource). No scoping predicate was invented. Same shape as the pit/chimney resolution.

ELEVEN, NOT TWELVE, AND THAT IS READ FROM THE CODE
Surface roughness (4b) does NOT read the per-room copy: the original's
`const FStrateGenerationParams& Params = LocalTerrainParams;` is declared INSIDE the
`if (bNearCaveSurface)` block, which begins after step 4b. Ported that way and flagged in the op so
it does not get "uniformised" later.

MEMOISED PER VOXEL, AND WHY THAT IS FIDELITY RATHER THAN OPTIMISATION
The original builds the copy ONCE per voxel inside the gate. Eleven ops calling LocalParams()
would build it eleven times. So FState carries a valid-flag that FRoomGraphSource::Eval clears at
the top of every voxel (before any early-out, so nothing can read the previous voxel's room), and
the first modifier that asks pays for it. Deep rock pays nothing, exactly as before.
The ~74-field copy itself is TRANSCRIBED AS-IS. It is a real per-voxel cost; recorded in
OPSTACK-PROGRESS as a perf item, not "improved" here.

ONE THING THIS EXPOSES, WRITTEN DOWN BEFORE IT BITES
EffectOverBox still answers from the STRATE params, because a box spans many rooms and a per-voxel
copy has no meaning there. Since ApplyTo writes the op's value even where the strate's was 0, a
room op can ENABLE a modifier the strate had off -- so a box verdict can now be too optimistic on
a strate that has a terrain-op pool. Harmless today (nothing consumes ClassifyBox in production)
and it MUST be fixed before ClassifyTile does. Noted at FLayerLineMod::EffectOverBox and in
OPSTACK-PROGRESS.

TEST (same commit) -- this is the acceptance stage B could not make
- The pool gains a Terrace op with values (3.0 / 0.95 / 1.4) deliberately far from the strate's
  (6.0 / 0.6 / 0.5). A stack that ignored the override now CANNOT be bit-identical.
- New check 3c asks the structure, not the density, because there is still no params probe for a
  pool (the pool is not in the SDF cache key): how many baked rooms carry a Terrace op, and how
  many samples fall inside one of those rooms' influence radius. Both must be non-zero, or check
  1's green means only that two paths agree where the override never applies -- which is what
  stage B already proved. Fourth application of the pit lesson.

IF THIS GROUP IS WRONG: check 1 fails with diffs clustered inside a subset of rooms (those that
drew Terrace) and terrace-shaped Z banding, while check 3c still reports non-zero coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 03:39:38 +02:00
parent 8a303cc085
commit 9591088d34
3 changed files with 296 additions and 83 deletions
@@ -1,6 +1,6 @@
// VoxelForgeOpStackTunnelTest.cpp
// TunnelNetwork — ÉTAPE A (squelette SDF) + ÉTAPE B COMPLÈTE (les douze modificateurs, 4b4h).
// TunnelNetwork — STAGE A (the SDF spine) + ALL OF STAGE B (the twelve detail modifiers, 4b-4h).
// TunnelNetwork — ÉTAPES A + B + C1 : l'archétype ENTIER, override d'op par salle compris.
// TunnelNetwork — STAGES A + B + C1: the WHOLE archetype, per-room op override included.
//
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
// `GetDensityWithParams` fait ~1080 lignes et douze modificateurs de détail (le chiffre « treize »
@@ -24,17 +24,16 @@
// • B3 : surplombs, falaise, festons, arches — STEP 4c.
// • B4 : colonnes (4d), dômes (4g), pincement (4h), biais de sol. La liste des amplitudes
// éteintes est vide : les douze modificateurs sont portés.
// • B5 (ce commit) : le GATE lui-même. Aucun opérateur ajouté — la décision (early-out répété par
// opérateur plutôt que conteneur de portée) est écrite dans `VF_NearCaveSurface`, et le
// contrôle 1d la paie : hors gate, la pile complète doit être BIT À BIT celle sans modificateurs.
// • B5 : le GATE lui-même. Aucun opérateur ajouté — la décision (early-out répété par opérateur
// plutôt que conteneur de portée) est écrite dans `VF_NearCaveSurface`, et le contrôle 1d la
// paie : hors gate, la pile complète doit être BIT À BIT celle sans modificateurs.
// • C1 (ce commit) : l'override d'op PAR SALLE. Aucun opérateur ajouté non plus — onze des douze
// modificateurs lisent désormais `FRoomGraphSource::LocalParams()`. Le pool du test reçoit un op
// `Terrace` dont les valeurs écrasent celles de la strate : **une pile qui ignorerait l'override
// ne peut plus être bit-identique**, ce qu'aucun test de l'étape B ne pouvait exiger.
//
// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur l'override d'op PAR SALLE — onze des douze
// modificateurs lisent, dans l'original, une copie des params où l'op de la salle la plus proche a
// été appliqué, et la pile lit les params de la strate. Les deux coïncident **uniquement** parce que
// le pool du test ne contient que Pit / Chimney / Column, dont les `ApplyTo` n'écrivent aucun champ
// de détail. Un op `Terrace` dans ce pool casserait l'équivalence : c'est exactement le test que C1
// ajoutera, et c'est la seule preuve possible de l'override. Rien non plus sur le saut de tuile —
// `FRoomGraphSource::EffectOverBox` rend `Both`.
// CE QUE CE TEST NE PROUVE TOUJOURS PAS : le saut de tuile — `FRoomGraphSource::EffectOverBox` rend
// `Both` et le ver `CarveOnly` partout, donc zéro verdict prouvé, ce que le contrôle 4 ASSERTE.
//
// ⚠️ É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`
@@ -204,6 +203,26 @@ namespace
ChimOp->ChimneyHeight = 18.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ChimOp));
// ⚠️⚠️ AJOUTÉ À L'ÉTAPE C1, ET C'EST *LE* TEST DE L'OVERRIDE PAR SALLE.
// Jusqu'ici le pool était volontairement limité aux types Pit / Chimney / Column, dont les
// `ApplyTo` n'écrivent AUCUN champ de détail : c'est ce qui rendait l'étape B vérifiable
// alors même que les onze modificateurs lisaient les params de la strate au lieu de ceux de
// la salle. Un op `Terrace` change exactement ça — les salles qui le tirent voient
// `TerraceStepHeight/Hardness/NoiseDisplacement` ÉCRASÉS par ceux de l'op. Une pile qui
// ignorerait l'override rendrait donc une densité différente **sur ces salles-là**, et
// l'équivalence tomberait. C'est la seule preuve possible de C1.
//
// Valeurs délibérément TRÈS différentes de celles de la strate (6.0 / 0.6 / 0.5) : un
// override qui n'écrase qu'avec des valeurs proches serait indétectable au bit près sur peu
// de points, et « peu de points » est une couverture qu'on ne saurait pas lire.
UVoxelTerrainOpDefinition* TerraceOp = NewObject<UVoxelTerrainOpDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
TerraceOp->Type = EVoxelTerrainOpType::Terrace;
TerraceOp->TerraceStepHeight = 3.0f; // strate : 6.0
TerraceOp->TerraceHardness = 0.95f; // strate : 0.6
TerraceOp->TerraceNoiseDisplacement = 1.4f; // strate : 0.5
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(TerraceOp));
// ⚠️ AJOUTÉ À L'ÉTAPE B4 : sans cette entrée, `SDFCache.Columns` reste VIDE et l'opérateur
// de colonnes ne s'exécute sur rien — ce que le contrôle 3b exigeait à l'étape A et qu'il
// exige désormais dans l'autre sens.
@@ -215,8 +234,8 @@ namespace
ColOp->ColumnMaxRadius = 5.0f;
OutKeepAlive.Add(TStrongObjectPtr<UVoxelTerrainOpDefinition>(ColOp));
// `Probability = 1.0` sur les trois : la somme dépasse 1, donc la sélection est normalisée
// et chaque salle tire exactement un op parmi les trois, à parts égales.
// `Probability = 1.0` sur les quatre : la somme dépasse 1, donc la sélection est normalisée
// et chaque salle tire exactement un op parmi les quatre, à parts égales.
FStrateTerrainOpEntry PitEntry;
PitEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(PitOp);
PitEntry.Weight = 1.0f;
@@ -232,10 +251,19 @@ namespace
ColEntry.Weight = 1.0f;
ColEntry.Probability = 1.0f;
// ⚠️ `Weight = 1.0` et pas autre chose : `ApplyTo` multiplie le champ d'ACTIVATION par le
// poids (`TerraceStepHeight * Weight`) et copie les autres tels quels. Un poids ≠ 1 rendrait
// la valeur attendue moins lisible dans un diff sans rien prouver de plus.
FStrateTerrainOpEntry TerraceEntry;
TerraceEntry.Operation = TSoftObjectPtr<UVoxelTerrainOpDefinition>(TerraceOp);
TerraceEntry.Weight = 1.0f;
TerraceEntry.Probability = 1.0f;
Def->TerrainOperations.Reset();
Def->TerrainOperations.Add(PitEntry);
Def->TerrainOperations.Add(ChimEntry);
Def->TerrainOperations.Add(ColEntry);
Def->TerrainOperations.Add(TerraceEntry);
}
/** Fraction minimale d'échantillons devant tomber en grotte ouverte pour que l'équivalence
@@ -911,6 +939,89 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
TotalColumns > 0);
}
//=========================================================================
// 3c. L'OVERRIDE D'OP PAR SALLE A-T-IL EU L'OCCASION DE COMPTER ? (ÉTAPE C1)
//=========================================================================
// Le pool contient depuis C1 un op `Terrace` dont les valeurs (3.0 / 0.95 / 1.4) écrasent celles
// de la strate (6.0 / 0.6 / 0.5) sur les salles qui le tirent. Une pile qui ignorerait l'override
// rendrait donc une densité DIFFÉRENTE près de ces salles, et le contrôle 1 tomberait.
//
// ⚠️ MAIS SEULEMENT SI DE TELLES SALLES EXISTENT ET SI ON A ÉCHANTILLONNÉ PRÈS D'ELLES. C'est
// exactement le piège des pits, une quatrième fois : une équivalence verte prouverait alors que
// deux chemins s'accordent là où l'override ne s'applique pas. Il n'y a pas de sonde de params
// possible (le pool n'est pas dans la clé du cache), donc on interroge encore la STRUCTURE :
// (1) combien de salles portent un op `Terrace` ?
// (2) combien d'échantillons tombent dans le rayon d'influence d'une de ces salles ?
// Zéro à l'une ou l'autre ⇒ le contrôle 1 ne dit RIEN de C1, quelle que soit sa couleur.
//
// (2) est approximatif — il mesure en coordonnées NON warpées, alors que le voxel choisit sa
// salle en coordonnées warpées. C'est un compteur de COUVERTURE, pas un oracle : sur-estimer
// légèrement ne rend rien faux, ça rend seulement le seuil un peu généreux.
{
int32 StrateIdx = 0;
{
const int32 QZ = FMath::FloorToInt((float)((TopVoxelZ + BottomVoxelZ) / 2) / (float)CHUNK_SIZE);
StrateIdx = World.StrateManager->GetStrateIndex(
((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
}
const UVoxelStrateDefinition* Def = World.Definitions[FTestWorld::SlotTunnelNetwork].Get();
// UNE grande boîte couvrant tout le domaine échantillonné (chunks XY de -3 à +3), plutôt que
// 49 boîtes par chunk : les salles sont hachées par cellule, donc leurs positions ne
// dépendent pas du découpage — seule la décision de STOCKAGE change, et on ne lit ici que
// des centres et des ops.
const float Expansion = P.CaveWarpStrength + 2.0f;
const float BigMinX = -3.0f * CHUNK_SIZE - Expansion;
const float BigMinY = -3.0f * CHUNK_SIZE - Expansion;
const float BigMaxX = 4.0f * CHUNK_SIZE + Expansion;
const float BigMaxY = 4.0f * CHUNK_SIZE + Expansion;
FChunkSDFCache BigCache;
VoxelCaveMorphology::BuildChunkCache(
BigCache, BigMinX, BigMinY, BigMaxX, BigMaxY,
P, (uint32)World.Settings->Seed, StrateIdx, &Def->TerrainOperations);
TArray<const FCachedRoom*> TerraceRooms;
int32 NumRoomsWithOp = 0;
for (const FCachedRoom& R : BigCache.Rooms)
{
if (R.RoomOp == nullptr) { continue; }
++NumRoomsWithOp;
if (R.RoomOp->Type == EVoxelTerrainOpType::Terrace) { TerraceRooms.Add(&R); }
}
int32 NumSamplesNearTerrace = 0;
for (int32 i = 0; i < NumTunnelSamples; ++i)
{
const FVector Pt = Points[i];
for (const FCachedRoom* R : TerraceRooms)
{
if ((float)FVector::DistSquared(Pt, R->Center) < R->CullRadiusSq)
{
++NumSamplesNearTerrace;
break;
}
}
}
AddInfo(FString::Printf(
TEXT("Per-room override coverage (stage C1): %d of %d baked rooms carry a terrain op, ")
TEXT("%d of them a Terrace op whose params (3.0/0.95/1.4) overwrite the strate's ")
TEXT("(6.0/0.6/0.5); %d of %d samples fall inside one of those rooms' influence radius. ")
TEXT("The bit-identity in check 1 means C1 works ONLY insofar as those last two numbers ")
TEXT("are non-zero -- otherwise it proves that two paths agree where the override never ")
TEXT("applies, which is what stage B already proved."),
NumRoomsWithOp, BigCache.Rooms.Num(), TerraceRooms.Num(),
NumSamplesNearTerrace, NumTunnelSamples));
TestTrue(TEXT("the bake rolled at least one Terrace op onto a room (stage C1's premise)"),
TerraceRooms.Num() > 0);
TestTrue(TEXT("at least one sample lands inside a Terrace-op room, so the per-room override ")
TEXT("is actually exercised by the equivalence above"),
NumSamplesNearTerrace > 0);
}
//=========================================================================
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
//=========================================================================
+166 -64
View File
@@ -26,6 +26,7 @@
#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 "VoxelTerrainOpDefinition.h" // ApplyTo — l'override d'op PAR SALLE (étape C1)
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
@@ -1817,6 +1818,13 @@ namespace
/** La salle de SDF minimal pour le dernier voxel évalué. -1 = aucune. */
int32 NearestRoom = -1;
/** ÉTAPE C1 — les params de la strate avec l'op de CETTE salle appliqué par-dessus.
* Mémo par voxel : invalidé au début de chaque `Eval`, calculé au PREMIER modificateur
* qui le demande. C'est ce qui reproduit le coût de l'original (une copie de struct par
* voxel PRÈS D'UNE SURFACE, pas partout) sans que onze opérateurs la refassent chacun. */
FStrateGenerationParams LocalParams;
bool bLocalParamsValid = false;
};
static FState& State()
@@ -1832,6 +1840,55 @@ namespace
* Lu par les arches, les dômes, le pincement et le biais de sol. */
int32 GetNearestRoomIdx() const { return State().NearestRoom; }
/**
* ÉTAPE C1 — L'OVERRIDE D'OP PAR SALLE. Les params de la strate avec l'op de terrain tiré
* pour la salle la plus proche appliqué par-dessus. **ONZE des douze modificateurs de détail
* lisent ceci au lieu de leurs propres params.**
*
* ⚠️ POURQUOI ÇA VIT ICI ET PAS DANS CHAQUE MODIFICATEUR.
* `OPSTACK-DECOMPOSITION §2` disait que cette pièce n'a « pas de domicile propre » et
* proposait de donner à chaque modificateur un prédicat « seulement dans la salle N ». La
* difficulté venait d'une hypothèse : que chaque modificateur doive POSSÉDER ses params. Dès
* qu'UN opérateur possède l'état partagé et que les autres le LISENT, elle disparaît — et ce
* motif est déjà celui de `FOverhangShelfMod` ← `FSurfaceColumnSource` et de
* `FShaftLedgeMod` ← `FShaftFieldSource`. C'est la résolution des pits/cheminées une
* deuxième fois : ne pas inventer de mécanisme de portée, laisser une source publier.
*
* ⚠️ LA COPIE DE ~74 CHAMPS PAR VOXEL EST TRANSCRITE TELLE QUELLE. L'original écrit
* `FStrateGenerationParams LocalTerrainParams = Params;` dans le bloc par voxel. C'est un
* poste de perf réel, noté dans OPSTACK-PROGRESS ; le mémo ci-dessous garantit seulement
* qu'on ne la fait pas ONZE fois là où l'original la fait une.
*
* ⚠️ **LA RUGOSITÉ (4b) N'APPELLE PAS CECI**, et c'est la lecture du code, pas une
* simplification : dans l'original le shadow `const FStrateGenerationParams& Params =
* LocalTerrainParams;` est déclaré DANS le bloc `if (bNearCaveSurface)` qui commence APRÈS
* l'étape 4b. La rugosité lit les params de la strate. Douze modificateurs, onze lecteurs.
*
* One op owns the shared state and the rest read it — the same pattern the surface and shaft
* ports already use, and the reason §2's "no clean home" problem evaporates. The ~74-field
* per-voxel copy is the original's, kept. Roughness (4b) deliberately does NOT read this.
*/
const FStrateGenerationParams& LocalParams() const
{
FState& S = State();
if (!S.bLocalParamsValid)
{
S.LocalParams = P;
if (S.NearestRoom >= 0 && S.Cache.Rooms.IsValidIndex(S.NearestRoom))
{
const FCachedRoom& NR = S.Cache.Rooms[S.NearestRoom];
if (NR.RoomOp)
{
// N'écrit que les champs propres au type de l'op ; tout le reste garde la
// valeur de la strate. Exactement l'appel de l'original.
NR.RoomOp->ApplyTo(S.LocalParams, NR.RoomOpWeight);
}
}
S.bLocalParamsValid = true;
}
return S.LocalParams;
}
/**
* Une requête SDF supplémentaire dans le cache courant, pour les sondes de gradient des
* terrasses.
@@ -1871,6 +1928,11 @@ namespace
// 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;
// ÉTAPE C1 — le mémo d'override est PAR VOXEL. L'invalider ici, avant tout early-out,
// est ce qui garantit qu'aucun modificateur ne lira les params de la salle du voxel
// précédent. (Le mémo n'est PAS recalculé ici : le faire coûterait une copie de struct
// sur chaque voxel de roc profond, ce que l'original ne paie pas.)
S.bLocalParamsValid = false;
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX
@@ -2285,9 +2347,10 @@ namespace
// `&& 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 FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.TerraceStepHeight > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float StepH = P.TerraceStepHeight;
const float StepH = LP.TerraceStepHeight;
const float DistFromSurface = FMath::Abs(CaveSDF);
const float TerraceRange = StepH * 3.0f;
if (!(DistFromSurface < TerraceRange)) { return; }
@@ -2305,21 +2368,21 @@ namespace
// 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)
if (LP.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;
NoisedZ += DispNoise * LP.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);
const float Edge = FMath::Lerp(0.45f, 0.02f, LP.TerraceHardness);
float StairValue;
if (Frac < 0.5f - Edge)
{
@@ -2348,6 +2411,7 @@ namespace
* `StepH/2` — troisième client du pliage numérique de `OPSTACK-DECOMPOSITION §0.2`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.TerraceStepHeight > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
}
@@ -2366,7 +2430,8 @@ namespace
class FLayerLineMod final : public IVoxelDensityOp
{
public:
explicit FLayerLineMod(const FStrateGenerationParams& InP) : P(InP) {}
FLayerLineMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms)
: P(InP), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
@@ -2375,13 +2440,15 @@ namespace
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; }
if (Rooms == nullptr) { return; }
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float LineRange = P.LayerLineSpacing * 1.5f;
const float LineRange = LP.LayerLineSpacing * 1.5f;
if (!(DistFromSurface < LineRange)) { return; }
const float LinePhase = WorldZ * (2.0f * PI) / P.LayerLineSpacing;
const float LinePhase = WorldZ * (2.0f * PI) / LP.LayerLineSpacing;
float LineValue = FMath::Sin(LinePhase);
LineValue = FMath::Max(LineValue, 0.0f);
@@ -2390,19 +2457,28 @@ namespace
float Fade = 1.0f - (DistFromSurface / LineRange);
Fade = Fade * Fade;
InOut.Density -= LineValue * P.LayerLineDepth * Fade;
InOut.Density -= LineValue * LP.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
{
// ⚠️ VERDICT DE BOÎTE ET OVERRIDE PAR SALLE : la borne se lit sur les params de la
// STRATE, pas sur ceux d'une salle — une boîte couvre plusieurs salles, donc aucune
// copie par voxel n'y a de sens. Une salle ne peut qu'ACTIVER un modificateur éteint au
// niveau strate, jamais l'inverse… sauf que `ApplyTo` écrit la valeur de l'op, y compris
// quand la strate valait 0. Donc quand une strate a un pool d'ops, ce verdict-ci peut
// être TROP OPTIMISTE. Aucun risque aujourd'hui : rien ne consomme `ClassifyBox` en
// production (cf. la file d'attente post-8/8), et il faudra le régler AVANT que
// `ClassifyTile` ne le consomme. Noté dans OPSTACK-PROGRESS.
return (P.LayerLineSpacing > 0.0f && P.LayerLineDepth > 0.0f)
? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
@@ -2418,7 +2494,8 @@ namespace
class FRibbingMod final : public IVoxelDensityOp
{
public:
explicit FRibbingMod(const FStrateGenerationParams& InP) : P(InP) {}
FRibbingMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms)
: P(InP), Rooms(InRooms) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
@@ -2427,13 +2504,15 @@ namespace
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; }
if (Rooms == nullptr) { return; }
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float RibRange = P.RibbingSpacing * 1.5f;
const float RibRange = LP.RibbingSpacing * 1.5f;
if (!(DistFromSurface < RibRange)) { return; }
const float RibPhase = WorldZ * (2.0f * PI) / P.RibbingSpacing + PI * 0.5f;
const float RibPhase = WorldZ * (2.0f * PI) / LP.RibbingSpacing + PI * 0.5f;
float RibValue = FMath::Sin(RibPhase);
RibValue = FMath::Max(RibValue, 0.0f);
@@ -2442,18 +2521,20 @@ namespace
float Fade = 1.0f - (DistFromSurface / RibRange);
Fade = Fade * Fade;
InOut.Density += RibValue * P.RibbingDepth * Fade;
InOut.Density += RibValue * LP.RibbingDepth * Fade;
}
/** N'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.RibbingSpacing > 0.0f && P.RibbingDepth > 0.0f)
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
@@ -2468,8 +2549,8 @@ namespace
class FCaveOverhangMod final : public IVoxelDensityOp
{
public:
FCaveOverhangMod(const FStrateGenerationParams& InP, int32 Seed)
: P(InP), SeedU((uint32)Seed) {}
FCaveOverhangMod(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 {}
@@ -2478,10 +2559,12 @@ namespace
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.OverhangStrength > 0.0f && CaveSDF < FLT_MAX)) { return; }
if (Rooms == nullptr) { return; }
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.OverhangStrength > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float OverhangRange = P.OverhangDepth * 2.0f;
const float OverhangRange = LP.OverhangDepth * 2.0f;
if (!(DistFromSurface < OverhangRange)) { return; }
const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f)
@@ -2489,9 +2572,9 @@ namespace
// Fréquence en Z à 0.15× celle de XY ⇒ les motifs s'étirent horizontalement.
const float OverhangNoise = HFractal3D(FVector(
WorldX * P.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f),
WorldY * P.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f),
EffectiveZ * P.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f)
WorldX * LP.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f),
WorldY * LP.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f),
EffectiveZ * LP.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f)
), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE;
if (OverhangNoise > 0.0f)
@@ -2499,13 +2582,14 @@ namespace
float Fade = 1.0f - (DistFromSurface / OverhangRange);
Fade = Fade * Fade;
InOut.Density += OverhangNoise * P.OverhangDepth * P.OverhangStrength * Fade;
InOut.Density += OverhangNoise * LP.OverhangDepth * LP.OverhangStrength * Fade;
}
}
/** Lobe positif seulement ⇒ n'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.OverhangStrength > 0.0f && P.OverhangDepth > 0.0f)
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
@@ -2513,6 +2597,7 @@ namespace
private:
FStrateGenerationParams P;
uint32 SeedU;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
@@ -2534,8 +2619,8 @@ namespace
class FCaveCliffMod final : public IVoxelDensityOp
{
public:
FCaveCliffMod(const FStrateGenerationParams& InP, int32 Seed)
: P(InP), SeedU((uint32)Seed) {}
FCaveCliffMod(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 {}
@@ -2544,7 +2629,9 @@ namespace
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.CliffStrength > 0.0f && CaveSDF < FLT_MAX)) { return; }
if (Rooms == nullptr) { return; }
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.CliffStrength > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float CliffRange = 8.0f; // constante en dur dans l'original
@@ -2559,7 +2646,7 @@ namespace
EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3× plus vite en Z
)) * VOXEL_NOISE_SCALE;
const float CliffEffect = VertGrad * CaveSDF * P.CliffStrength;
const float CliffEffect = VertGrad * CaveSDF * LP.CliffStrength;
if (FMath::Abs(VertGrad) > 0.3f)
{
@@ -2572,12 +2659,14 @@ namespace
/** `Both` : le signe suit celui de `VertGrad · CaveSDF`, donc les deux directions. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.CliffStrength > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
uint32 SeedU;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
@@ -2592,8 +2681,8 @@ namespace
class FScallopMod final : public IVoxelDensityOp
{
public:
FScallopMod(const FStrateGenerationParams& InP, int32 Seed)
: P(InP), SeedU((uint32)Seed) {}
FScallopMod(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 {}
@@ -2602,16 +2691,18 @@ namespace
{
const float CaveSDF = InOut.Sdf;
if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; }
if (!(P.ScallopStrength > 0.0f && CaveSDF < FLT_MAX)) { return; }
if (Rooms == nullptr) { return; }
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.ScallopStrength > 0.0f && CaveSDF < FLT_MAX)) { return; }
const float DistFromSurface = FMath::Abs(CaveSDF);
const float ScallopRange = P.ScallopStrength * 4.0f;
const float ScallopRange = LP.ScallopStrength * 4.0f;
if (!(DistFromSurface < ScallopRange)) { return; }
const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f)
? (WorldZ / P.VerticalScale) : WorldZ;
const float SF = P.ScallopFrequency;
const float SF = LP.ScallopFrequency;
const float ScallopNoise = VoxelNoise::Cellular3D(FVector(
WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f),
WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f),
@@ -2623,19 +2714,21 @@ namespace
float Fade = 1.0f - (DistFromSurface / ScallopRange);
Fade = Fade * Fade;
InOut.Density -= ScallopNoise * P.ScallopStrength * Fade;
InOut.Density -= ScallopNoise * LP.ScallopStrength * Fade;
}
}
/** Lobe positif seulement, SOUSTRAIT ⇒ `CarveOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.ScallopStrength > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
private:
FStrateGenerationParams P;
uint32 SeedU;
const FRoomGraphSource* Rooms; // NON possédant
};
//=========================================================================
@@ -2664,7 +2757,8 @@ namespace
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.ArchDensity > 0.0f && CaveSDF < P.SDFBlendRadius && CaveSDF < FLT_MAX
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.ArchDensity > 0.0f && CaveSDF < LP.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0))
{
return;
@@ -2686,7 +2780,7 @@ namespace
{
const uint32 AH = VoxelHash::Mix(Room.Hash ^ (0xA4C400u + (uint32)i * 7369u));
if (VoxelHash::ToFloat01(AH) > P.ArchDensity) { continue; }
if (VoxelHash::ToFloat01(AH) > LP.ArchDensity) { continue; }
const uint32 AH2 = VoxelHash::Mix(AH ^ 0xA4C4u);
const float ArcCX = Room.Center.X + VoxelHash::ToFloatSigned(AH2) * Room.RadiusXY * 0.3f;
@@ -2707,7 +2801,7 @@ namespace
const FVector ArchB(ArcCX + CosA * HalfSpan, ArcCY + SinA * HalfSpan, ArcCZ);
const uint32 AH5 = VoxelHash::Mix(AH4 ^ 0xF00Du);
const float ArchRadius = FMath::Lerp(P.ArchMinRadius, P.ArchMaxRadius,
const float ArchRadius = FMath::Lerp(LP.ArchMinRadius, LP.ArchMaxRadius,
VoxelHash::ToFloat01(AH5));
const float ArchSDF = VoxelSDF::Capsule(VoxPos, ArchA, ArchB, ArchRadius);
@@ -2717,7 +2811,7 @@ namespace
{
float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f);
Fill = SmoothStep01(Fill);
InOut.Density += Fill * P.BaseDensity * 1.5f;
InOut.Density += Fill * LP.BaseDensity * 1.5f;
}
}
}
@@ -2727,6 +2821,7 @@ namespace
* dette que `FRoomGraphSource::EffectOverBox`, et elle se paiera au même moment. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.ArchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
@@ -2817,7 +2912,8 @@ namespace
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.DomeDensity > 0.0f && CaveSDF < P.SDFBlendRadius && CaveSDF < FLT_MAX
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.DomeDensity > 0.0f && CaveSDF < LP.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0))
{
return;
@@ -2833,7 +2929,7 @@ namespace
{
const uint32 DH = VoxelHash::Mix(Room.Hash ^ (0xD0AE0u + (uint32)i * 8191u));
if (VoxelHash::ToFloat01(DH) > P.DomeDensity) { continue; }
if (VoxelHash::ToFloat01(DH) > LP.DomeDensity) { continue; }
const uint32 DH2 = VoxelHash::Mix(DH ^ 0xD0A0u);
const float DmX = Room.Center.X + VoxelHash::ToFloatSigned(DH2) * Room.RadiusXY * 0.4f;
@@ -2842,7 +2938,7 @@ namespace
const uint32 DH3 = VoxelHash::Mix(DH2 ^ 0x90DEu);
const float DmRadius = FMath::Min(
FMath::Lerp(P.DomeMinRadius, P.DomeMaxRadius, VoxelHash::ToFloat01(DH3)),
FMath::Lerp(LP.DomeMinRadius, LP.DomeMaxRadius, VoxelHash::ToFloat01(DH3)),
Room.RadiusXY * 0.85f
);
@@ -2850,7 +2946,7 @@ namespace
const float DmCenterZ = Room.Center.Z + Room.RadiusZ * 0.2f
+ VoxelHash::ToFloat01(DH4) * Room.RadiusZ * 0.3f;
const float DmHeight = DmRadius * P.DomeHeightRatio;
const float DmHeight = DmRadius * LP.DomeHeightRatio;
if (WorldZ > DmCenterZ + DmHeight + 3.0f || WorldZ < DmCenterZ - 3.0f) { continue; }
@@ -2871,7 +2967,7 @@ namespace
{
float Carve = FMath::Clamp((DmBlend - DomeSDF) / (DmBlend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
InOut.Density -= Carve * P.BaseDensity * 1.5f;
InOut.Density -= Carve * LP.BaseDensity * 1.5f;
}
}
}
@@ -2879,6 +2975,7 @@ namespace
/** Ne SOUSTRAIT que ⇒ `CarveOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.DomeDensity > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity;
}
@@ -2910,7 +3007,8 @@ namespace
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.PinchDensity > 0.0f && CaveSDF < P.SDFBlendRadius && CaveSDF < FLT_MAX
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.PinchDensity > 0.0f && CaveSDF < LP.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0))
{
return;
@@ -2927,7 +3025,7 @@ namespace
{
const uint32 PnH = VoxelHash::Mix(Room.Hash ^ (0xF1C400u + (uint32)i * 5417u));
if (VoxelHash::ToFloat01(PnH) > P.PinchDensity) { continue; }
if (VoxelHash::ToFloat01(PnH) > LP.PinchDensity) { continue; }
const uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u);
const float PnX = Room.Center.X + VoxelHash::ToFloatSigned(PnH2) * Room.RadiusXY * Spread;
@@ -2946,15 +3044,15 @@ namespace
const float DYPn = WorldY - PnY;
const float DZPn = WorldZ - PnZ;
const float MaxExtent = FMath::Max(P.PinchLength, P.PinchStrength) + 5.0f;
const float MaxExtent = FMath::Max(LP.PinchLength, LP.PinchStrength) + 5.0f;
if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > MaxExtent) { continue; }
const float Along = DXPn * CosPN + DYPn * SinPN;
const float Across = -DXPn * SinPN + DYPn * CosPN;
const float HalfLength = P.PinchLength * 0.5f;
const float HalfNarrow = P.PinchStrength;
const float HalfVertical = P.PinchStrength * 1.5f;
const float HalfLength = LP.PinchLength * 0.5f;
const float HalfNarrow = LP.PinchStrength;
const float HalfVertical = LP.PinchStrength * 1.5f;
const float NAlong = Along / HalfLength;
const float NAcross = Across / HalfNarrow;
@@ -2967,7 +3065,7 @@ namespace
Fill = SmoothStep01(Fill);
const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp);
const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f);
InOut.Density += Fill * SideFactor * P.BaseDensity * 1.5f;
InOut.Density += Fill * SideFactor * LP.BaseDensity * 1.5f;
}
}
}
@@ -2975,6 +3073,7 @@ namespace
/** N'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.PinchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
@@ -3009,7 +3108,8 @@ namespace
if (Rooms == nullptr) { return; }
const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx();
if (!(P.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f)) { return; }
const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE
if (!(LP.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f)) { return; }
const FChunkSDFCache& Cache = Rooms->GetCache();
if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod
@@ -3021,13 +3121,14 @@ namespace
if (NormZ < 0.0f)
{
const float FloorFactor = NormZ * NormZ; // 0 au centre, 1 au sol
InOut.Density += FloorFactor * P.FloorBias;
InOut.Density += FloorFactor * LP.FloorBias;
}
}
/** N'AJOUTE que du solide ⇒ `FillOnly`. */
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
// Borne au niveau STRATE — voir la note d'`FLayerLineMod::EffectOverBox`.
return (P.FloorBias > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
@@ -3284,14 +3385,15 @@ namespace VoxelDensityOps
void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{
// ⚠️ ÉTAPES A + B (B1 À B5) — LES DOUZE MODIFICATEURS DE DÉTAIL SONT PORTÉS.
// **CE QUI RESTE, C'EST L'ÉTAPE C** : l'override d'op PAR SALLE. Tant qu'il manque, les
// onze modificateurs qui, dans l'original, lisent la copie de params de la salle la plus
// proche lisent ici les params de la STRATE. Les deux coïncident exactement tant qu'aucune
// salle ne porte un op de type détail (Terrace, LayerLines, Ribbing, Cliff, Scallop,
// Overhang, Arch, Dome, Pinch) — le test garde donc son pool à Pit/Cheminée/Colonne, dont
// les `ApplyTo` n'écrivent aucun champ de détail. **C'est CETTE condition, et rien d'autre,
// qui rend l'étape B vérifiable avant l'étape C.**
// ⚠️ ÉTAPES A + B + C1 — LA PILE EST COMPLÈTE POUR CET ARCHÉTYPE.
// Portés : échelle verticale, roc de base, warp, graphe de salles (+ pits + cheminées),
// carve, LES DOUZE MODIFICATEURS DE DÉTAIL (4b4h), l'override d'op PAR SALLE, les vers,
// le post structurel.
//
// L'override (C1) n'ajoute AUCUN opérateur : il change ce que ONZE d'entre eux LISENT.
// `FRoomGraphSource::LocalParams()` publie les params de la strate avec l'op de la salle la
// plus proche appliqué ; les onze modificateurs concernés y lisent leurs champs au lieu des
// leurs. La rugosité (4b) NON — dans l'original elle précède la déclaration du shadow.
//
// 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
@@ -3333,11 +3435,11 @@ namespace VoxelDensityOps
// a laissée (le biais de sol, en particulier, existe pour rattraper la rugosité).
OutStack.Add(MakeUnique<FCaveRoughnessMod>(P, Seed)); // 4b
OutStack.Add(MakeUnique<FCaveTerraceMod>(P, Seed, RoomPtr)); // 4c — terrasses
OutStack.Add(MakeUnique<FLayerLineMod>(P)); // 4c — lignes de strates
OutStack.Add(MakeUnique<FRibbingMod>(P)); // 4c — nervures
OutStack.Add(MakeUnique<FCaveOverhangMod>(P, Seed)); // 4c — surplombs
OutStack.Add(MakeUnique<FCaveCliffMod>(P, Seed)); // 4c — falaise
OutStack.Add(MakeUnique<FScallopMod>(P, Seed)); // 4c — festons
OutStack.Add(MakeUnique<FLayerLineMod>(P, RoomPtr)); // 4c — lignes de strates
OutStack.Add(MakeUnique<FRibbingMod>(P, RoomPtr)); // 4c — nervures
OutStack.Add(MakeUnique<FCaveOverhangMod>(P, Seed, RoomPtr)); // 4c — surplombs
OutStack.Add(MakeUnique<FCaveCliffMod>(P, Seed, RoomPtr)); // 4c — falaise
OutStack.Add(MakeUnique<FScallopMod>(P, Seed, RoomPtr)); // 4c — festons
OutStack.Add(MakeUnique<FCaveArchMod>(P, RoomPtr)); // 4c — arches
OutStack.Add(MakeUnique<FRoomColumnMod>(P, RoomPtr)); // 4d — colonnes (pré-cuites)
OutStack.Add(MakeUnique<FDomeMod>(P, RoomPtr)); // 4g — dômes
@@ -266,15 +266,15 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager);
/**
* TunnelNetwork — **ÉTAPES A + B COMPLÈTES, 19 ops** :
* TunnelNetwork — **COMPLET, 19 ops** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×3]
*
* ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C :
* l'override d'op PAR SALLE n'est pas porté, donc les onze modificateurs qui devraient lire les
* params de la salle la plus proche lisent ceux de la strate. Identique tant qu'aucune salle ne
* porte un op de type détail — ce que le test garantit et qu'un monde réel ne garantit pas.
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
* ONZE des douze modificateurs y lisent leurs champs. La rugosité (4b) lit les params de la
* STRATE, parce que dans l'original elle précède la déclaration du shadow.
*
* ⚠️ `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