From 4c53d3bbed756a87438211102ffb4584b36d86e7 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Mon, 27 Jul 2026 02:32:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20Maze=20ported?= =?UTF-8?q?=20to=20the=20operator=20stack,=20OFF=20the=20hot=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maze decomposes into seven ops with no contortion: ConstantRockSource -> LatticeCorridorSource -> SdfRoughnessMod -> SdfCarve -> OriginSpine -> BoundarySeal -> PassageCarve That is the answer to Phase 1's actual question (OPSTACK-PLAN section 4's stop-trigger: "does the source/modifier split fall out naturally?"). It does. Three of those ops are already shared: ConstantRockSource is the first line of TunnelNetwork, Maze AND VerticalShafts; SdfCarve is the same six lines in all three; the structural post is identical across all six density functions. GetDensityAt and ClassifyTile are NOT touched. The archetype switch is still the only path feeding the game, so nothing in a running world can change. The port is validated instead by VoxelForge.OpStack.MazeEquivalence, which compares the stack against GetMazeDensity over 20k points, re-checks purity across worker threads, and brute-forces every box verdict the stack emits. Two contract decisions, delegated and taken: 1. Eval is now two-channel (FVoxelOpSample { Density, Sdf }). Maze forces it: its roughness perturbs the SDF, not the density, and on density the same noise scales with the local gradient and is a visibly different effect. It is also what lets two different sources SmoothMin together later, which is the difference between a composed idea belonging somewhere and being punched into it. 2. The stack's density channel is INTERNAL convention (positive = solid), negated once by the caller. This REVERSES what the header said yesterday. Every archetype body is already written that way, so each port becomes a literal transcription instead of a sign-flip of every line -- on the plugin's documented #1 source of confusion. The SDF channel keeps standard SDF convention, so min() means opposite things on the two channels; the header says so loudly. Also extracts spine/seal/passage from VoxelGenerator.cpp into Public/VoxelDensityPrimitives.h so the generator and the ops share ONE copy of three world invariants. Forwarders keep the local names, so not one of the ~20 call sites changes; bodies are byte-identical. One thing found while writing the seal's ClassifyBox and NOT silently fixed: at the inner edge of a seal band, 1 - Dist/Thickness can round to exactly 0.0f, so SealFactor*BaseDensity is 0, internal density lands on 0, and the mesher counts that as AIR. Claiming AllSolid there would be a hole. The new op keeps a 1-voxel safety margin before it forces. Today's ClassifyTile has no such margin -- the window is hairline and needs the archetype to produce air at exactly that z, but it is real. Reported rather than patched, since Phase 1 does not touch that path. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 --- .../Tests/VoxelForgeOpStackMazeTest.cpp | 297 +++++++++++ .../Private/VoxelDensityOpStack.cpp | 504 ++++++++++++++++++ Source/VoxelForge/Private/VoxelGenerator.cpp | 85 +-- Source/VoxelForge/Public/VoxelDensityOp.h | 92 +++- .../VoxelForge/Public/VoxelDensityOpStack.h | 159 ++++++ .../Public/VoxelDensityPrimitives.h | 130 +++++ 6 files changed, 1188 insertions(+), 79 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp create mode 100644 Source/VoxelForge/Private/VoxelDensityOpStack.cpp create mode 100644 Source/VoxelForge/Public/VoxelDensityOpStack.h create mode 100644 Source/VoxelForge/Public/VoxelDensityPrimitives.h diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp new file mode 100644 index 0000000..708cf34 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -0,0 +1,297 @@ +// VoxelForgeOpStackMazeTest.cpp +// PHASE 1, LE TEST QUI COMPTE — la pile d'opérateurs Maze contre GetMazeDensity. +// PHASE 1'S LOAD-BEARING TEST — the Maze operator stack against GetMazeDensity. +// +// CE QUE LA PHASE 1 DOIT PROUVER / WHAT PHASE 1 HAS TO PROVE +// La question n'est pas « est-ce que le code tourne ». C'est celle du déclencheur d'arrêt de +// `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe naturellement du code +// existant ? »** Si oui, la décomposition reproduit l'original à l'identique sans contorsion. Si +// non, on s'en aperçoit ici — pas trois archétypes plus tard. +// +// Not "does the code run". It is the stop-trigger question from OPSTACK-PLAN §4: **does the +// source/modifier split fall out naturally from the existing code?** If it does, the decomposition +// reproduces the original without contortion. If it doesn't, we find out HERE — not three +// archetypes later. +// +// SUR LA BARRE D'ACCEPTATION / ON THE ACCEPTANCE BAR +// `OPSTACK-PLAN §2.6` n'EXIGE PAS l'identité binaire avec l'ancien système — c'est justement la +// relaxation qui autorise une vraie décomposition plutôt qu'un emballage. Mais Maze se décompose +// si proprement qu'on peut viser l'identité binaire, et quand on peut l'avoir il faut la prendre : +// elle transforme « je crois que la décomposition est juste » en preuve. Un ÉCHEC ici n'est donc +// pas forcément une erreur — c'est un signal à lire (le test rapporte l'écart max et où). +// +// §2.6 does NOT require bit-identity — that relaxation is what permits real decomposition. But Maze +// decomposes cleanly enough to achieve it, and where it is achievable it should be taken: it turns +// belief into proof. A FAILURE here is not automatically a bug — it is a signal to read (the test +// reports the largest divergence and where it is). + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "Async/ParallelFor.h" +#include "HAL/PlatformMisc.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelDensityOpStack.h" + +#include + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeOpStackMazeTest, + "VoxelForge.OpStack.MazeEquivalence", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 NumMazeSamples = 20000; + + /** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */ + bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out, + int32& OutTopVoxelZ, int32& OutBottomVoxelZ) + { + using namespace VoxelForgeTest; + if (!World.GetSlotVoxelZRange(FTestWorld::SlotMaze, OutTopVoxelZ, OutBottomVoxelZ)) { return false; } + const int32 MidChunkZ = ((OutTopVoxelZ + OutBottomVoxelZ) / 2) / CHUNK_SIZE; + Out = World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ)); + return true; + } +} + +bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + FMazeGenerationParams MazeParams; + int32 TopVoxelZ = 0, BottomVoxelZ = 0; + if (!ResolveMazeParams(World, MazeParams, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("The fixture layout has no Maze slot. Check FTestWorld::Build's Archetypes[] ") + TEXT("against FTestWorld::SlotMaze.")); + return false; + } + + // GetMazeDensity court-circuite sur une strate dégénérée (`return 1.0f`, air, en convention MC). + // Cette garde appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une + // strate valide. Vérifier plutôt que supposer. + if (MazeParams.StrateTopWorldZ - MazeParams.StrateBottomWorldZ <= 0.0f) + { + AddError(FString::Printf( + TEXT("The Maze strate has degenerate Z bounds (top %.1f, bottom %.1f), which sends ") + TEXT("GetMazeDensity down its early-out. The op stack has no such early-out by design, ") + TEXT("so the comparison below would be meaningless."), + MazeParams.StrateTopWorldZ, MazeParams.StrateBottomWorldZ)); + return false; + } + + const UVoxelGenerator* Gen = World.Generator.Get(); + + FVoxelOpStack Stack; + VoxelDensityOps::BuildMazeStack(Stack, MazeParams, World.Settings->Seed, + Gen->OriginSpineRadius, World.StrateManager.Get()); + + // La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les + // tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor + // (OPSTACK-PLAN §2.5). C'est le seul test que le nombre d'opérateurs mérite. + // A monolithic FMazeOp would pass every numeric check below and still have missed the entire + // point (OPSTACK-PLAN §2.5). This is the one thing an op COUNT is worth asserting. + TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"), + Stack.Num(), 7); + + FVoxelOpContext Ctx; + Ctx.Seed = (uint32)World.Settings->Seed; + Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion(); + Ctx.StrateTopWorldZ = MazeParams.StrateTopWorldZ; + Ctx.StrateBottomWorldZ = MazeParams.StrateBottomWorldZ; + Stack.PrepareChunk(Ctx); + + // ── Points d'échantillonnage : dans la bande Z de la strate Maze, largement autour de (0,0) + // pour que la spine, les passages et le roc ordinaire soient tous représentés. ── + TArray Points; + Points.Reserve(NumMazeSamples); + { + FRandomStream Rng(31337); + for (int32 i = 0; i < NumMazeSamples; ++i) + { + Points.Add(FVector( + (float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE), + (float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE), + (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ))); + } + } + + // ── L'ÉQUIVALENCE. ── + int32 NumDiff = 0, WorstIdx = -1; + float WorstDelta = 0.0f; + int32 NumSolidDisagreements = 0; // le seul écart qui compte VRAIMENT : un côté d'iso différent + for (int32 i = 0; i < NumMazeSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z; + + const float Old = Gen->GetMazeDensity(X, Y, Z, MazeParams); // MC : négatif = solide + const float New = Stack.EvalMC(X, Y, Z); + + if (!BitEqual(Old, New)) + { + ++NumDiff; + const float Delta = FMath::Abs(Old - New); + if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; } + } + // Le mesher ne lit que le SIGNE (D >= IsoLevel ⇒ air). Deux valeurs peuvent différer d'un + // ULP sans changer un seul triangle ; un désaccord de CÔTÉ change la géométrie. + if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; } + } + + if (NumDiff == 0) + { + AddInfo(FString::Printf( + TEXT("Bit-identical across %d samples. The Maze decomposition (constant rock -> lattice ") + TEXT("corridors -> SDF roughness -> carve -> spine/seal/passage) reproduces ") + TEXT("GetMazeDensity exactly, which is as strong a signal as Phase 1 can get that the ") + TEXT("source/modifier split is real and not imposed."), NumMazeSamples)); + } + else + { + AddWarning(FString::Printf( + TEXT("%d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, %.0f)); %d of them ") + TEXT("land on the OPPOSITE side of the isosurface. OPSTACK-PLAN section 2.6 does not ") + TEXT("require bit-identity, so this is a warning, not a failure -- but Maze SHOULD be ") + TEXT("reproducible exactly, so a nonzero count means the port drifted somewhere. Check, ") + TEXT("in order: the roughness apply-window (R + SurfaceRoughness + 2), the carve blend ") + TEXT("(2.0), the noise frequency (0.12) and octave count (3), and the order of the ") + TEXT("structural post ops."), + NumDiff, NumMazeSamples, WorstDelta, + WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f, + NumSolidDisagreements)); + } + + // Un désaccord de côté d'iso EST une différence de géométrie. C'est la seule chose ici qui + // mérite un échec dur. / A side-of-iso disagreement IS a geometry difference. The one hard fail. + TestEqual(TEXT("no sample lands on the opposite side of the isosurface from the original"), + NumSolidDisagreements, 0); + + // ── La pile doit satisfaire les MÊMES invariants que le reste du générateur. ── + // Invariance de fenêtre : pure, ordre-indépendante, identique sur tous les threads. Le cache + // par cellule de la source de couloirs est `thread_local` — c'est exactement le genre d'endroit + // où une clé incomplète produit une couture (cf. AUDIT C2). + { + TArray Order; + BuildShuffledOrder(NumMazeSamples, 8675309, Order); + std::atomic Impure{ 0 }; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + + TArray Ref; + Ref.SetNumUninitialized(NumMazeSamples); + for (int32 i = 0; i < NumMazeSamples; ++i) + { + Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + } + + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray LocalOrder; + BuildShuffledOrder(NumMazeSamples, 500 + Block, LocalOrder); + for (const int32 i : LocalOrder) + { + const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z); + if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); } + } + }); + + TestEqual(TEXT("the op stack is window-invariant across query order and worker threads"), + Impure.load(), 0); + } + + // ── LE VERDICT DE BOÎTE : Maze n'a JAMAIS su sauter une tuile. ── + // ClassifyTile renvoie Mixed pour tout archétype de grotte ("pas prouvable en v1"), donc + // TunnelNetwork, Maze, VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater + // ne captent RIEN du gain T1.d. C'est le vrai prix perf du refactor, et c'est vérifiable ici. + // + // Maze has NEVER skipped a tile: ClassifyTile returns Mixed for every cave archetype. This is + // the refactor's real perf prize, and it is checkable right here. + { + int32 NumProved = 0, NumMixed = 0, NumUnsound = 0; + FRandomStream Rng(24680); + + for (int32 t = 0; t < 60; ++t) + { + const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable + const int32 Extent = Step * Cells; + const FIntVector Origin( + Rng.RandRange(-6, 6) * Extent, + Rng.RandRange(-6, 6) * Extent, + FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent); + + // La MÊME boîte que le treillis du mesher, marge +/-1 comprise (cf. ClassifyTile). + const int32 GridDim = Cells + 1; + const FBox Box( + FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step), + FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step)); + + const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx); + if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; } + ++NumProved; + + // Force brute : le verdict doit tenir sur CHAQUE point du treillis. Un faux verdict + // n'est pas une imprécision, c'est un trou — pas de géométrie, PAS DE COLLISION. + const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid); + for (int32 gz = -1; gz <= GridDim; ++gz) + for (int32 gy = -1; gy <= GridDim; ++gy) + for (int32 gx = -1; gx <= GridDim; ++gx) + { + const float X = (float)(Origin.X + gx * Step); + const float Y = (float)(Origin.Y + gy * Step); + const float Z = (float)(Origin.Z + gz * Step); + const float D = Stack.EvalMC(X, Y, Z); + if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) + { + if (NumUnsound == 0) + { + AddError(FString::Printf( + TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ") + TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ") + TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ") + TEXT("the lattice source's ExtraReach (does it cover the roughness ") + TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."), + bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"), + Origin.X, Origin.Y, Origin.Z, X, Y, Z, D, + (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID"))); + } + ++NumUnsound; + gz = gy = gx = GridDim + 1; // ce verdict est déjà mort, tuile suivante + } + } + } + + TestEqual(TEXT("every box verdict the stack emits survives brute force (a false verdict is a hole)"), + NumUnsound, 0); + + AddInfo(FString::Printf( + TEXT("Box verdicts over 60 Maze tiles: %d proved uniform, %d Mixed. Today's ClassifyTile ") + TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ") + TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."), + NumProved, NumMixed)); + + if (NumProved == 0) + { + AddWarning(TEXT("The stack proved no tile uniform, so it is not yet better than today's ") + TEXT("classifier for Maze. Not a correctness problem, but the perf case for ") + TEXT("the port rests on this number -- check whether BranchProbability is high ") + TEXT("enough that corridors genuinely reach every sampled tile, or whether the ") + TEXT("lattice source's reach is over-conservative.")); + } + } + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp new file mode 100644 index 0000000..b240896 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -0,0 +1,504 @@ +// VoxelDensityOpStack.cpp +// Les opérateurs concrets de la Phase 1 : la décomposition de Maze + le post-traitement structurel. +// The concrete Phase 1 operators: the Maze decomposition + the structural post-process. +// +// ⚠️ AUCUN de ces opérateurs n'alimente le jeu. Voir l'en-tête de VoxelDensityOpStack.h. +// +// FIDÉLITÉ / FIDELITY +// Chaque corps ci-dessous est une transcription LITTÉRALE du bloc correspondant de +// `UVoxelGenerator::GetMazeDensity` — mêmes hashes, mêmes constantes, même ordre d'opérations +// flottantes, même convention de signe (INTERNE : positif = solide). L'objectif est +// l'égalité BIT à BIT, vérifiée par `VoxelForge.OpStack.MazeEquivalence`. +// +// `OPSTACK-PLAN §2.6` n'EXIGE pas l'identité binaire avec l'ancien système — mais Maze se +// décompose si proprement qu'on peut l'obtenir, et quand on peut l'obtenir il faut la prendre : +// une égalité binaire transforme « je crois que la décomposition est correcte » en preuve. +// +// §2.6 does not REQUIRE bit-identity with the old system — but Maze decomposes cleanly enough that +// it is achievable, and when it is achievable it should be taken: bit-equality turns "I believe the +// decomposition is right" into a proof. + +#include "VoxelDensityOpStack.h" + +#include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / Seal / PassageCarving +#include "VoxelCaveMorphology.h" // VoxelSDF::Capsule, VoxelHash +#include "VoxelGenerator.h" // VoxelGenLOD::Eff +#include "VoxelNoise.h" // VoxelNoise::FBM +#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox +#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE + +namespace +{ + //========================================================================= + // RÔLE 1 — SOURCE : ROC CONSTANT / CONSTANT ROCK + //========================================================================= + // `float Density = Params.BaseDensity; // start solid` — la première ligne de TunnelNetwork, + // de Maze ET de VerticalShafts. Trois archétypes, une ligne, désormais un opérateur. + class FConstantRockSource final : public IVoxelDensityOp + { + public: + explicit FConstantRockSource(float InBaseDensity) : BaseDensity(InBaseDensity) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + void PrepareChunk(const FVoxelOpContext&) override {} + bool IsXYPure() const override { return true; } // constant ⇒ trivialement sans Z + + void Eval(float, float, float, FVoxelOpSample& InOut) const override + { + InOut.Density = BaseDensity; // Replace : racine de pile, ignore l'entrée + } + + // Exact et gratuit : une constante positive est solide partout. C'est ce qui donne aux + // strates de grotte une hypothèse AllSolid de départ — elles n'en ont jamais eu. + EVoxelTileClass ClassifyBox(const FBox&, const FVoxelOpContext&) const override + { + return (BaseDensity > 0.0f) ? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant + } + + private: + float BaseDensity; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : COULOIRS SUR TREILLIS 3D / 3D LATTICE CORRIDORS + //========================================================================= + // Chaque nœud du treillis est au centre d'une cellule ; l'arête vers son voisin +X/+Y/+Z est + // « ouverte » quand un hash de (nœud inférieur, axe) passe BranchProbability (Verticality pour + // Z). Le couloir est une capsule fine. + // + // ⚠️ LA propriété qui fait de Maze le bon premier portage : l'identité d'une arête est + // (nœud INFÉRIEUR, axe). Deux chunks adjacents calculent donc littéralement le même hash pour + // l'arête qu'ils partagent — ils NE PEUVENT PAS être en désaccord. Pas de cache de chunk, pas + // de région COLLECT, pas de discipline d'invariance de fenêtre à maintenir (AUDIT §6.4). + class FLatticeCorridorSource final : public IVoxelDensityOp + { + public: + FLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float InExtraReach) + : CellSize(FMath::Max(P.CellSize, 1.0f)) + , CorridorRadius(FMath::Max(P.CorridorRadius, 0.5f)) + , BranchProbability(P.BranchProbability) + , Verticality(P.Verticality) + , Salt((uint32)Seed ^ 0x4D617A65u) // 'Maze' — identique à GetMazeDensity + , ExtraReach(InExtraReach) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const int32 CX = FMath::FloorToInt(WorldX / CellSize); + const int32 CY = FMath::FloorToInt(WorldY / CellSize); + const int32 CZ = FMath::FloorToInt(WorldZ / CellSize); + + const TArray>& Edges = GetCellEdges(FIntVector(CX, CY, CZ)); + + const FVector Pos(WorldX, WorldY, WorldZ); + float Sdf = FLT_MAX; + for (const FEdge& E : Edges) + { + Sdf = FMath::Min(Sdf, VoxelSDF::Capsule(Pos, E.A, E.B, CorridorRadius)); + } + // Union de formes ⇒ MIN sur le canal SDF (voir la note de signe dans VoxelDensityOp.h : + // « min » ici veut dire l'inverse de ce qu'il veut dire sur le canal densité). + InOut.Sdf = FMath::Min(InOut.Sdf, Sdf); + } + + // Répond pour la paire source + conversion (SIMPLIFICATION DE PHASE 1, cf. VoxelDensityOp.h). + // Conservatif par construction : on sur-approxime la boîte de chaque capsule, donc on peut + // dire CarveOnly à tort (coût CPU) mais jamais Identity à tort (ce serait un trou). + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + const float Reach = CorridorRadius + ExtraReach; + const FVector Min = VoxelBox.Min - FVector(Reach); + const FVector Max = VoxelBox.Max + FVector(Reach); + + // Nœuds dont une arête peut atteindre la boîte élargie. Les arêtes partent du nœud + // INFÉRIEUR vers +1, d'où le -1 sur la borne basse. + const int32 LoX = FMath::FloorToInt(Min.X / CellSize) - 1; + const int32 LoY = FMath::FloorToInt(Min.Y / CellSize) - 1; + const int32 LoZ = FMath::FloorToInt(Min.Z / CellSize) - 1; + const int32 HiX = FMath::FloorToInt(Max.X / CellSize); + const int32 HiY = FMath::FloorToInt(Max.Y / CellSize); + const int32 HiZ = FMath::FloorToInt(Max.Z / CellSize); + + // Garde-fou : une boîte énorme face à une petite CellSize ferait exploser la boucle. + // Au-delà, on renonce à prouver quoi que ce soit — CarveOnly est toujours SÛR. + constexpr int64 MaxNodesScanned = 32 * 32 * 32; + const int64 NodeCount = (int64)(HiX - LoX + 1) * (HiY - LoY + 1) * (HiZ - LoZ + 1); + if (NodeCount <= 0 || NodeCount > MaxNodesScanned) { return EVoxelOpEffect::CarveOnly; } + + for (int32 nz = LoZ; nz <= HiZ; ++nz) + for (int32 ny = LoY; ny <= HiY; ++ny) + for (int32 nx = LoX; nx <= HiX; ++nx) + { + const FVector A = NodeCenter(nx, ny, nz); + if (EdgeOpen(nx, ny, nz, 0xA1u, BranchProbability) && SegmentHitsBox(A, NodeCenter(nx + 1, ny, nz), Min, Max)) return EVoxelOpEffect::CarveOnly; + if (EdgeOpen(nx, ny, nz, 0xB2u, BranchProbability) && SegmentHitsBox(A, NodeCenter(nx, ny + 1, nz), Min, Max)) return EVoxelOpEffect::CarveOnly; + if (EdgeOpen(nx, ny, nz, 0xC3u, Verticality) && SegmentHitsBox(A, NodeCenter(nx, ny, nz + 1), Min, Max)) return EVoxelOpEffect::CarveOnly; + } + + // Aucun couloir n'atteint cette boîte ⇒ la pile ne peut rien y creuser. + // C'est le premier saut de tuile que Maze ait jamais eu. + return EVoxelOpEffect::Identity; + } + + private: + struct FEdge { FVector A, B; }; + + FVector NodeCenter(int32 X, int32 Y, int32 Z) const + { + return FVector((X + 0.5f) * CellSize, (Y + 0.5f) * CellSize, (Z + 0.5f) * CellSize); + } + + bool EdgeOpen(int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) const + { + uint32 H = VoxelHash::Cell(X, Y, Salt ^ AxisSalt); + H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); + return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; + } + + // Sur-approximation volontaire : boîte englobante du segment contre la boîte élargie. + // Un test capsule/AABB exact serait plus serré ; il coûterait plus cher pour un gain nul + // ici, car la réponse ne sert qu'à un rejet grossier par tuile. + static bool SegmentHitsBox(const FVector& A, const FVector& B, const FVector& Min, const FVector& Max) + { + return FMath::Min(A.X, B.X) <= Max.X && FMath::Max(A.X, B.X) >= Min.X + && FMath::Min(A.Y, B.Y) <= Max.Y && FMath::Max(A.Y, B.Y) >= Min.Y + && FMath::Min(A.Z, B.Z) <= Max.Z && FMath::Max(A.Z, B.Z) >= Min.Z; + } + + /** + * Le cache par CELLULE, repris tel quel de GetMazeDensity. Il est `thread_local` et non + * membre parce que la pile est PARTAGÉE entre workers en lecture — un membre mutable serait + * une course. C'est aussi exactement ce que fait le code d'aujourd'hui. + * + * ⚠️ PHASE 3 : quand les opérateurs deviendront des assets partagés, il faudra un objet + * d'état PAR WORKER plutôt que ce `thread_local` (qui est global à la fonction, donc partagé + * entre DEUX piles Maze différentes sur le même thread — la clé le rattrape, mais au prix + * d'un rebuild à chaque alternance). + */ + const TArray>& GetCellEdges(const FIntVector& Cell) const + { + thread_local TArray> MZ_Edges; + thread_local FIntVector MZ_Cell(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local uint32 MZ_Seed = 0xFFFFFFFFu; + thread_local float MZ_CS = -1.0f, MZ_Branch = -1.0f, MZ_Vert = -1.0f; + + if (Cell != MZ_Cell || Salt != MZ_Seed || CellSize != MZ_CS || + BranchProbability != MZ_Branch || Verticality != MZ_Vert) + { + MZ_Cell = Cell; MZ_Seed = Salt; MZ_CS = CellSize; + MZ_Branch = BranchProbability; MZ_Vert = Verticality; + MZ_Edges.Reset(); + + // Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell. + for (int32 dz = -1; dz <= 0; dz++) + for (int32 dy = -1; dy <= 0; dy++) + for (int32 dx = -1; dx <= 0; dx++) + { + const int32 nx = Cell.X + dx, ny = Cell.Y + dy, nz = Cell.Z + dz; + const FVector A = NodeCenter(nx, ny, nz); + + if (EdgeOpen(nx, ny, nz, 0xA1u, BranchProbability)) + MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) }); + if (EdgeOpen(nx, ny, nz, 0xB2u, BranchProbability)) + MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) }); + if (EdgeOpen(nx, ny, nz, 0xC3u, Verticality)) + MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) }); + } + } + return MZ_Edges; + } + + float CellSize, CorridorRadius, BranchProbability, Verticality; + uint32 Salt; + float ExtraReach; + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE SDF + //========================================================================= + // La variante SDF (Maze / VerticalShafts / FloatingIslands) : `Sdf += bruit · échelle · force`. + // Déplace la SURFACE. La variante densité de TunnelNetwork est un opérateur DIFFÉRENT (fade + // quadratique, clamp anti-remplissage, 4 types de bruit) — voir OPSTACK-DECOMPOSITION §1. + class FSdfRoughnessMod final : public IVoxelDensityOp + { + public: + FSdfRoughnessMod(float InStrength, float InFrequency, int32 InBaseOctaves, float InApplyWithin) + : Strength(InStrength), Frequency(InFrequency) + , BaseOctaves(InBaseOctaves), ApplyWithin(InApplyWithin) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (Strength <= 0.0f || InOut.Sdf >= ApplyWithin) { return; } + // VoxelNoise::FBM est exactement ce que FractalNoise3D appelle (VoxelGenerator.cpp) — + // le wrapper ne fait que transtyper. T2.b : les octaves passent par Eff() pour que les + // tuiles lointaines perdent les octaves sous-cellule. + InOut.Sdf += VoxelNoise::FBM(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency, + VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f) + * VOXEL_NOISE_SCALE * Strength; + } + + // Ne touche pas la densité par lui-même ; la source amont a déjà compté son amplitude dans + // sa portée (`ExtraReach`). Cf. SIMPLIFICATION DE PHASE 1 dans VoxelDensityOp.h. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; + } + + private: + float Strength, Frequency; + int32 BaseOctaves; + float ApplyWithin; + }; + + //========================================================================= + // RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE) + //========================================================================= + // Les six mêmes lignes dans TunnelNetwork, Maze et VerticalShafts. Une fois ici, plus jamais. + class FSdfCarveOp final : public IVoxelDensityOp + { + public: + FSdfCarveOp(float InBlend, float InBaseDensity) : Blend(InBlend), BaseDensity(InBaseDensity) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float, float, float, FVoxelOpSample& InOut) const override + { + if (InOut.Sdf >= Blend) { return; } + float Carve = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + InOut.Density -= Carve * BaseDensity * 2.0f; // interne : baisser = vers l'air + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; // la source a répondu pour la paire + } + + private: + float Blend, BaseDensity; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : SPINE (0,0) + //========================================================================= + class FOriginSpineOp final : public IVoxelDensityOp + { + public: + FOriginSpineOp(float InTopZ, float InBotZ, float InSeal, float InBase, float InRadius) + : TopZ(InTopZ), BotZ(InBotZ), Seal(InSeal), Base(InBase), Radius(InRadius) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override + { + VF_ApplyOriginSpine(InOut.Density, X, Y, Z, TopZ, BotZ, Seal, Base, Radius); + } + + // Ne fait QUE de l'air ⇒ tue AllSolid, jamais AllAir. Identity quand le cercle XY rate la + // boîte, ou quand la boîte est entièrement hors de l'intérieur de la strate. + // ≡ le test cercle/boîte écrit à la main dans ClassifyTile aujourd'hui. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (Radius <= 0.0f) { return EVoxelOpEffect::Identity; } + + const float InnerTop = TopZ - Seal; + const float InnerBot = BotZ + Seal; + if (VoxelBox.Max.Z <= InnerBot || VoxelBox.Min.Z >= InnerTop) { return EVoxelOpEffect::Identity; } + + const float Reach = Radius + VoxelDensityReach::SpineBlend; + const float CX = FMath::Clamp(0.0f, (float)VoxelBox.Min.X, (float)VoxelBox.Max.X); + const float CY = FMath::Clamp(0.0f, (float)VoxelBox.Min.Y, (float)VoxelBox.Max.Y); + if (CX * CX + CY * CY > Reach * Reach) { return EVoxelOpEffect::Identity; } + + return EVoxelOpEffect::CarveOnly; + } + + private: + float TopZ, BotZ, Seal, Base, Radius; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : SEAL DE FRONTIÈRE (l'opérateur FORÇANT) + //========================================================================= + class FBoundarySealOp final : public IVoxelDensityOp + { + public: + FBoundarySealOp(float InTopZ, float InBotZ, float InThickness, float InBase) + : TopZ(InTopZ), BotZ(InBotZ), Thickness(InThickness), Base(InBase) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float, float, float Z, FVoxelOpSample& InOut) const override + { + VF_ApplyBoundarySeal(InOut.Density, Z, TopZ, BotZ, Thickness, Base); + } + + /** + * Dans sa bande, le seal fait `Max(D, SealFactor·Base)` avec SealFactor > 0 : le résultat + * est solide GARANTI quelle qu'ait été l'entrée. C'est un opérateur FORÇANT, et la raison + * d'être de `ClassifyBox` (voir VoxelDensityOp.h). + * + * ⚠️ MARGE DE SÛRETÉ DÉLIBÉRÉE. Au bord INTÉRIEUR de la bande, `1 - Dist/Thickness` peut + * arrondir à exactement 0.0f en float ; SealFactor·Base vaut alors 0, la densité interne + * finit à 0, et le mesher (`D >= IsoLevel`) compte ce point du côté AIR. Prétendre AllSolid + * là serait un TROU. On exige donc que la boîte soit dans la bande avec 1 voxel de marge + * avant de forcer ; sinon on retombe sur le FillOnly, qui est toujours sûr. + * + * (Le `ClassifyTile` actuel n'a pas cette marge — il exclut simplement ces z du test de + * colonne. La fenêtre est infime et demande que l'archétype produise de l'air pile à ce z, + * mais elle est réelle ; notée plutôt que corrigée en douce, puisque le chemin d'aujourd'hui + * n'est pas touché par cette Phase 1.) + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (Thickness <= 0.0f || Base <= 0.0f) { return EVoxelTileClass::Mixed; } + + constexpr float SafetyMargin = 1.0f; + const float Usable = Thickness - SafetyMargin; + if (Usable <= 0.0f) { return EVoxelTileClass::Mixed; } + + const float MinDistTop = TopZ - (float)VoxelBox.Min.Z; // plus petite distance au plafond + const float MaxDistTop = TopZ - (float)VoxelBox.Max.Z; + const bool bWhollyInTopBand = (MaxDistTop >= 0.0f) && (MinDistTop < Usable); + + const float MinDistBot = (float)VoxelBox.Min.Z - BotZ; + const float MaxDistBot = (float)VoxelBox.Max.Z - BotZ; + const bool bWhollyInBotBand = (MinDistBot >= 0.0f) && (MaxDistBot < Usable); + + return (bWhollyInTopBand || bWhollyInBotBand) ? EVoxelTileClass::AllSolid + : EVoxelTileClass::Mixed; + } + + // Hors de sa bande, le seal ne fait rien du tout ; à cheval, il ne peut qu'ajouter du solide. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (Thickness <= 0.0f) { return EVoxelOpEffect::Identity; } + const bool bTouchesTopBand = ((float)VoxelBox.Max.Z >= TopZ - Thickness) && ((float)VoxelBox.Min.Z <= TopZ); + const bool bTouchesBotBand = ((float)VoxelBox.Min.Z <= BotZ + Thickness) && ((float)VoxelBox.Max.Z >= BotZ); + if (!bTouchesTopBand && !bTouchesBotBand) { return EVoxelOpEffect::Identity; } + return EVoxelOpEffect::FillOnly; + } + + private: + float TopZ, BotZ, Thickness, Base; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : CARVE DE PASSAGE + //========================================================================= + class FPassageCarveOp final : public IVoxelDensityOp + { + public: + FPassageCarveOp(const UVoxelStrateManager* InManager, float InBase, float InSeal) + : Manager(InManager), Base(InBase), Seal(InSeal) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override + { + if (!Manager) { return; } + const float ModSDF = Manager->EvaluateModifierSDF(X, Y, Z); + VF_ApplyPassageCarving(InOut.Density, ModSDF, Base, Seal); + } + + // ≡ la garde `AnyPassageNearBox` écrite à la main dans ClassifyTile — déjà écrite, ici + // simplement branchée au bon endroit au lieu d'être un cas particulier du classifieur. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (!Manager) { return EVoxelOpEffect::Identity; } + return Manager->AnyPassageNearBox(VoxelBox.Min, VoxelBox.Max) + ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; + } + + private: + const UVoxelStrateManager* Manager; + float Base, Seal; + }; +} + +//============================================================================= +// FVoxelOpStack +//============================================================================= + +void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, + float SealThickness, float BaseDensity, float SpineRadius, + const UVoxelStrateManager* StrateManager) +{ + // ORDRE NON NÉGOCIABLE, et c'est l'ordre que les six fonctions de densité utilisent déjà : + // la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal), le seal re-solidifie + // ses bandes, les passages percent tout — seal compris —, le joueur gagne en dernier. + Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius)); + Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity)); + Add(MakeUnique(StrateManager, BaseDensity, SealThickness)); +} + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelDensityOps +{ + TUniquePtr MakeConstantRockSource(float BaseDensity) + { + return MakeUnique(BaseDensity); + } + + TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach) + { + return MakeUnique(P, Seed, ExtraReach); + } + + TUniquePtr MakeSdfRoughnessMod(float Strength, float Frequency, + int32 BaseOctaves, float ApplyWithin) + { + return MakeUnique(Strength, Frequency, BaseOctaves, ApplyWithin); + } + + TUniquePtr MakeSdfCarve(float Blend, float BaseDensity) + { + return MakeUnique(Blend, BaseDensity); + } + + void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + { + // Les constantes viennent telles quelles de GetMazeDensity — elles y étaient codées en dur. + constexpr float CarveBlend = 2.0f; + constexpr float RoughFrequency = 0.12f; + constexpr int32 RoughOctaves = 3; + + const float R = FMath::Max(P.CorridorRadius, 0.5f); + + // Fenêtre d'application de la rugosité : `MazeSDF < R + SurfaceRoughness + 2.0f` dans + // l'original. Reproduite à l'identique pour que l'égalité binaire tienne. + const float RoughApplyWithin = R + P.SurfaceRoughness + 2.0f; + + // Portée que la source doit déclarer pour la paire source+carve : le rayon du couloir peut + // être élargi par la rugosité (FBM ∈ [-1,1] ⇒ ±Strength·VOXEL_NOISE_SCALE) puis par le blend + // du carve. Sur-estimer coûte du CPU ; sous-estimer serait un trou. + const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE + CarveBlend + 1.0f; + + OutStack.Add(MakeConstantRockSource(P.BaseDensity)); + OutStack.Add(MakeLatticeCorridorSource(P, Seed, ExtraReach)); + OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, RoughFrequency, RoughOctaves, RoughApplyWithin)); + OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); + + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); + } +} diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index e2680f9..91243ce 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -13,6 +13,7 @@ #include "VoxelDiffLayer.h" #include "VoxelBiomeDefinition.h" #include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core +#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack //============================================================================= // SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy @@ -230,80 +231,34 @@ static float CellularNoise3D(const FVector& Position) // DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab) //============================================================================= -// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur -// `Thickness` voxels depuis chaque bord. N'AJOUTE que de la densité -// (FMath::Max), jamais en enlève → le joueur ne peut jamais percer le seal -// "par accident", seulement via les passages. -static void ApplyBoundarySeal(float& Density, float WorldZ, - float StrateTopZ, float StrateBottomZ, - float Thickness, float BaseDensity) +// Les CORPS de ces trois helpers ont déménagé dans Public/VoxelDensityPrimitives.h : la pile +// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies de trois +// INVARIANTS de monde (descente possible, seals qui tiennent, passages qui percent) finiraient par +// diverger. Ces trois lignes gardent les noms locaux pour que les ~20 sites d'appel ci-dessous ne +// bougent pas d'un caractère — le déplacement ne change AUCUN comportement. +// +// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same three, and +// two copies of three world invariants would eventually drift. These forwarders keep the local names +// so not one of the ~20 call sites below changes. No behavioural change. +// +// Convention INTERNE ici : positif = SOLIDE. La négation vers MC se fait sur le `return`. +static FORCEINLINE void ApplyBoundarySeal(float& Density, float WorldZ, + float StrateTopZ, float StrateBottomZ, float Thickness, float BaseDensity) { - if (Thickness <= 0.0f) return; - - const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond - const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol - - if (DistTop >= 0.0f && DistTop < Thickness) - { - float SealFactor = 1.0f - (DistTop / Thickness); - SealFactor = SmoothStep01(SealFactor); - Density = FMath::Max(Density, SealFactor * BaseDensity); - } - if (DistBot >= 0.0f && DistBot < Thickness) - { - float SealFactor = 1.0f - (DistBot / Thickness); - SealFactor = SmoothStep01(SealFactor); - Density = FMath::Max(Density, SealFactor * BaseDensity); - } + VF_ApplyBoundarySeal(Density, WorldZ, StrateTopZ, StrateBottomZ, Thickness, BaseDensity); } -// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages -// puissent percer à travers le bouchon solide. -// Le rayon de blend hard-codé à 4.0f correspond à l'ancienne valeur — -// à exposer via UVoxelSettings si on veut pouvoir le tweaker. -static void ApplyPassageCarving(float& Density, float ModSDF, +static FORCEINLINE void ApplyPassageCarving(float& Density, float ModSDF, float BaseDensity, float SealThickness) { - constexpr float PASSAGE_BLEND_RADIUS = 4.0f; - if (ModSDF >= PASSAGE_BLEND_RADIUS) return; - - float CarveFactor = FMath::Clamp( - (PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f), - 0.0f, 1.0f); - CarveFactor = SmoothStep01(CarveFactor); - - // FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in - // its path (seals, columns, surface roughness, terrain ops). A plain subtraction can - // be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is - // why the shaft "didn't go all the way through". Lerp toward a strongly negative target - // and take the min so we only ever make it MORE air (never refill an existing cave). - const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); - Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor)); + VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness); } -// (0,0) DESCENT SPINE — carve a guaranteed open vertical column at world XY (0,0) -// inside the strate INTERIOR (between the top and bottom seals). The seals are left -// intact so the player still has to dig through them to descend — this just makes a -// clean, archetype-independent landing space aligned across every strate. -static void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, +static FORCEINLINE void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) { - if (Radius <= 0.0f) return; - - // Stay within the interior — never touch the seal bands. - const float InnerTop = StrateTopZ - SealThickness; - const float InnerBot = StrateBottomZ + SealThickness; - if (WorldZ <= InnerBot || WorldZ >= InnerTop) return; - - const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY); - const float SDF = DistXY - Radius; // < 0 inside the column - const float Blend = 3.0f; - if (SDF < Blend) - { - float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f); - Carve = SmoothStep01(Carve); - Density -= Carve * (BaseDensity * 2.0f + SealThickness); - } + VF_ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); } // DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index e90e633..176a998 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -73,11 +73,14 @@ // ───────────────────────────────────────────────────────────────────────────────────────── // ÉTAT / STATUS // ───────────────────────────────────────────────────────────────────────────────────────── -// Phase 1, header seul. Aucun opérateur n'existe encore, GetDensityAt n'a pas changé, le `switch` -// est intact. Prochaine étape : porter Maze en le DÉCOMPOSANT (source réseau de couloirs + modifier -// de rugosité), pas en l'emballant. Voir OPSTACK-DECOMPOSITION.md pour le plan par archétype. +// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais +// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui +// alimente le jeu. La pile est validée par un test qui la compare à GetMazeDensity point par point. +// Le branchement dans GetDensityAt attend un build vert. // -// Phase 1, header only. No operator exists yet, GetDensityAt is unchanged, the switch is intact. +// Phase 1. Maze IS ported (VoxelDensityOpStack.{h,cpp}) but **GetDensityAt and ClassifyTile are NOT +// touched** — the switch is still the only path feeding the game. The stack is validated by a test +// that compares it to GetMazeDensity point by point. Wiring it in waits for a green build. // // NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h). // Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et @@ -120,16 +123,30 @@ enum class EVoxelOpRole : uint8 // Vocabulaire délibérément petit, et il réutilise ce qui existe déjà // (VoxelSDF::SmoothMin / SmoothMax). // -// RAPPEL DE SIGNE — la source n°1 de confusion du plugin : -// au mesher, NÉGATIF = SOLIDE, POSITIF = AIR (IsoLevel 0). -// Donc « ajouter du solide » = prendre le MIN, « creuser de l'air » = prendre le MAX. -// SIGN REMINDER: at the mesher NEGATIVE = SOLID, POSITIVE = AIR. So "add solid" is min(), -// "carve air" is max(). Getting this backwards inverts the world. +// ⚠️⚠️ RAPPEL DE SIGNE — LA source n°1 de confusion du plugin, et il y a DEUX conventions en jeu. +// Lire ceci en entier avant d'écrire un opérateur. +// +// • CANAL DENSITÉ, à l'intérieur de la pile : convention INTERNE, **POSITIF = SOLIDE**. +// C'est celle dans laquelle CHAQUE fonction d'archétype est écrite aujourd'hui. La négation +// vers la convention marching-cubes (négatif = solide) se fait UNE FOIS, tout à la fin, par +// l'appelant. Donc ici : « ajouter du solide » = MAX, « creuser de l'air » = MIN. +// +// • CANAL SDF : convention SDF standard, **NÉGATIF = À L'INTÉRIEUR de la primitive**. +// Réunir deux formes = MIN (c'est `SmoothMin`, ce que fait déjà le code pour salle+puits). +// Le sens de « min » est donc l'INVERSE d'un canal à l'autre. Ce n'est pas une incohérence : +// un SDF décrit une FORME, une densité décrit de la MATIÈRE. +// +// DENSITY channel inside the stack: INTERNAL convention, **POSITIVE = SOLID** (what every +// archetype body already uses; the MC negate happens once, at the end, in the caller). So +// "add solid" is max(), "carve air" is min(). +// SDF channel: standard SDF, **NEGATIVE = INSIDE the primitive**; unioning shapes is min(). +// The meaning of min() is therefore opposite between the two channels — an SDF describes a +// SHAPE, a density describes MATTER. enum class EVoxelOpCombine : uint8 { Replace, // ignore l'entrée — racine de pile (heightfield, densité de base) - Union, // min() — ajoute du solide : ponts, îles, colonnes - Subtract, // max() — creuse de l'air : salles, tunnels, passages, spine + Union, // max() sur la DENSITÉ — ajoute du solide : ponts, îles, colonnes + Subtract, // min() sur la DENSITÉ — creuse de l'air : salles, tunnels, passages, spine SmoothUnion, // VoxelSDF::SmoothMin(k) — jonctions organiques SmoothSubtract, // VoxelSDF::SmoothMax(k) Add, // accumulation scalaire — termes de bruit / rugosité @@ -201,6 +218,41 @@ struct FVoxelOpContext const FBiomeContext* Biome = nullptr; }; +//============================================================================= +// L'ÉTAT QUI TRAVERSE LA PILE / THE STATE THE STACK THREADS THROUGH +//============================================================================= +// DEUX canaux, pas un. Ce n'est pas de la généralité gratuite — c'est ce que le code fait déjà : +// +// CaveSDF = EvaluateSDFCached(salles + tunnels) ← espace SDF +// CaveSDF = SmoothMin(CaveSDF, PitSDF, BlendK) ← espace SDF +// CaveSDF = SmoothMin(CaveSDF, ChimneySDF, BlendK) ← espace SDF +// → UN SEUL carve à la fin : Density -= CarveFactor · BaseDensity · 2 +// +// Maze, VerticalShafts et FloatingIslands ont la même forme, et TROIS d'entre eux appliquent la +// rugosité au **SDF** (`MazeSDF += bruit·Rough`), pas à la densité. Sur la densité, le même bruit +// est mis à l'échelle par le gradient local : effet visiblement différent. +// +// Avec un seul canal, un opérateur ne peut qu'ÉCRASER le précédent — les jonctions SmoothMin +// (salle↔puits, et demain « un graphe de salles creusé DANS une montagne ») sont impossibles. +// Un `SmoothMin` entre deux SOURCES différentes est précisément ce qui fait qu'une idée composée +// a l'air d'appartenir au lieu au lieu d'y avoir été percée. Coût : un float. +// +// Two channels, not one — because that is what the code already does, and because SmoothMin between +// two different SOURCES is precisely what makes a composed idea look like it belongs there rather +// than like a hole punched in something else. Cost: one float. +struct FVoxelOpSample +{ + // Convention INTERNE : POSITIF = SOLIDE. Négation vers MC une seule fois, par l'appelant. + // INTERNAL convention: POSITIVE = SOLID. Negated to MC once, by the caller. + float Density = 0.0f; + + // Convention SDF standard : NÉGATIF = à l'intérieur de la primitive. + // FLT_MAX = « aucune surface à proximité » (l'état initial, et le early-out des sources + // placées quand aucune primitive n'atteint ce voxel). + // FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use. + float Sdf = FLT_MAX; +}; + //============================================================================= // L'INTERFACE / THE INTERFACE //============================================================================= @@ -228,8 +280,8 @@ public: virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0; /** - * Par voxel. InDensity = ce que la pile a produit jusqu'ici, convention MC - * (négatif = solide, positif = air). Coordonnées en VOXELS, pas en cm. + * Par voxel. `InOut` est l'état que la pile a produit jusqu'ici (voir FVoxelOpSample). + * Coordonnées en VOXELS, pas en cm. * * INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (coords monde, seed, layout). * Le même point évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le @@ -237,7 +289,7 @@ public: * visible, et en multijoueur une divergence de monde. Le test * VoxelForge.Determinism.DensityPurity vérifie cela. */ - virtual float Eval(float WorldX, float WorldY, float WorldZ, float InDensity) const = 0; + virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0; /** * CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique. @@ -249,6 +301,18 @@ public: * fonctions, mêmes floats, donc verdict exact plutôt qu'estimé. Cela DOIT survivre au portage. * * The contract is "conservative", not "closed-form": an op MAY sample to answer. + * + * ⚠️ SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche + * pas la densité par elle-même — c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le + * fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête + * de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond + * pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte, + * `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par + * une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3). + * + * PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the + * conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box + * query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile. */ virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0; diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h new file mode 100644 index 0000000..3f3deec --- /dev/null +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -0,0 +1,159 @@ +// VoxelDensityOpStack.h +// La PILE : un conteneur ordonné d'opérateurs, plus les fabriques d'opérateurs concrets. +// The STACK: an ordered container of operators, plus the concrete-operator factories. +// +// ⚠️ RIEN ICI N'ALIMENTE LE JEU. `UVoxelGenerator::GetDensityAt` et `ClassifyTile` ne sont pas +// touchés ; le `switch` par archétype reste le seul chemin de production. Cette pile est construite +// et exercée UNIQUEMENT par le test `VoxelForge.OpStack.MazeEquivalence`, qui la compare point par +// point à `GetMazeDensity`. Le branchement attend un build vert (OPSTACK-PLAN §4, Phase 1, point 3). +// +// NOTHING HERE FEEDS THE GAME. GetDensityAt and ClassifyTile are untouched; the archetype switch is +// still the only production path. This stack is built and exercised only by the equivalence test. +// +// POURQUOI CETTE FORME / WHY THIS SHAPE +// La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais +// **« est-ce que la séparation source / modifier tombe naturellement du code existant ? »** +// (OPSTACK-PLAN §4, le déclencheur d'arrêt). En portant Maze hors du chemin chaud et en le +// comparant à l'original, cette question reçoit une réponse MESURÉE plutôt qu'une opinion. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelDensityOp.h" +#include "VoxelStrateTypes.h" // FMazeGenerationParams + +class UVoxelStrateManager; + +/** + * FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte. + * + * PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et + * l'ordre spine → seal → passage est garanti par cette fonction, pas par l'auteur. Un auteur ne + * peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester + * possible, les seals doivent tenir, les passages doivent percer). + * + * PROPRIÉTÉ (threading) : la pile est LUE par les workers. Les opérateurs concrets qui ont besoin + * d'un cache par cellule/chunk le tiennent en `thread_local` à l'intérieur de leur `Eval`, comme le + * fait déjà chaque fonction d'archétype. En Phase 3, quand les opérateurs deviendront des assets + * partagés, il faudra un objet d'état PAR WORKER — noté ici pour que ça ne surprenne personne. + * + * THREADING: the stack is READ by workers. Concrete ops that need a per-cell/per-chunk cache keep it + * thread_local inside Eval, exactly as every archetype function already does. Phase 3 (ops as shared + * assets) will need a per-worker state object — flagged here so it is not a surprise. + */ +class VOXELFORGE_API FVoxelOpStack +{ +public: + void Add(TUniquePtr Op) { Ops.Add(MoveTemp(Op)); } + + int32 Num() const { return Ops.Num(); } + + /** Hoist chunk-constant work for every op. Une fois par chunk et par worker. + * Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un + * mensonge utile qui finirait par masquer une course. */ + void PrepareChunk(const FVoxelOpContext& Ctx) + { + for (const TUniquePtr& Op : Ops) { Op->PrepareChunk(Ctx); } + } + + /** + * Évalue la pile complète en un point. Rend la densité en convention INTERNE + * (positif = solide) — l'appelant négate UNE FOIS pour le marching cubes. + * + * Returns INTERNAL-convention density (positive = solid). The caller negates once for MC. + */ + float EvalInternal(float WorldX, float WorldY, float WorldZ) const + { + FVoxelOpSample S; + for (const TUniquePtr& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); } + return S.Density; + } + + /** Le même, négaté pour le mesher (négatif = solide). */ + float EvalMC(float WorldX, float WorldY, float WorldZ) const + { + return -EvalInternal(WorldX, WorldY, WorldZ); + } + + /** + * Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile. + * Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un + * opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui. + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + FVoxelBoxHypotheses H; + for (const TUniquePtr& Op : Ops) + { + VF_FoldOp(H, *Op, VoxelBox, Ctx); + if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver + } + return H.Resolve(); + } + + /** + * RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile. + * spine (0,0) → seal de frontière → carve de passage. + * + * ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la + * négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront + * portées et que la question de convention MC-vs-interne sera tranchée pour de bon + * (OPSTACK-DECOMPOSITION §10.2). Tant que la pile n'alimente pas le jeu, c'est sans effet. + * + * The diff layer is NOT here: it lives in GetDensityAt, AFTER the MC negate, with disturbances. + * It joins the stack when disturbances are ported. Harmless while the stack feeds nothing. + * + * @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel). + */ + void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, + float SealThickness, float BaseDensity, float SpineRadius, + const UVoxelStrateManager* StrateManager); + +private: + TArray> Ops; +}; + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelDensityOps +{ + /** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit. + * Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */ + VOXELFORGE_API TUniquePtr MakeConstantRockSource(float BaseDensity); + + /** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D. + * Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux + * chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région + * COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */ + * `ExtraReach` = tout ce qui peut ÉLARGIR la portée du couloir en aval (amplitude de rugosité + + * rayon de blend du carve). La source répond pour la paire source+conversion dans + * `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle + * doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un + * trou. / The source answers for the source+conversion pair, so it must know the downstream + * margin: an Identity that is wrong is a hole. */ + VOXELFORGE_API TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, + int32 Seed, float ExtraReach); + + /** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands). + * `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est + * un gain d'authoring gratuit, et §2.6 autorise explicitement le re-tune. */ + VOXELFORGE_API TUniquePtr MakeSdfRoughnessMod(float Strength, float Frequency, + int32 BaseOctaves, float ApplyWithin); + + /** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur. + * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */ + VOXELFORGE_API TUniquePtr MakeSdfCarve(float Blend, float BaseDensity); + + /** + * La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique : + * ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post] + * + * C'est le test de la Phase 1 : si Maze ne se décompose pas ainsi, l'abstraction est mauvaise + * pour ce domaine (OPSTACK-PLAN §4, déclencheur d'arrêt). + */ + VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager); +} diff --git a/Source/VoxelForge/Public/VoxelDensityPrimitives.h b/Source/VoxelForge/Public/VoxelDensityPrimitives.h new file mode 100644 index 0000000..e281185 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelDensityPrimitives.h @@ -0,0 +1,130 @@ +// VoxelDensityPrimitives.h +// Les trois post-traitements STRUCTURELS partagés par chaque archétype. +// The three STRUCTURAL post-processes every archetype shares. +// +// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS +// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six +// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie, +// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent — +// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les +// passages doivent percer), pas des choix créatifs. +// +// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The +// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would +// guarantee divergence, and these are world INVARIANTS, not creative choices. +// +// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin. +// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**. +// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la +// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`. +// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC +// convention happens ONCE, at the caller's return. +// +// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de +// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" // SmoothStep01 + +//============================================================================= +// SEAL DE FRONTIÈRE / BOUNDARY SEAL +//============================================================================= +// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur `Thickness` voxels depuis +// chaque bord. N'AJOUTE que de la densité (FMath::Max), jamais n'en enlève → le joueur ne peut +// jamais percer le seal "par accident", seulement via les passages. +// +// ⚠️ C'est un opérateur FORÇANT, pas seulement un FillOnly : à l'intérieur de la bande, avec +// SealFactor > 0 et BaseDensity > 0, le résultat est solide GARANTI quelle qu'ait été l'entrée. +// C'est exactement ce qu'encode `IVoxelDensityOp::ClassifyBox` (voir VoxelDensityOp.h), et la +// raison pour laquelle cette méthode existe. +FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ, + float StrateTopZ, float StrateBottomZ, + float Thickness, float BaseDensity) +{ + if (Thickness <= 0.0f) return; + + const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond + const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol + + if (DistTop >= 0.0f && DistTop < Thickness) + { + float SealFactor = 1.0f - (DistTop / Thickness); + SealFactor = SmoothStep01(SealFactor); + Density = FMath::Max(Density, SealFactor * BaseDensity); + } + if (DistBot >= 0.0f && DistBot < Thickness) + { + float SealFactor = 1.0f - (DistBot / Thickness); + SealFactor = SmoothStep01(SealFactor); + Density = FMath::Max(Density, SealFactor * BaseDensity); + } +} + +//============================================================================= +// CARVE DE PASSAGE / PASSAGE CARVING +//============================================================================= +// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages puissent percer à +// travers le bouchon solide. Le rayon de blend hard-codé à 4.0f correspond à l'ancienne valeur — +// à exposer via UVoxelSettings si on veut pouvoir le tweaker. +FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF, + float BaseDensity, float SealThickness) +{ + constexpr float PASSAGE_BLEND_RADIUS = 4.0f; + if (ModSDF >= PASSAGE_BLEND_RADIUS) return; + + float CarveFactor = FMath::Clamp( + (PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + + // FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in + // its path (seals, columns, surface roughness, terrain ops). A plain subtraction can + // be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is + // why the shaft "didn't go all the way through". Lerp toward a strongly negative target + // and take the min so we only ever make it MORE air (never refill an existing cave). + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor)); +} + +//============================================================================= +// SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE +//============================================================================= +// Creuse une colonne verticale garantie ouverte au XY monde (0,0) dans l'INTÉRIEUR de la strate +// (entre les seals haut et bas). Les seals sont laissés intacts pour que le joueur doive encore +// creuser à travers pour descendre — ceci ne fait qu'un espace d'atterrissage propre, indépendant +// de l'archétype, aligné à travers toutes les strates. +FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (Radius <= 0.0f) return; + + // Stay within the interior — never touch the seal bands. + const float InnerTop = StrateTopZ - SealThickness; + const float InnerBot = StrateBottomZ + SealThickness; + if (WorldZ <= InnerBot || WorldZ >= InnerTop) return; + + const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY); + const float SDF = DistXY - Radius; // < 0 inside the column + const float Blend = 3.0f; + if (SDF < Blend) + { + float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + Density -= Carve * (BaseDensity * 2.0f + SealThickness); + } +} + +//============================================================================= +// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin +//============================================================================= +// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la +// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas +// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today. +// Naming them here so a future bounds test cannot let the two drift apart. +namespace VoxelDensityReach +{ + constexpr float SpineBlend = 3.0f; + constexpr float PassageBlend = 4.0f; +}