From 9591088d34ca663d24e4fb677772d30fd997aadb Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 28 Jul 2026 03:39:38 +0200 Subject: [PATCH] feat(opstack C1): per-room op override -- eleven detail ops read LocalParams() from the room source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Tests/VoxelForgeOpStackTunnelTest.cpp | 139 +++++++++-- .../Private/VoxelDensityOpStack.cpp | 230 +++++++++++++----- .../VoxelForge/Public/VoxelDensityOpStack.h | 10 +- 3 files changed, 296 insertions(+), 83 deletions(-) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index 0efb9ed..e96f79b 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1,6 +1,6 @@ // VoxelForgeOpStackTunnelTest.cpp -// TunnelNetwork — ÉTAPE A (squelette SDF) + ÉTAPE B COMPLÈTE (les douze modificateurs, 4b–4h). -// 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(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( + 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(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(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(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(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 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 //========================================================================= diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 503a7e8..71fa4c5 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -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 (4b–4h), 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(P, Seed)); // 4b OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — terrasses - OutStack.Add(MakeUnique(P)); // 4c — lignes de strates - OutStack.Add(MakeUnique(P)); // 4c — nervures - OutStack.Add(MakeUnique(P, Seed)); // 4c — surplombs - OutStack.Add(MakeUnique(P, Seed)); // 4c — falaise - OutStack.Add(MakeUnique(P, Seed)); // 4c — festons + OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — lignes de strates + OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — nervures + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — surplombs + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — falaise + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — festons OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — arches OutStack.Add(MakeUnique(P, RoomPtr)); // 4d — colonnes (pré-cuites) OutStack.Add(MakeUnique(P, RoomPtr)); // 4g — dômes diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 77974b6..582feb7 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -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