diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp index 52573e6..a6f0d65 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackTunnelTest.cpp @@ -1,6 +1,6 @@ // VoxelForgeOpStackTunnelTest.cpp -// TunnelNetwork — ÉTAPE A : le squelette SDF, sans les modificateurs de détail. -// TunnelNetwork — STAGE A: the SDF spine, without the detail modifiers. +// TunnelNetwork — ÉTAPE A (squelette SDF) + ÉTAPE B1 (rugosité de paroi, 4b). +// TunnelNetwork — STAGE A (the SDF spine) + STAGE B1 (wall roughness, 4b). // // POURQUOI UN TEST D'UNE PILE INCOMPLÈTE // `GetDensityWithParams` fait ~1080 lignes et treize modificateurs de détail. Tout porter avant de @@ -15,9 +15,14 @@ // Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise // dans l'autre sens. // -// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les 13 modificateurs, rien sur l'override d'op -// par salle, et rien sur le saut de tuile — `FRoomGraphSource::EffectOverBox` rend `Both`, donc -// aucun verdict n'est prouvable à ce stade. Ces trois manques sont l'étape B et l'étape C. +// 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 +// 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. +// +// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les onze modificateurs restants (4c–4h), rien +// sur l'override d'op par salle, et rien sur le saut de tuile — `FRoomGraphSource::EffectOverBox` +// rend `Both`, donc aucun verdict n'est prouvable à ce stade. Ce sont les étapes B2–B5 et C. // // ⚠️ ÉCHANTILLONNAGE PAR GRAPPES, PAS UNIFORME. Le cache SDF se reconstruit quand la requête sort de // sa boîte de recherche ; 20 000 points uniformément aléatoires feraient ~20 000 `BuildChunkCache` @@ -50,14 +55,17 @@ namespace constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk; /** - * Met à zéro tout ce que l'étape A n'a pas encore porté, pour que l'original prenne le même - * chemin. Tout sauf `SurfaceRoughness` est DÉJÀ à zéro par défaut ; on l'écrit quand même, parce - * qu'un test qui dépend d'un défaut se casse le jour où le défaut change, et silencieusement. + * Met à zéro tout ce qui n'est PAS ENCORE porté, pour que l'original prenne le même chemin. + * Ces champs-là sont déjà à zéro par défaut ; on les écrit quand même, parce qu'un test qui + * dépend d'un défaut se casse le jour où le défaut change, et silencieusement. + * + * ⚠️ CETTE LISTE RÉTRÉCIT À CHAQUE GROUPE DE L'ÉTAPE B. Une ligne qui part d'ici doit arriver + * dans `EnableTunnelFeatures` ET dans `FeatureProbes` : la déplacer sans la sonder rendrait le + * groupe « activé » sans aucune preuve qu'il s'exécute. `SurfaceRoughness` (B1) est le premier + * à avoir fait le trajet — c'était le seul non nul par défaut (5.0). */ void DisableStageBModifiers(FStrateGenerationParams& P) { - P.SurfaceRoughness = 0.0f; // le seul non nul par défaut (5.0) - P.DomainWarpStrength = 0.0f; P.TerraceStepHeight = 0.0f; P.TerraceNoiseDisplacement = 0.0f; P.LayerLineSpacing = 0.0f; @@ -97,6 +105,16 @@ namespace P.RoomSpacing = 42.0f; // 80 → 42 : des salles à portée de chaque chunk échantillonné P.RoomDensity = 0.85f; // 0.35 → 0.85 P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout + + // ── ÉTAPE B1 : rugosité de paroi (4b) ──────────────────────────────────────────────── + // Écrits EXPLICITEMENT, pas laissés au défaut : un test qui dépend d'un défaut se casse en + // silence le jour où le défaut change. `SurfaceRoughness` était le seul de ces champs non nul + // par défaut (5.0), et l'étape A le remettait à zéro — c'est ce zéro qui disparaît ici. + P.SurfaceRoughness = 5.0f; + P.RoughnessFrequency = 0.1f; + 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.DomainWarpFrequency = 0.03f; } /** @@ -155,6 +173,66 @@ namespace /** Fraction minimale d'échantillons devant tomber en grotte ouverte pour que l'équivalence * signifie quelque chose. 10 % est modeste et très au-dessus du 1,1 % observé. */ constexpr float MinCaveFraction = 0.10f; + + //========================================================================= + // COUVERTURE PAR GROUPE — une entrée par groupe de l'étape B + //========================================================================= + // ⚠️ LA LEÇON DES PITS, GÉNÉRALISÉE : **activer une fonctionnalité n'est pas une preuve qu'elle + // a tiré.** `PitDensity = 0.55` n'a rien fait pendant tout un run (mauvais struct) et le test + // restait vert. Ici la question « le groupe B_n a-t-il changé quelque chose ? » se pose de la + // seule façon qui ne puisse répondre juste par hasard : reconstruire la pile avec CE groupe + // éteint et COMPTER LES POINTS QUI BOUGENT. + // + // ⚠️⚠️ POURQUOI CE DIFF-DE-PILES EST LÉGITIME ICI ALORS QU'IL AURAIT MENTI POUR LES PITS : + // le pool d'ops de terrain n'est PAS dans la clé du cache SDF, donc deux piles n'en différant + // que par lui se servaient le même cache `thread_local` et rendaient exactement la même chose. + // Les champs ci-dessous, eux, sont des params — et l'empreinte CRC des params EST dans la clé. + // Deux piles qui n'en diffèrent que par un de ces champs se reconstruisent donc bien chacune. + // + // Enabling a feature is not evidence it fired. Each entry rebuilds the stack with that group + // OFF and counts moved points. Legitimate here (unlike for the op pool) because these are + // params, and the params CRC is part of the SDF cache key. + struct FFeatureProbe + { + const TCHAR* Name; + void (*Disable)(FStrateGenerationParams&); // lambda sans capture ⇒ pointeur de fonction + }; + + const FFeatureProbe FeatureProbes[] = + { + { TEXT("B1 surface roughness (STEP 4b)"), + [](FStrateGenerationParams& Q) { Q.SurfaceRoughness = 0.0f; } }, + }; + + //========================================================================= + // LE `switch` SUR LE TYPE DE BRUIT — quatre branches, et une seule serait testée + //========================================================================= + // L'équivalence principale tourne en FBM. Les trois autres branches (Ridged, Mixed, Cellular) + // et les deux chemins de warp de domaine ne seraient JAMAIS exécutés — une transcription fausse + // dans `case Cellular:` passerait tout l'étage B sans un mot. Ce balayage les prend une par une. + struct FRoughVariant + { + const TCHAR* Name; + EVoxelNoiseType Type; + float WarpStrength; + }; + + const FRoughVariant RoughVariants[] = + { + { TEXT("FBM, no domain warp"), EVoxelNoiseType::FBM, 0.0f }, + { TEXT("FBM, domain warp"), EVoxelNoiseType::FBM, 3.0f }, + { TEXT("Ridged, no domain warp"), EVoxelNoiseType::Ridged, 0.0f }, + { TEXT("Ridged, domain warp"), EVoxelNoiseType::Ridged, 3.0f }, + { TEXT("Mixed, no domain warp"), EVoxelNoiseType::Mixed, 0.0f }, + { TEXT("Mixed, domain warp"), EVoxelNoiseType::Mixed, 3.0f }, + { TEXT("Cellular, no domain warp"), EVoxelNoiseType::Cellular, 0.0f }, + { TEXT("Cellular, domain warp"), EVoxelNoiseType::Cellular, 3.0f }, + }; + + /** Le balayage ne relit pas les 6000 points : les branches de bruit sont par-voxel et sans + * état, donc un sous-ensemble les couvre autant. Ce qui coûte, c'est la reconstruction du + * cache SDF à chaque changement de chunk, et elle est proportionnelle aux chunks visités. */ + constexpr int32 RoughSweepPoints = 1500; } bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) @@ -212,11 +290,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed, Gen->OriginSpineRadius, World.StrateManager.Get()); - // rock + roomgraph + carve + worms + 3 structurels = 7. (Le premier run a dit 7 contre un 6 - // attendu : faute d'arithmétique dans l'attente, pas dans la pile — 4 + 3, comme les îles.) - // Les 13 modificateurs de détail viendront s'insérer entre le carve et les vers, donc ce nombre - // DOIT bouger à l'étape B. - TestEqual(TEXT("the stage-A tunnel stack is decomposed into 7 ops"), Stack.Num(), 7); + // rock + roomgraph + carve + **rugosité (B1)** + worms + 3 structurels = 8. + // Les onze modificateurs restants viendront s'insérer entre la rugosité et les vers, donc ce + // 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é. + TestEqual(TEXT("the stage-A+B1 tunnel stack is decomposed into 8 ops"), Stack.Num(), 8); FVoxelOpContext Ctx; Ctx.Seed = (uint32)World.Settings->Seed; @@ -259,12 +337,18 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) int32 NumInCave = 0, NumInRock = 0; float WorstDelta = 0.0f; + // Gardé pour le contrôle 1b : la référence « pile complète » que chaque sonde de couverture + // compare à une pile dont UN groupe est éteint. Rempli ici pour ne pas repayer une passe. + TArray FullVals; + FullVals.SetNumUninitialized(NumTunnelSamples); + for (int32 i = 0; i < NumTunnelSamples; ++i) { const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; const float Old = Gen->GetDensityWithParams(X, Y, Z, P); const float New = Stack.EvalMC(X, Y, Z); + FullVals[i] = New; const bool bInterior = (Z > InnerBot && Z < InnerTop); if (bInterior && Old >= 0.0f) { ++NumInCave; } // air loin des seals ⇒ salle/tunnel/ver @@ -282,14 +366,15 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) if (NumDiff == 0) { AddInfo(FString::Printf( - TEXT("TunnelNetwork STAGE A: bit-identical across %d samples in %d chunks (%d in open ") - TEXT("cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, so ") - TEXT("effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ") - TEXT("the SHARED BuildChunkCache, the carve with its floored divisor, and the worm carve ") - TEXT("with its network mask. Pits and chimneys are covered only if the bake-coverage ") - TEXT("line below reports non-zero -- this message used to claim them outright, and was ") - TEXT("wrong for a whole run. NOT covered at all: the 13 detail modifiers, the per-room ") - TEXT("op override, and any tile verdict."), + TEXT("TunnelNetwork STAGE A+B1: bit-identical across %d samples in %d chunks (%d in ") + TEXT("open cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, ") + TEXT("so effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ") + TEXT("the SHARED BuildChunkCache, the carve with its floored divisor, wall roughness ") + TEXT("(4b, density-space variant), and the worm carve with its network mask. Pits, ") + TEXT("chimneys and roughness are covered only insofar as the bake-coverage and ") + TEXT("group-coverage lines below report non-zero -- this message used to CLAIM coverage ") + TEXT("outright, and was wrong for a whole run. NOT covered at all: the eleven remaining ") + TEXT("detail modifiers (4c-4h), the per-room op override, and any tile verdict."), NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock)); AddInfo(FString::Printf( @@ -310,7 +395,14 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) TEXT("or chimney Z), then the pit/chimney loops reading UNWARPED coords while the room ") TEXT("SDF reads warped ones, then the SDF cache key (it now includes a params ") TEXT("fingerprint the original lacks -- that can cost a rebuild, never a wrong room), ") - TEXT("then the worm early-out on N1 >= threshold."), + TEXT("then the worm early-out on N1 >= threshold. NEW AT B1, so suspect these first: ") + TEXT("the roughness gate is bNearCaveSurface (SDF < BlendRadius*3) AND ") + TEXT("|SDF| < SurfaceRoughness*2 -- two different windows; the domain warp adds ONE ") + TEXT("shared offset to BOTH noise positions (two independent warps would be tidier and ") + TEXT("wrong); the fine octave set is frequency*3 with +2000/+2500/+3000 offsets; the ") + TEXT("anti-fill clamp is min(TotalRough, 0) only where SDF < 0; and the fade is ") + TEXT("quadratic in DistFromSurface/RoughnessDepth. Roughness also reads EffectiveZ, ") + TEXT("not WorldZ."), NumDiff, NumTunnelSamples, WorstDelta, WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, @@ -333,6 +425,102 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters) 100.0f * (float)NumInCave / (float)NumTunnelSamples, 100.0f * MinCaveFraction)); } + //========================================================================= + // 1b. CHAQUE GROUPE DE L'ÉTAPE B A-T-IL RÉELLEMENT TIRÉ ? + //========================================================================= + // Voir `FeatureProbes` : on rebâtit la pile avec un groupe éteint et on compte les points qui + // BOUGENT. Zéro ⇒ l'équivalence ci-dessus ne dit rien de ce groupe, quelle que soit sa couleur. + // C'est une ERREUR, pas un avertissement : un groupe porté et jamais exécuté est exactement + // l'état dans lequel une faute de transcription traverse tout un run sans se faire voir. + for (const FFeatureProbe& Probe : FeatureProbes) + { + FStrateGenerationParams PWithout = P; + Probe.Disable(PWithout); + + FVoxelOpStack StackWithout; + VoxelDensityOps::BuildTunnelNetworkStack(StackWithout, PWithout, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + StackWithout.PrepareChunk(Ctx); + + int32 NumMoved = 0; + for (int32 i = 0; i < NumTunnelSamples; ++i) + { + const float V = StackWithout.EvalMC((float)Points[i].X, (float)Points[i].Y, + (float)Points[i].Z); + if (!BitEqual(V, FullVals[i])) { ++NumMoved; } + } + + if (NumMoved > 0) + { + AddInfo(FString::Printf( + TEXT("Group coverage -- %s: %d of %d samples (%.1f%%) move when this group is ") + TEXT("switched off, so the equivalence above genuinely covers it."), + Probe.Name, NumMoved, NumTunnelSamples, + 100.0f * (float)NumMoved / (float)NumTunnelSamples)); + } + else + { + AddError(FString::Printf( + TEXT("Group coverage -- %s: ZERO of %d samples move when this group is switched ") + TEXT("off. The group contributed NOTHING to the 6000-sample equivalence, so that ") + TEXT("equivalence says nothing about it. Either its params never reach the op (the ") + TEXT("PitDensity mistake, second time), or its gate never opens at these sample ") + TEXT("points. Do not read the green equivalence as covering this group."), + Probe.Name, NumTunnelSamples)); + } + } + + //========================================================================= + // 1c. LES QUATRE BRANCHES DU `switch` DE BRUIT, ET LES DEUX CHEMINS DE WARP + //========================================================================= + // L'équivalence principale ne prend QU'UNE branche (FBM). Une transcription fausse dans + // `case Cellular:` ou `case Mixed:` la traverserait sans un mot. Chaque variante est donc + // comparée à l'original sur un sous-ensemble des mêmes points. + { + int32 TotalVariantDiffs = 0; + for (const FRoughVariant& V : RoughVariants) + { + FStrateGenerationParams PV = P; + PV.RoughnessNoiseType = V.Type; + PV.DomainWarpStrength = V.WarpStrength; + + FVoxelOpStack VStack; + VoxelDensityOps::BuildTunnelNetworkStack(VStack, PV, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + VStack.PrepareChunk(Ctx); + + int32 VDiff = 0; + for (int32 i = 0; i < RoughSweepPoints; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; + if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV), VStack.EvalMC(X, Y, Z))) + { + ++VDiff; + } + } + + TotalVariantDiffs += VDiff; + if (VDiff > 0) + { + AddError(FString::Printf( + TEXT("Roughness variant '%s': %d of %d samples differ from the original. Only ") + TEXT("this branch of the noise switch is implicated -- the other variants and ") + TEXT("the main equivalence use the same code either side of it."), + V.Name, VDiff, RoughSweepPoints)); + } + } + + if (TotalVariantDiffs == 0) + { + AddInfo(FString::Printf( + TEXT("Roughness noise sweep: all %d variants (FBM / Ridged / Mixed / Cellular, each ") + TEXT("with and without the domain warp) are bit-identical over %d samples. This is ") + TEXT("what makes the three unused branches of the 4b switch mean anything -- the ") + TEXT("main equivalence only ever takes the FBM one."), + (int32)UE_ARRAY_COUNT(RoughVariants), RoughSweepPoints)); + } + } + //========================================================================= // 2. INVARIANCE DE FENÊTRE — le test qui compte le plus sur cet archétype //========================================================================= diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 83c9cac..689a4db 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -42,6 +42,51 @@ namespace Octaves, Lacunarity, Persistence); } + /** Idem pour `RidgedNoise3D` (également `static` dans VoxelGenerator.cpp). Le bruit cellulaire, + * lui, n'a pas besoin d'enveloppe : son corps a migré dans VoxelCaveMorphology.h et s'appelle + * `VoxelNoise::Cellular3D`, avec la MÊME signature `const FVector&` que l'original. */ + FORCEINLINE float HRidged3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); + } + + //========================================================================= + // LE GATE `bNearCaveSurface` — ÉTAPE B + //========================================================================= + // ⚠️ DÉCISION DE L'ÉTAPE B5, ÉCRITE ICI PARCE QUE C'EST LE POINT OÙ ELLE SE LIT. + // Dans l'original, les douze modificateurs de détail vivent dans UN SEUL `if (bNearCaveSurface)`. + // Deux façons de porter ça : (a) un opérateur « conteneur » qui enveloppe ses enfants, (b) le + // même early-out répété dans chaque opérateur. **C'est (b), délibérément :** + // + // • La pile est une LISTE PLATE, et `FVoxelOpStack::ClassifyBox` plie les opérateurs un par un. + // Un conteneur devrait replier ses enfants lui-même — donc reproduire `VF_FoldOp` — et ses + // enfants deviendraient invisibles au pliage. On paierait une abstraction pour en casser une. + // • Un opérateur qui n'existe QUE dans un conteneur n'est pas composable, donc pas transposable + // en asset (Phase 3). Le motif « chaque op teste son propre gate » est déjà celui de + // `FSdfRoughnessMod` (`InOut.Sdf >= ApplyWithin`) et de `FShaftLedgeMod`. + // • Le gate n'est de toute façon PAS uniforme : chaque modificateur a EN PLUS sa propre fenêtre + // (`RoughnessDepth`, `TerraceRange`, `LineRange`…). Le gate partagé n'est qu'un early-out + // commun, pas la condition réelle de chacun. + // + // ⚠️ CE QUE ÇA COÛTE, dit franchement : l'original teste UNE fois et saute les douze ; la pile + // teste douze fois. Douze comparaisons flottantes parfaitement prédites par voxel de roc profond + // — mesurable, mais c'est exactement le genre de chose que `AUDIT §C10` dit de MESURER avant + // d'optimiser. Noté dans OPSTACK-PROGRESS comme poste de perf, pas « corrigé » à l'aveugle. + // + // STAGE B5 DECISION: repeated early-out in each op, NOT a scoping container — the stack is a flat + // list that ClassifyBox folds op by op, and an op that only exists inside a container is not + // composable. Cost stated honestly: twelve predictable compares instead of one branch. + FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius) + { + // Transcrit tel quel, ordre des comparaisons compris : + // const float DetailThreshold = Params.SDFBlendRadius * 3.0f; + // const bool bNearCaveSurface = (CaveSDF < DetailThreshold) && (CaveSDF < FLT_MAX); + const float DetailThreshold = SDFBlendRadius * 3.0f; + return (Sdf < DetailThreshold) && (Sdf < FLT_MAX); + } + //========================================================================= // RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide) //========================================================================= @@ -1964,6 +2009,179 @@ namespace uint32 LayoutVersion = 0; }; + //========================================================================= + // RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE DENSITÉ (TunnelNetwork, STEP 4b) + //========================================================================= + // ⚠️ CE N'EST PAS `FSdfRoughnessMod`, ET C'EST LE PIÈGE QUE `OPSTACK-DECOMPOSITION §1` SIGNALE. + // Les deux s'appellent « rugosité de surface » et lisent le même champ de params, mais : + // + // • variante SDF (Maze / VerticalShafts / FloatingIslands) : `Sdf += bruit·SCALE·Force`. + // Brut, sans fade, sans clamp, fréquence codée en dur au site d'appel. Déplace la SURFACE. + // • variante DENSITÉ (ici) : DEUX jeux d'octaves (principal + fin ×3), warp de domaine + // optionnel, QUATRE types de bruit, un `Min(…, 0)` anti-remplissage, et un fade QUADRATIQUE + // par distance à la surface. Déplace la MATIÈRE, mise à l'échelle par le gradient local. + // + // Les fusionner sous un enum `Space` était la suggestion du §1 ; en les portant, ils n'ont + // presque aucune ligne en commun (le clamp, le fade et le second jeu d'octaves n'ont pas + // d'équivalent dans l'autre). Deux opérateurs, un nom partagé — comme `FGridColumnMod` et le + // futur `FRoomColumnMod`, que le §1 sépare pour la même raison. + // + // ⚠️⚠️ CET OPÉRATEUR EST **HORS** DE L'OVERRIDE D'OP PAR SALLE, et ce n'est pas un oubli. + // Dans l'original, le shadow `const FStrateGenerationParams& Params = LocalTerrainParams;` est + // déclaré à l'INTÉRIEUR du bloc `if (bNearCaveSurface)` qui commence APRÈS l'étape 4b. La + // rugosité lit donc les params de la STRATE, jamais ceux de la salle la plus proche. Onze + // modificateurs sur douze lisent la copie par salle ; celui-ci non. À NE PAS « uniformiser » + // à l'étape C1. + // + // This op reads STRATE params, not the per-room copy: the original's shadow is declared inside + // the `if (bNearCaveSurface)` block that starts AFTER step 4b. Eleven of twelve modifiers read + // the shadowed copy; this one does not. + class FCaveRoughnessMod final : public IVoxelDensityOp + { + public: + FCaveRoughnessMod(const FStrateGenerationParams& InP, int32 Seed) + : P(InP), SeedU((uint32)Seed) {} + + 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 (!(P.SurfaceRoughness > 0.0f)) { return; } + + const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) + ? (WorldZ / P.VerticalScale) : WorldZ; + + const float RoughnessDepth = P.SurfaceRoughness * 2.0f; + const float DistFromSurface = FMath::Abs(CaveSDF); + if (!(DistFromSurface < RoughnessDepth)) { return; } + + const float RF = P.RoughnessFrequency; + + // ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ (même raison que dans FSdfRoughnessMod) : + // FVector est en DOUBLE, donc chaque produit transite par un double avant d'être + // re-arrondi en float à l'appel du bruit. Sauter l'aller-retour change l'arrondi. + FVector MainPos( + WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f), + WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f), + EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f) + ); + FVector FinePos( + WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f, + WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f, + EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f + ); + + // WARP DE DOMAINE : le MÊME offset est ajouté aux DEUX positions (une seule + // `FVector WarpOffset`, deux `+=`). Transcrit tel quel — appliquer deux warps + // indépendants serait plus « propre » et donnerait un autre monde. + if (P.DomainWarpStrength > 0.0f) + { + const float WF = P.DomainWarpFrequency; + const float WS = P.DomainWarpStrength; + + const float WarpX = VoxelNoise::Perlin3D(FVector( + WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f), + WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f), + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f) + )) * VOXEL_NOISE_SCALE * WS; + + const float WarpY = VoxelNoise::Perlin3D(FVector( + WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f), + WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f), + EffectiveZ * WF + 300.0f + )) * VOXEL_NOISE_SCALE * WS; + + const float WarpZ = VoxelNoise::Perlin3D(FVector( + WorldX * WF + 400.0f, + WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f), + EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f) + )) * VOXEL_NOISE_SCALE * WS; + + const FVector WarpOffset(WarpX, WarpY, WarpZ); + MainPos += WarpOffset; + FinePos += WarpOffset; + } + + // Les comptes d'octaves passent par VoxelGenLOD::Eff — contrat T2.b, les tuiles + // lointaines perdent les octaves sous-cellulaires. + float RoughNoise, FineNoise; + const int32 Oct3 = VoxelGenLOD::Eff(3); + const int32 Oct2 = VoxelGenLOD::Eff(2); + + switch (P.RoughnessNoiseType) + { + case EVoxelNoiseType::Ridged: + RoughNoise = HRidged3D(MainPos, Oct3); + FineNoise = HRidged3D(FinePos, Oct2); + break; + + case EVoxelNoiseType::Mixed: + RoughNoise = HFractal3D(MainPos, Oct3) * 0.5f + + HRidged3D(MainPos, Oct3) * 0.5f; + FineNoise = HFractal3D(FinePos, Oct2) * 0.5f + + HRidged3D(FinePos, Oct2) * 0.5f; + break; + + case EVoxelNoiseType::Cellular: + RoughNoise = VoxelNoise::Cellular3D(MainPos); + FineNoise = VoxelNoise::Cellular3D(FinePos); + break; + + case EVoxelNoiseType::FBM: + default: + RoughNoise = HFractal3D(MainPos, Oct3); + FineNoise = HFractal3D(FinePos, Oct2); + break; + } + + RoughNoise *= VOXEL_NOISE_SCALE; + FineNoise *= VOXEL_NOISE_SCALE; + + float TotalRough = RoughNoise * P.SurfaceRoughness + + FineNoise * P.SurfaceRoughness * 0.4f; + + // CLAMP ANTI-REMPLISSAGE : dans l'air certain (SDF < 0) la rugosité ne doit JAMAIS + // rajouter du solide — sinon lucarnes, membranes, coutures aux jonctions et aux lèvres + // de puits. Elle peut encore creuser plus loin dans la paroi. + if (CaveSDF < 0.0f) + { + TotalRough = FMath::Min(TotalRough, 0.0f); + } + + float SurfaceFade = 1.0f - (DistFromSurface / RoughnessDepth); + SurfaceFade = SurfaceFade * SurfaceFade; // quadratique : concentre près de la surface + + InOut.Density += TotalRough * SurfaceFade; + } + + /** + * `Both` : la rugosité peut pousser dans les deux sens (le clamp ne s'applique que dans + * l'air certain). Conservatif et donc correct, mais coûteux — comme les vers, c'est un + * opérateur dont l'AMPLITUDE est bornée alors que sa DIRECTION ne l'est pas : + * |TotalRough| ≤ 1.4 · SurfaceRoughness · VOXEL_NOISE_SCALE, fade ∈ [0,1]. + * Deuxième client pour le pliage numérique de `OPSTACK-DECOMPOSITION §0.2`, noté au point + * exact où la borne manque (le premier est `FWormFieldSource::MaxCarveAmplitude`). + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.SurfaceRoughness > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + } + + /** La borne d'amplitude, en unités de densité. Pas encore consommée par le pliage. */ + float MaxAmplitude() const + { + return (P.SurfaceRoughness > 0.0f) + ? (1.4f * P.SurfaceRoughness * VOXEL_NOISE_SCALE) : 0.0f; + } + + private: + FStrateGenerationParams P; + uint32 SeedU; + }; + //========================================================================= // RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork) //========================================================================= @@ -2212,12 +2430,12 @@ namespace VoxelDensityOps void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) { - // ⚠️ ÉTAPE A SUR TROIS — LA PILE EST INCOMPLÈTE, ET DÉLIBÉRÉMENT. + // ⚠️ ÉTAPES A + B1 — 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 - // + cheminées), le carve, les vers, le post structurel. **NE SONT PAS ENCORE PORTÉS** les - // treize modificateurs de détail de l'étape 4b-4h (rugosité, terrasses, lignes de strates, - // nervures, surplombs, falaise, festons, arches, colonnes, dômes, pincement, biais de sol), - // ni l'override d'op PAR SALLE. + // + cheminées), le carve, **la rugosité de paroi (4b)**, les vers, le post structurel. + // **NE SONT PAS ENCORE PORTÉS** les onze modificateurs restants de l'étape 4c-4h (terrasses, + // lignes de strates, nervures, surplombs, falaise, festons, arches, colonnes, dômes, + // pincement, biais de sol), ni l'override d'op PAR SALLE. // // C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork : // brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare @@ -2248,7 +2466,11 @@ namespace VoxelDensityOps OutStack.Add(MakeConstantRockSource(P.BaseDensity)); OutStack.Add(MakeUnique(P, Seed, StrateManager)); OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor)); - // [ÉTAPE B ira ici : les 13 modificateurs de détail, gated sur `Sdf < SDFBlendRadius·3`] + // ── ÉTAPE B : les modificateurs de détail (4b–4h), chacun gated sur + // `Sdf < SDFBlendRadius·3` via VF_NearCaveSurface. Voir la note de l'étape B5 là-bas. + OutStack.Add(MakeUnique(P, Seed)); // 4b + // [ÉTAPES B2–B4 iront ici : terrasses, lignes, nervures, surplombs, falaise, festons, + // arches, colonnes, dômes, pincement, biais de sol] OutStack.Add(MakeUnique(P, Seed)); OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 65a579d..bcdd466 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -212,65 +212,15 @@ static float RidgedNoise3D(const FVector& Position, int32 Octaves = 4, // Using F2-F1 (difference of two closest distances) gives smooth cell // boundaries with ridges between cells — more interesting than raw distance. +// Le CORPS a déménagé dans Public/VoxelCaveMorphology.h (namespace VoxelNoise), au plus bas point +// qui voit déjà `VoxelHash` : la pile d'opérateurs a besoin exactement du même bruit pour la +// rugosité (4b, type Cellular) et pour les festons (4f), et deux copies d'une fonction pure finissent +// par diverger — c'est littéralement `AUDIT §C1`. Ce forwarder garde les ~3 sites d'appel ci-dessous +// inchangés, comme l'ont fait FractalNoise3D et RidgedNoise3D lors de T2.a. Aucun changement de +// comportement : corps identique, mêmes doubles de `FVector`, même ordre d'opérations. static float CellularNoise3D(const FVector& Position) { - // Integer cell coordinates - int32 CellX = FMath::FloorToInt(Position.X); - int32 CellY = FMath::FloorToInt(Position.Y); - int32 CellZ = FMath::FloorToInt(Position.Z); - - // Fractional position within cell - float FracX = Position.X - CellX; - float FracY = Position.Y - CellY; - float FracZ = Position.Z - CellZ; - - float F1 = FLT_MAX; // Distance to nearest feature point - float F2 = FLT_MAX; // Distance to 2nd nearest - - // Search 3x3x3 neighborhood - for (int32 DZ = -1; DZ <= 1; DZ++) - { - for (int32 DY = -1; DY <= 1; DY++) - { - for (int32 DX = -1; DX <= 1; DX++) - { - int32 NX = CellX + DX; - int32 NY = CellY + DY; - int32 NZ = CellZ + DZ; - - // Hash the neighbor cell to get a feature point position [0,1) - // Using three different hash mixes for X, Y, Z offsets - uint32 H = VoxelHash::Mix( - (uint32)(NX + 0x7FFFFFFF) - ^ VoxelHash::Mix((uint32)(NY + 0x7FFFFFFF) * 2654435761u) - ^ VoxelHash::Mix((uint32)(NZ + 0x7FFFFFFF) * 374761393u) - ); - - float FPX = (float)DX + VoxelHash::ToFloat01(H) - FracX; - float FPY = (float)DY + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) - FracY; - float FPZ = (float)DZ + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) - FracZ; - - float DistSq = FPX * FPX + FPY * FPY + FPZ * FPZ; - - // Track closest two distances - if (DistSq < F1) - { - F2 = F1; - F1 = DistSq; - } - else if (DistSq < F2) - { - F2 = DistSq; - } - } - } - } - - // F2 - F1: smooth cell boundaries with ridges between cells - // Sqrt for actual distance, then normalize to ~[-1, 1] - float Result = FMath::Sqrt(F2) - FMath::Sqrt(F1); - // Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types. - return Result * 2.0f - 1.0f; + return VoxelNoise::Cellular3D(Position); } //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index c3435ca..3f9448e 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -242,6 +242,88 @@ namespace VoxelHash } } +//============================================================================= +// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D +//============================================================================= +// ⚠️ POURQUOI CE CORPS VIT ICI ET NON DANS VoxelNoise.h. +// Il a besoin de `VoxelHash::Mix` / `ToFloat01`, qui vivent dans CE fichier. Faire dépendre +// VoxelNoise.h (le socle bas niveau, inclus partout) du header de morphologie de grotte serait une +// inversion de dépendance ; dupliquer les 50 lignes serait un FORK d'une fonction pure — exactement +// le motif qui a produit `AUDIT §C1` (un correctif appliqué à une copie sur deux). Il monte donc au +// point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent. +// +// Ce corps était `static float CellularNoise3D(const FVector&)` dans VoxelGenerator.cpp, invisible +// à la pile d'opérateurs. Déplacement LITTÉRAL : mêmes opérations, même ordre, même passage par +// `FVector` (donc par des doubles) — l'égalité binaire du portage TunnelNetwork en dépend. +// `UVoxelGenerator`'s copy is now a one-line forwarder; the body moved verbatim. +// +// Algorithme : distance au point-feature le plus proche dans une grille hachée. +// 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 − F1), normalisé ~[-1, 1] +// F2−F1 donne des frontières de cellules lisses avec des arêtes entre elles. +namespace VoxelNoise +{ + FORCEINLINE float Cellular3D(const FVector& Position) + { + // Integer cell coordinates + int32 CellX = FMath::FloorToInt(Position.X); + int32 CellY = FMath::FloorToInt(Position.Y); + int32 CellZ = FMath::FloorToInt(Position.Z); + + // Fractional position within cell + float FracX = Position.X - CellX; + float FracY = Position.Y - CellY; + float FracZ = Position.Z - CellZ; + + float F1 = FLT_MAX; // Distance to nearest feature point + float F2 = FLT_MAX; // Distance to 2nd nearest + + // Search 3x3x3 neighborhood + for (int32 DZ = -1; DZ <= 1; DZ++) + { + for (int32 DY = -1; DY <= 1; DY++) + { + for (int32 DX = -1; DX <= 1; DX++) + { + int32 NX = CellX + DX; + int32 NY = CellY + DY; + int32 NZ = CellZ + DZ; + + // Hash the neighbor cell to get a feature point position [0,1) + // Using three different hash mixes for X, Y, Z offsets + uint32 H = VoxelHash::Mix( + (uint32)(NX + 0x7FFFFFFF) + ^ VoxelHash::Mix((uint32)(NY + 0x7FFFFFFF) * 2654435761u) + ^ VoxelHash::Mix((uint32)(NZ + 0x7FFFFFFF) * 374761393u) + ); + + float FPX = (float)DX + VoxelHash::ToFloat01(H) - FracX; + float FPY = (float)DY + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) - FracY; + float FPZ = (float)DZ + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) - FracZ; + + float DistSq = FPX * FPX + FPY * FPY + FPZ * FPZ; + + // Track closest two distances + if (DistSq < F1) + { + F2 = F1; + F1 = DistSq; + } + else if (DistSq < F2) + { + F2 = DistSq; + } + } + } + } + + // F2 - F1: smooth cell boundaries with ridges between cells + // Sqrt for actual distance, then normalize to ~[-1, 1] + float Result = FMath::Sqrt(F2) - FMath::Sqrt(F1); + // Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types. + return Result * 2.0f - 1.0f; + } +} + //============================================================================= // PER-CHUNK SDF CACHE //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 4da2585..9e35b78 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -266,12 +266,13 @@ namespace VoxelDensityOps const UVoxelStrateManager* StrateManager); /** - * TunnelNetwork — **ÉTAPE A SUR TROIS, PILE INCOMPLÈTE** : - * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → Worms → [structural ×3] + * TunnelNetwork — **ÉTAPES A + B1, PILE ENCORE INCOMPLÈTE** : + * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b) + * → Worms → [structural ×3] * - * ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les 13 - * modificateurs de détail (4b–4h) et l'override d'op par salle ne sont pas portés, donc le monde - * y perdrait tout son détail. Le test compare avec ces amplitudes à zéro. + * ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les onze + * modificateurs de détail restants (4c–4h) 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. * * ⚠️ `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