feat(opstack B1): port TunnelNetwork surface roughness (STEP 4b) + share the cellular noise

STAGE B of TunnelNetwork, group 1 of 5. The stack is now
  ConstantRock -> RoomGraph -> SdfCarve -> CaveRoughness(4b) -> Worms -> [structural x3]
8 ops (was 7). Still NOT wired: UsesOperatorStackForChunk returns false for TunnelNetwork.

TRANSCRIBED
- FCaveRoughnessMod (VoxelDensityOpStack.cpp): STEP 4b literally -- two octave sets
  (main + fine x3 with the +2000/+2500/+3000 offsets), the optional domain warp, the four-way
  noise switch, the min(TotalRough, 0) anti-fill clamp inside definite air, and the quadratic
  fade by distance from surface. Reads EffectiveZ, not WorldZ.
- VoxelNoise::Cellular3D moved verbatim from VoxelGenerator.cpp's `static CellularNoise3D` into
  VoxelCaveMorphology.h; the generator keeps a one-line forwarder, exactly as FractalNoise3D and
  RidgedNoise3D already do since T2.a. It lands in the cave header rather than VoxelNoise.h
  because it needs VoxelHash, and VoxelNoise.h must not depend on the cave header. Forking a pure
  function is how AUDIT C1 happened.
- HRidged3D added next to HFractal3D (same FVector round-trip, deliberately).

TWO THINGS THAT LOOKED WRONG AND WERE PORTED AS-IS
- The domain warp computes ONE offset and adds it to BOTH noise positions, so the main and fine
  octave sets are warped identically. Two independent warps would be tidier and a different world.
- Roughness reads the STRATE params, NOT the per-room override. The original's
  `const FStrateGenerationParams& Params = LocalTerrainParams;` shadow is declared INSIDE the
  `if (bNearCaveSurface)` block that begins after step 4b. So eleven of the twelve detail
  modifiers read the room copy and this one does not -- flagged in the op so C1 does not
  "uniformise" it. (The handoff's "all 13 modifiers read the shadowed copy" is off on both counts:
  it is twelve modifiers, and one of them is outside the shadow.)

STAGE B5 DECIDED HERE, WITH REASONS (VF_NearCaveSurface)
The gate is a repeated early-out per op, NOT a scoping container: the stack is a flat list that
ClassifyBox folds op by op, a container would have to re-implement VF_FoldOp and would hide its
children from the fold, and an op that only exists inside a container is not a Phase-3 asset.
Cost stated rather than hidden: the original tests once and skips twelve, the stack tests twelve
times. Measured before optimised -- that is the C10 lesson.

TEST (same commit)
- Op count 7 -> 8.
- SurfaceRoughness/DomainWarp moved from DisableStageBModifiers into EnableTunnelFeatures.
- NEW check 1b, group coverage: rebuild the stack with the group OFF and count moved samples.
  Zero is an ERROR, not a warning -- a ported group that never executes is exactly how a bad
  transcription survives a whole green run (the PitDensity lesson). This diff-of-stacks is sound
  here although it lied for the op pool, because these are params and the params CRC IS in the
  SDF cache key.
- NEW check 1c: all 4 noise types x {warp off, warp on} compared to the original over 1500 points.
  The main equivalence only ever takes the FBM branch; three of four cases were untested.

IF THIS GROUP IS WRONG, what breaks first: the main equivalence reports diffs concentrated in
open cave and within |SDF| < SurfaceRoughness*2; if instead only check 1c fails, the fault is
inside one branch of the noise switch and nothing else is implicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 03:18:21 +02:00
parent 7a87cdda14
commit 6e29cbfe4c
5 changed files with 535 additions and 92 deletions
@@ -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 (4c4h), 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 B2B5 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<float> 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
//=========================================================================
@@ -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<FRoomGraphSource>(P, Seed, StrateManager));
OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor));
// [ÉTAPE B ira ici : les 13 modificateurs de détail, gated sur `Sdf < SDFBlendRadius·3`]
// ── ÉTAPE B : les modificateurs de détail (4b4h), chacun gated sur
// `Sdf < SDFBlendRadius·3` via VF_NearCaveSurface. Voir la note de l'étape B5 là-bas.
OutStack.Add(MakeUnique<FCaveRoughnessMod>(P, Seed)); // 4b
// [ÉTAPES B2B4 iront ici : terrasses, lignes, nervures, surplombs, falaise, festons,
// arches, colonnes, dômes, pincement, biais de sol]
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
+7 -57
View File
@@ -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);
}
//=============================================================================
@@ -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]
// F2F1 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
//=============================================================================
@@ -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 (4b4h) 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 (4c4h) et l'override d'op par salle ne sont pas portés, donc
* le monde y perdrait du détail. Le test compare avec ces amplitudes à zéro.
*
* ⚠️ `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