feat: Phase 1 — Maze ported to the operator stack, OFF the hot path
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <atomic>
|
||||||
|
|
||||||
|
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<FVector> 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<int32> Order;
|
||||||
|
BuildShuffledOrder(NumMazeSamples, 8675309, Order);
|
||||||
|
std::atomic<int32> Impure{ 0 };
|
||||||
|
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||||
|
|
||||||
|
TArray<float> 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<int32> 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
|
||||||
@@ -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<FEdge, TInlineAllocator<24>>& 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<FEdge, TInlineAllocator<24>>& GetCellEdges(const FIntVector& Cell) const
|
||||||
|
{
|
||||||
|
thread_local TArray<FEdge, TInlineAllocator<24>> 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<FOriginSpineOp>(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius));
|
||||||
|
Add(MakeUnique<FBoundarySealOp>(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity));
|
||||||
|
Add(MakeUnique<FPassageCarveOp>(StrateManager, BaseDensity, SealThickness));
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// FABRIQUES / FACTORIES
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
namespace VoxelDensityOps
|
||||||
|
{
|
||||||
|
TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity)
|
||||||
|
{
|
||||||
|
return MakeUnique<FConstantRockSource>(BaseDensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach)
|
||||||
|
{
|
||||||
|
return MakeUnique<FLatticeCorridorSource>(P, Seed, ExtraReach);
|
||||||
|
}
|
||||||
|
|
||||||
|
TUniquePtr<IVoxelDensityOp> MakeSdfRoughnessMod(float Strength, float Frequency,
|
||||||
|
int32 BaseOctaves, float ApplyWithin)
|
||||||
|
{
|
||||||
|
return MakeUnique<FSdfRoughnessMod>(Strength, Frequency, BaseOctaves, ApplyWithin);
|
||||||
|
}
|
||||||
|
|
||||||
|
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity)
|
||||||
|
{
|
||||||
|
return MakeUnique<FSdfCarveOp>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "VoxelDiffLayer.h"
|
#include "VoxelDiffLayer.h"
|
||||||
#include "VoxelBiomeDefinition.h"
|
#include "VoxelBiomeDefinition.h"
|
||||||
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
|
#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
|
// 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)
|
// DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab)
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|
||||||
// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur
|
// Les CORPS de ces trois helpers ont déménagé dans Public/VoxelDensityPrimitives.h : la pile
|
||||||
// `Thickness` voxels depuis chaque bord. N'AJOUTE que de la densité
|
// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies de trois
|
||||||
// (FMath::Max), jamais en enlève → le joueur ne peut jamais percer le seal
|
// INVARIANTS de monde (descente possible, seals qui tiennent, passages qui percent) finiraient par
|
||||||
// "par accident", seulement via les passages.
|
// diverger. Ces trois lignes gardent les noms locaux pour que les ~20 sites d'appel ci-dessous ne
|
||||||
static void ApplyBoundarySeal(float& Density, float WorldZ,
|
// bougent pas d'un caractère — le déplacement ne change AUCUN comportement.
|
||||||
float StrateTopZ, float StrateBottomZ,
|
//
|
||||||
float Thickness, float BaseDensity)
|
// 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;
|
VF_ApplyBoundarySeal(Density, WorldZ, StrateTopZ, StrateBottomZ, Thickness, BaseDensity);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages
|
static FORCEINLINE void ApplyPassageCarving(float& Density, float ModSDF,
|
||||||
// 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,
|
|
||||||
float BaseDensity, float SealThickness)
|
float BaseDensity, float SealThickness)
|
||||||
{
|
{
|
||||||
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
|
VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// (0,0) DESCENT SPINE — carve a guaranteed open vertical column at world XY (0,0)
|
static FORCEINLINE void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
|
||||||
// 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,
|
|
||||||
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||||
{
|
{
|
||||||
if (Radius <= 0.0f) return;
|
VF_ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
|
||||||
|
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density
|
// DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density
|
||||||
|
|||||||
@@ -73,11 +73,14 @@
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// ÉTAT / STATUS
|
// ÉTAT / STATUS
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||||
// Phase 1, header seul. Aucun opérateur n'existe encore, GetDensityAt n'a pas changé, le `switch`
|
// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais
|
||||||
// est intact. Prochaine étape : porter Maze en le DÉCOMPOSANT (source réseau de couloirs + modifier
|
// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui
|
||||||
// de rugosité), pas en l'emballant. Voir OPSTACK-DECOMPOSITION.md pour le plan par archétype.
|
// 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).
|
// 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
|
// 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à
|
// Vocabulaire délibérément petit, et il réutilise ce qui existe déjà
|
||||||
// (VoxelSDF::SmoothMin / SmoothMax).
|
// (VoxelSDF::SmoothMin / SmoothMax).
|
||||||
//
|
//
|
||||||
// RAPPEL DE SIGNE — la source n°1 de confusion du plugin :
|
// ⚠️⚠️ RAPPEL DE SIGNE — LA source n°1 de confusion du plugin, et il y a DEUX conventions en jeu.
|
||||||
// au mesher, NÉGATIF = SOLIDE, POSITIF = AIR (IsoLevel 0).
|
// Lire ceci en entier avant d'écrire un opérateur.
|
||||||
// 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(),
|
// • CANAL DENSITÉ, à l'intérieur de la pile : convention INTERNE, **POSITIF = SOLIDE**.
|
||||||
// "carve air" is max(). Getting this backwards inverts the world.
|
// 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
|
enum class EVoxelOpCombine : uint8
|
||||||
{
|
{
|
||||||
Replace, // ignore l'entrée — racine de pile (heightfield, densité de base)
|
Replace, // ignore l'entrée — racine de pile (heightfield, densité de base)
|
||||||
Union, // min() — ajoute du solide : ponts, îles, colonnes
|
Union, // max() sur la DENSITÉ — ajoute du solide : ponts, îles, colonnes
|
||||||
Subtract, // max() — creuse de l'air : salles, tunnels, passages, spine
|
Subtract, // min() sur la DENSITÉ — creuse de l'air : salles, tunnels, passages, spine
|
||||||
SmoothUnion, // VoxelSDF::SmoothMin(k) — jonctions organiques
|
SmoothUnion, // VoxelSDF::SmoothMin(k) — jonctions organiques
|
||||||
SmoothSubtract, // VoxelSDF::SmoothMax(k)
|
SmoothSubtract, // VoxelSDF::SmoothMax(k)
|
||||||
Add, // accumulation scalaire — termes de bruit / rugosité
|
Add, // accumulation scalaire — termes de bruit / rugosité
|
||||||
@@ -201,6 +218,41 @@ struct FVoxelOpContext
|
|||||||
const FBiomeContext* Biome = nullptr;
|
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
|
// L'INTERFACE / THE INTERFACE
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -228,8 +280,8 @@ public:
|
|||||||
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
|
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Par voxel. InDensity = ce que la pile a produit jusqu'ici, convention MC
|
* Par voxel. `InOut` est l'état que la pile a produit jusqu'ici (voir FVoxelOpSample).
|
||||||
* (négatif = solide, positif = air). Coordonnées en VOXELS, pas en cm.
|
* Coordonnées en VOXELS, pas en cm.
|
||||||
*
|
*
|
||||||
* INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (coords monde, seed, layout).
|
* 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
|
* 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
|
* visible, et en multijoueur une divergence de monde. Le test
|
||||||
* VoxelForge.Determinism.DensityPurity vérifie cela.
|
* 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.
|
* 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.
|
* 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.
|
* 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;
|
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -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<IVoxelDensityOp> 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<IVoxelDensityOp>& 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<IVoxelDensityOp>& 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<IVoxelDensityOp>& 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<TUniquePtr<IVoxelDensityOp>> 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<IVoxelDensityOp> 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<IVoxelDensityOp> 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<IVoxelDensityOp> 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<IVoxelDensityOp> 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user