feat: Phase 2 first port — FlatPlain + CrystalChamber collapse into one op
Jahni closed OPSTACK-DECOMPOSITION 3.1: the slab noise Z term was not intentional character. Phase 1 also closed — the visual A/B on Maze passed. Two changes, deliberately together, kept attributable by the test: 1. Design: GetSlabDensity's floor and ceiling noise lose their Z terms. A floor height no longer depends on the altitude you sample it from. The ceiling keeps its + 3000.0f, which is a decorrelation offset, not a Z term. The world re-tunes once — a different slice of the noise field, not a worse one. 2. Refactor: the now-XY-pure function ports to FSlabVoidSource + FGridColumnMod plus the three structural ops. BuildSlabStack has NO branch on archetype because GetSlabDensity never had one — CrystalChamber is FlatPlain with a bigger CeilingRoughness. 8 archetypes -> 7. SlabEquivalence compares against the reference AS IT IS NOW and runs the whole battery on both slots, so green means the port is a pure refactor and any visual delta is attributable to the Z-term removal alone. The attribution comes from the test, not from splitting it across two builds. The payoff 3.1 was actually about: FSlabVoidSource::ClassifyBox is exact and needs no sampling. FBM is contractually [-1,1], so both surfaces live in Z bands with known bounds — a tile below the floor band is provably solid, a tile between the bands provably air. ClassifyTile proves zero tiles for these archetypes today. FGridColumnMod answers Identity when no column reaches the box, which is what lets the source's AllAir verdict survive the fold. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
// VoxelForgeOpStackSlabTest.cpp
|
||||
// PHASE 2, PREMIER PORTAGE — la pile Slab contre GetSlabDensity, sur LES DEUX archétypes.
|
||||
// PHASE 2'S FIRST PORT — the Slab operator stack against GetSlabDensity, on BOTH archetypes.
|
||||
//
|
||||
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
|
||||
// Trois choses, et la troisième est la raison d'être du portage :
|
||||
//
|
||||
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. Même barre que Maze : un changement de
|
||||
// côté d'isosurface est un ÉCHEC DUR, un écart d'ULP est le plancher accepté.
|
||||
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET
|
||||
// CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a
|
||||
// besoin d'en faire un, la fusion est fausse et ce test le dit.
|
||||
// 3. LE VERDICT DE BOÎTE — et c'est ici que §3.1 se paie. `ClassifyTile` prouve ZÉRO tuile pour
|
||||
// FlatPlain et CrystalChamber aujourd'hui. Depuis que les deux surfaces sont XY-PURES, leurs
|
||||
// bornes en Z sont connues exactement (contrat [-1,1] de FBM), donc toute tuile entièrement
|
||||
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ CE TEST NE PEUT PAS DÉTECTER LE RETRAIT DU TERME EN Z — et c'est voulu
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// `GetSlabDensity` a perdu son terme en Z en même temps que ce portage était écrit
|
||||
// (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni). La pile est comparée à la fonction TELLE
|
||||
// QU'ELLE EST MAINTENANT, donc ce test dit « le portage est fidèle » et ne dit RIEN sur le
|
||||
// changement de génération — c'est exactement la séparation voulue :
|
||||
//
|
||||
// • ce test vert ⇒ la pile == la fonction de référence. Le portage est un refactor pur.
|
||||
// • le monde a changé ⇒ imputable au retrait du terme en Z, ET À RIEN D'AUTRE.
|
||||
//
|
||||
// Sans cette séparation, un écart visuel serait inattribuable entre « j'ai changé le design » et
|
||||
// « j'ai raté le portage ». C'est le test qui fait l'attribution, pas l'ordre des builds.
|
||||
//
|
||||
// This test compares the stack against the reference function AS IT IS NOW, so green here means the
|
||||
// port is a pure refactor and ANY visual delta is attributable to the Z-term removal alone.
|
||||
//
|
||||
// ⚠️ Et la règle de §C10 tient toujours : ne jamais faire tourner les deux chemins dans le même
|
||||
// monde, ne jamais comparer leurs sorties pour égalité ailleurs qu'ici.
|
||||
|
||||
#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(
|
||||
FVoxelForgeOpStackSlabTest,
|
||||
"VoxelForge.OpStack.SlabEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumSlabSamples = 20000;
|
||||
constexpr int32 NumSlabTiles = 60;
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
//=========================================================================
|
||||
// LA BATTERIE, PARAMÉTRÉE PAR ARCHÉTYPE
|
||||
//=========================================================================
|
||||
// Exécutée à l'identique sur FlatPlain et CrystalChamber. Si les deux passent avec la MÊME
|
||||
// pile et la MÊME fabrique, la fusion des deux archétypes est démontrée plutôt qu'affirmée.
|
||||
auto RunForSlot = [&](int32 SlotIndex, const TCHAR* SlotName)
|
||||
{
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(SlotIndex, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("The fixture layout has no %s slot. Check FTestWorld::Build's Archetypes[] ")
|
||||
TEXT("against FTestWorld::Slot%s."), SlotName, SlotName));
|
||||
return;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
const FSlabGenerationParams SlabParams =
|
||||
World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||
|
||||
// `GetSlabDensity` court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde
|
||||
// appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate
|
||||
// valide, et `GetDensityAt` retombe sur le `switch` dans ce cas.
|
||||
if (SlabParams.StrateTopWorldZ - SlabParams.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("%s has degenerate Z bounds (top %.1f, bottom %.1f), which sends GetSlabDensity ")
|
||||
TEXT("down its early-out. The op stack has no such early-out by design."),
|
||||
SlotName, SlabParams.StrateTopWorldZ, SlabParams.StrateBottomWorldZ));
|
||||
return;
|
||||
}
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
|
||||
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
|
||||
Stack.Num(), 5);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = SlabParams.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = SlabParams.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
TArray<FVector> Points;
|
||||
Points.Reserve(NumSlabSamples);
|
||||
{
|
||||
FRandomStream Rng(31337 + SlotIndex);
|
||||
for (int32 i = 0; i < NumSlabSamples; ++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)));
|
||||
}
|
||||
}
|
||||
|
||||
//=====================================================================
|
||||
// 1. ÉQUIVALENCE — géométrie d'abord, bits ensuite.
|
||||
//=====================================================================
|
||||
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // 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; }
|
||||
|
||||
// Même forme que le carve de Maze : `(ColBlend - ColumnSDF)` annule au bord de la
|
||||
// coquille de blend des colonnes, donc un ULP amont ressort amplifié. Marge
|
||||
// généreuse mais BORNÉE — au-delà, c'est une vraie dérive de portage.
|
||||
const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON;
|
||||
if (Delta > UlpNoise) { ++NumBeyondUlpNoise; }
|
||||
}
|
||||
// Le mesher ne lit que le SIGNE. Un désaccord de CÔTÉ bouge la géométrie.
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(TEXT("%s: bit-identical across %d samples."),
|
||||
SlotName, NumSlabSamples));
|
||||
}
|
||||
else if (NumBeyondUlpNoise == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at ")
|
||||
TEXT("(%.0f, %.0f, %.0f)), and 0 cross the isosurface. Same accepted floor as Maze ")
|
||||
TEXT("-- see AUDIT-2026-07.md C10 before hunting it."),
|
||||
SlotName, NumDiff, NumSlabSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor ")
|
||||
TEXT("(largest |delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS is ")
|
||||
TEXT("real port drift. Check, in order: the floor/ceiling noise offsets (7.3/11.1 and ")
|
||||
TEXT("17.3+1000/19.7+2000/3000), the abs() on the ceiling noise, the ceiling clamp ")
|
||||
TEXT("(FloorSurface + 2), the column blend (2.0) and the 0.15/0.7 jitter."),
|
||||
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||
NumSolidDisagreements));
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: no sample lands on the opposite side of the isosurface"), SlotName),
|
||||
NumSolidDisagreements, 0);
|
||||
|
||||
//=====================================================================
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//=====================================================================
|
||||
// Le cache 3×3 des colonnes est `thread_local` et sa clé n'est PAS le chunk mais le jeu de
|
||||
// params + le seed. Si cette clé est incomplète, la couture apparaît ici.
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumSlabSamples);
|
||||
for (int32 i = 0; i < NumSlabSamples; ++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(NumSlabSamples, 700 + Block + SlotIndex * 32, 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(*FString::Printf(
|
||||
TEXT("%s: the op stack is window-invariant across order and threads"), SlotName),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=====================================================================
|
||||
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
|
||||
//=====================================================================
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680 + SlotIndex);
|
||||
|
||||
for (int32 t = 0; t < NumSlabTiles; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
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);
|
||||
|
||||
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
|
||||
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;
|
||||
|
||||
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: %s claimed %s for the box at (%d,%d,%d) but ")
|
||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
|
||||
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
|
||||
TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ")
|
||||
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
|
||||
TEXT("mod's reach (MaxRadius + blend)."),
|
||||
SlotName, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: every box verdict survives brute force (a false verdict is a hole)"),
|
||||
SlotName),
|
||||
NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s box verdicts over %d tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
|
||||
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
|
||||
SlotName, NumSlabTiles, NumProved, NumMixed));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%s proved no tile uniform. Not a correctness problem, but the entire perf ")
|
||||
TEXT("case for dropping the Z term rests on this number being well above zero -- ")
|
||||
TEXT("a slab is mostly solid rock below the floor. Check that the sampled tile Z ")
|
||||
TEXT("range actually reaches below FloorZ - FloorAmp."), SlotName));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RunForSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"));
|
||||
RunForSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -221,6 +221,152 @@ namespace
|
||||
float ExtraReach;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 1 — SOURCE : DALLE / SLAB VOID (FlatPlain ET CrystalChamber)
|
||||
//=========================================================================
|
||||
// Transcription littérale des ÉTAPES 1-3 de `GetSlabDensity` : surface de sol, surface de
|
||||
// plafond, puis `Density = -min(distAuSol, distAuPlafond)`.
|
||||
//
|
||||
// DEUX archétypes, UN opérateur. `GetSlabDensity` est appelé pour FlatPlain et
|
||||
// CrystalChamber sans le moindre branchement sur le type — CrystalChamber n'est rien d'autre
|
||||
// que FlatPlain avec un `CeilingRoughness` plus grand. C'est le premier vrai gain du refactor
|
||||
// (OPSTACK-PLAN §4) : deux des huit archétypes disparaissent dans un seul opérateur, et la
|
||||
// différence entre eux redevient ce qu'elle a toujours été — un jeu de valeurs par défaut.
|
||||
//
|
||||
// Two archetypes, ONE op: GetSlabDensity is called for both with no branch on the type.
|
||||
// CrystalChamber IS FlatPlain with a bigger CeilingRoughness.
|
||||
//
|
||||
// XY-PUR depuis §3.1 (le terme en Z des deux bruits est parti). C'est ce qui rend
|
||||
// `ClassifyBox` exact plutôt qu'estimé — voir plus bas.
|
||||
class FSlabVoidSource final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
FSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
|
||||
: SeedF((float)Seed)
|
||||
, FloorRoughness(P.FloorRoughness)
|
||||
, FloorFrequency(P.FloorRoughnessFrequency)
|
||||
, CeilRoughness(P.CeilingRoughness)
|
||||
, CeilFrequency(P.CeilingRoughnessFrequency)
|
||||
{
|
||||
const float StrateHeight = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
FloorZ = P.StrateBottomWorldZ + StrateHeight * P.FloorRelativeHeight;
|
||||
CeilZ = P.StrateBottomWorldZ + StrateHeight * P.CeilingRelativeHeight;
|
||||
|
||||
// Amplitudes MAXIMALES des deux bruits. Le contrat de `VoxelNoise::FBM` est [-1,1]
|
||||
// (noté à sa définition), donc ces bornes sont des garanties, pas des estimations —
|
||||
// c'est exactement ce qui autorise un verdict de boîte SÛR.
|
||||
// FBM's contract is [-1,1], so these bounds are guarantees, not estimates.
|
||||
FloorAmp = VOXEL_NOISE_SCALE * FMath::Max(FloorRoughness, 0.0f);
|
||||
CeilAmp = VOXEL_NOISE_SCALE * FMath::Max(CeilRoughness, 0.0f);
|
||||
}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
|
||||
// ⚠️ LE point de §3.1. Faux avant le retrait du terme en Z ; le déclarer alors aurait
|
||||
// corrompu silencieusement toute la pile verticale de chunks (voir l'avertissement sur
|
||||
// `IsXYPure` dans VoxelDensityOp.h).
|
||||
bool IsXYPure() const override { return true; }
|
||||
|
||||
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
const float FloorSurface = SurfaceFloor(WorldX, WorldY);
|
||||
const float CeilSurface = SurfaceCeil(WorldX, WorldY, FloorSurface);
|
||||
|
||||
const float DistAboveFloor = WorldZ - FloorSurface;
|
||||
const float DistBelowCeil = CeilSurface - WorldZ;
|
||||
const float VoidField = FMath::Min(DistAboveFloor, DistBelowCeil);
|
||||
|
||||
InOut.Density = -VoidField; // Replace : interne, positif = solide
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// LE VERDICT QUE FLATPLAIN N'A JAMAIS EU
|
||||
//---------------------------------------------------------------------
|
||||
// `ClassifyTile` ne prouve AUCUNE tuile pour les archétypes de grotte aujourd'hui. Ici la
|
||||
// preuve est immédiate et n'exige aucun échantillonnage : les deux surfaces vivent dans des
|
||||
// BANDES en Z dont on connaît les bornes exactes, donc une boîte entièrement sous la bande
|
||||
// du sol est solide, et une boîte entièrement entre les deux bandes est de l'air.
|
||||
//
|
||||
// ⚠️ Conservatif dans le bon sens : rendre `Mixed` ne coûte que du CPU, rendre le mauvais
|
||||
// verdict est un TROU. Toutes les comparaisons ci-dessous sont donc strictes et prennent le
|
||||
// pire cas des deux bruits.
|
||||
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
|
||||
{
|
||||
const float ZMin = (float)VoxelBox.Min.Z;
|
||||
const float ZMax = (float)VoxelBox.Max.Z;
|
||||
|
||||
// Bornes de la surface de sol : FloorZ ± FloorAmp.
|
||||
const float FloorLo = FloorZ - FloorAmp;
|
||||
const float FloorHi = FloorZ + FloorAmp;
|
||||
|
||||
// Bornes du plafond. `CeilNoise = |bruit| · rugosité` ∈ [0, CeilAmp] ⇒ la surface ne
|
||||
// peut que DESCENDRE depuis CeilZ… sauf que le clamp `Max(…, FloorSurface + 2)` peut la
|
||||
// remonter. Le majorant honnête est donc le max des deux possibilités.
|
||||
const float CeilLo = CeilZ - CeilAmp;
|
||||
const float CeilHi = FMath::Max(CeilZ, FloorHi + 2.0f);
|
||||
|
||||
// Sous le sol le plus bas possible ⇒ distAuSol < 0 partout ⇒ densité > 0 ⇒ SOLIDE.
|
||||
if (ZMax < FloorLo) { return EVoxelTileClass::AllSolid; }
|
||||
|
||||
// Au-dessus du plafond le plus haut possible ⇒ distAuPlafond < 0 ⇒ SOLIDE.
|
||||
if (ZMin > CeilHi) { return EVoxelTileClass::AllSolid; }
|
||||
|
||||
// Strictement entre les deux bandes ⇒ les deux distances sont > 0 ⇒ densité < 0 ⇒ AIR.
|
||||
// (Les colonnes peuvent re-remplir cet air : c'est FGridColumnMod qui le déclare, en
|
||||
// rendant FillOnly quand une colonne atteint la boîte. Le pliage s'en charge.)
|
||||
if (ZMin > FloorHi && ZMax < CeilLo) { return EVoxelTileClass::AllAir; }
|
||||
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||
{
|
||||
return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant
|
||||
}
|
||||
|
||||
private:
|
||||
// Les deux surfaces, transcrites au caractère près depuis GetSlabDensity — y compris le
|
||||
// détour par FVector, qui est le même piège d'arrondi que dans FSdfRoughnessMod
|
||||
// (float → double → float sous /fp:fast). Ne pas « simplifier ».
|
||||
float SurfaceFloor(float WorldX, float WorldY) const
|
||||
{
|
||||
if (FloorRoughness <= 0.0f) { return FloorZ; }
|
||||
const float FF = FloorFrequency;
|
||||
const FVector NoisePos(WorldX * FF + SeedF * 7.3f,
|
||||
WorldY * FF + SeedF * 11.1f,
|
||||
0.0f);
|
||||
const float N = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
|
||||
VoxelGenLOD::Eff(3), 2.0f, 0.5f)
|
||||
* VOXEL_NOISE_SCALE * FloorRoughness;
|
||||
return FloorZ + N;
|
||||
}
|
||||
|
||||
float SurfaceCeil(float WorldX, float WorldY, float FloorSurface) const
|
||||
{
|
||||
float CeilNoise = 0.0f;
|
||||
if (CeilRoughness > 0.0f)
|
||||
{
|
||||
const float CF = CeilFrequency;
|
||||
const FVector NoisePos(WorldX * CF + SeedF * 17.3f + 1000.0f,
|
||||
WorldY * CF + SeedF * 19.7f + 2000.0f,
|
||||
3000.0f);
|
||||
const float Raw = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
|
||||
VoxelGenLOD::Eff(3), 2.0f, 0.5f)
|
||||
* VOXEL_NOISE_SCALE;
|
||||
// abs() ⇒ les formations ne pendent QUE vers le bas.
|
||||
CeilNoise = FMath::Abs(Raw) * CeilRoughness;
|
||||
}
|
||||
return FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f);
|
||||
}
|
||||
|
||||
float SeedF;
|
||||
float FloorZ = 0.0f, CeilZ = 0.0f;
|
||||
float FloorRoughness, FloorFrequency;
|
||||
float CeilRoughness, CeilFrequency;
|
||||
float FloorAmp = 0.0f, CeilAmp = 0.0f;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE SDF
|
||||
//=========================================================================
|
||||
@@ -275,6 +421,148 @@ namespace
|
||||
float ApplyWithin;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 3 — MODIFIER : COLONNES SUR GRILLE MONDE / WORLD-GRID COLUMNS
|
||||
//=========================================================================
|
||||
// ÉTAPE 4 de `GetSlabDensity`. Des cylindres de hauteur infinie posés sur une grille de
|
||||
// `ColumnSpacing`, un tirage d'existence et un jitter par cellule. Le champ de vide décide déjà
|
||||
// où est le solide, donc la colonne n'a qu'à AJOUTER de la densité le long de son XY — elle
|
||||
// n'est visible que là où le vide avait creusé autour d'elle.
|
||||
//
|
||||
// Le cache 3×3 par cellule est repris tel quel (il était déjà `thread_local` dans l'original,
|
||||
// et c'est exactement ce que la note de threading de VoxelDensityOp.h autorise). Sa clé
|
||||
// contient tous les paramètres qui influent sur le résultat + le seed, donc un changement de
|
||||
// layout qui change un param invalide bien ; un changement qui n'en touche aucun produirait
|
||||
// des colonnes identiques (cf. AUDIT C2 — la clé est complète, pas seulement le coord).
|
||||
class FGridColumnMod final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
explicit FGridColumnMod(const FSlabGenerationParams& P, int32 InSeed)
|
||||
: Seed((uint32)InSeed)
|
||||
, Spacing(P.ColumnSpacing)
|
||||
, ColDensity(P.ColumnDensity)
|
||||
, MinRadius(P.ColumnMinRadius)
|
||||
, MaxRadius(P.ColumnMaxRadius)
|
||||
, BaseDensity(P.BaseDensity)
|
||||
{}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
bool IsXYPure() const override { return true; } // cylindres de hauteur infinie
|
||||
|
||||
void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
if (ColDensity <= 0.0f || Spacing <= 0.0f) { return; }
|
||||
|
||||
const int32 ColCX = FMath::FloorToInt(WorldX / Spacing);
|
||||
const int32 ColCY = FMath::FloorToInt(WorldY / Spacing);
|
||||
|
||||
const TArray<FSlabColumn, TInlineAllocator<9>>& Cols = GetCells(ColCX, ColCY);
|
||||
|
||||
float ColumnSDF = FLT_MAX;
|
||||
for (const FSlabColumn& Col : Cols)
|
||||
{
|
||||
const float DX2D = WorldX - Col.X;
|
||||
const float DY2D = WorldY - Col.Y;
|
||||
ColumnSDF = FMath::Min(ColumnSDF, FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - Col.R);
|
||||
}
|
||||
|
||||
if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX)
|
||||
{
|
||||
float Fill = FMath::Clamp((ColBlend - ColumnSDF) / (ColBlend * 2.0f), 0.0f, 1.0f);
|
||||
Fill = SmoothStep01(Fill);
|
||||
InOut.Density += Fill * BaseDensity * 1.5f;
|
||||
}
|
||||
}
|
||||
|
||||
// N'AJOUTE que du solide ⇒ tue AllAir, jamais AllSolid. `Identity` dès qu'aucune colonne
|
||||
// n'atteint la boîte — ce qui, pour un `ColumnDensity` de 0.08, est l'écrasante majorité du
|
||||
// volume. C'est cet `Identity` qui laisse survivre le verdict AllAir de la source.
|
||||
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
|
||||
{
|
||||
if (ColDensity <= 0.0f || Spacing <= 0.0f) { return EVoxelOpEffect::Identity; }
|
||||
|
||||
// Marge : le centre d'une colonne vit dans sa cellule, son influence porte au plus
|
||||
// MaxRadius + ColBlend. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
|
||||
const float Reach = FMath::Max(MaxRadius, 0.0f) + ColBlend;
|
||||
|
||||
const int32 CX0 = FMath::FloorToInt(((float)VoxelBox.Min.X - Reach) / Spacing);
|
||||
const int32 CX1 = FMath::FloorToInt(((float)VoxelBox.Max.X + Reach) / Spacing);
|
||||
const int32 CY0 = FMath::FloorToInt(((float)VoxelBox.Min.Y - Reach) / Spacing);
|
||||
const int32 CY1 = FMath::FloorToInt(((float)VoxelBox.Max.Y + Reach) / Spacing);
|
||||
|
||||
for (int32 CY = CY0; CY <= CY1; ++CY)
|
||||
{
|
||||
for (int32 CX = CX0; CX <= CX1; ++CX)
|
||||
{
|
||||
FSlabColumn Col;
|
||||
if (!RollColumn(CX, CY, Col)) { continue; }
|
||||
|
||||
// Cercle (rayon + blend) contre le rectangle XY de la boîte.
|
||||
const float R = Col.R + ColBlend;
|
||||
const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Col.X,
|
||||
Col.X - (float)VoxelBox.Max.X));
|
||||
const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Col.Y,
|
||||
Col.Y - (float)VoxelBox.Max.Y));
|
||||
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; }
|
||||
}
|
||||
}
|
||||
return EVoxelOpEffect::Identity;
|
||||
}
|
||||
|
||||
private:
|
||||
struct FSlabColumn { float X, Y, R; };
|
||||
|
||||
static constexpr float ColBlend = 2.0f; // identique à GetSlabDensity
|
||||
|
||||
/** Le tirage d'une cellule : existence, jitter, rayon. Fonction PURE de (cellule, seed,
|
||||
* params) — donc `Eval` et `EffectOverBox` voient forcément la même colonne. */
|
||||
bool RollColumn(int32 CX, int32 CY, FSlabColumn& Out) const
|
||||
{
|
||||
const uint32 H = VoxelHash::Cell(CX, CY, Seed ^ 0xC01C01u);
|
||||
if (VoxelHash::ToFloat01(H) > ColDensity) { return false; }
|
||||
|
||||
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u));
|
||||
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u));
|
||||
|
||||
Out.X = (CX + 0.15f + JX * 0.7f) * Spacing;
|
||||
Out.Y = (CY + 0.15f + JY * 0.7f) * Spacing;
|
||||
Out.R = FMath::Lerp(MinRadius, MaxRadius,
|
||||
VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Le voisinage 3×3 de la cellule centrale, mémoïsé par worker. */
|
||||
const TArray<FSlabColumn, TInlineAllocator<9>>& GetCells(int32 ColCX, int32 ColCY) const
|
||||
{
|
||||
thread_local TArray<FSlabColumn, TInlineAllocator<9>> SC_Cols;
|
||||
thread_local int32 SC_CX = INT32_MAX, SC_CY = INT32_MAX;
|
||||
thread_local uint32 SC_Seed = 0xFFFFFFFFu;
|
||||
thread_local float SC_Spacing = -1.0f, SC_Dens = -1.0f, SC_MinR = -1.0f, SC_MaxR = -1.0f;
|
||||
|
||||
if (ColCX != SC_CX || ColCY != SC_CY || Seed != SC_Seed || Spacing != SC_Spacing ||
|
||||
ColDensity != SC_Dens || MinRadius != SC_MinR || MaxRadius != SC_MaxR)
|
||||
{
|
||||
SC_CX = ColCX; SC_CY = ColCY; SC_Seed = Seed; SC_Spacing = Spacing;
|
||||
SC_Dens = ColDensity; SC_MinR = MinRadius; SC_MaxR = MaxRadius;
|
||||
SC_Cols.Reset();
|
||||
|
||||
for (int32 DY = -1; DY <= 1; DY++)
|
||||
{
|
||||
for (int32 DX = -1; DX <= 1; DX++)
|
||||
{
|
||||
FSlabColumn Col;
|
||||
if (RollColumn(ColCX + DX, ColCY + DY, Col)) { SC_Cols.Add(Col); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return SC_Cols;
|
||||
}
|
||||
|
||||
uint32 Seed;
|
||||
float Spacing, ColDensity, MinRadius, MaxRadius, BaseDensity;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE)
|
||||
//=========================================================================
|
||||
@@ -488,6 +776,30 @@ namespace VoxelDensityOps
|
||||
return MakeUnique<FSdfCarveOp>(Blend, BaseDensity);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
|
||||
{
|
||||
return MakeUnique<FSlabVoidSource>(P, Seed);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed)
|
||||
{
|
||||
return MakeUnique<FGridColumnMod>(P, Seed);
|
||||
}
|
||||
|
||||
void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||
{
|
||||
// DEUX archétypes entrent ici, aucun branchement ne les distingue — parce que
|
||||
// `GetSlabDensity` n'en fait aucun non plus. FlatPlain et CrystalChamber ne diffèrent que
|
||||
// par leurs valeurs par défaut, et c'est maintenant visible dans le code plutôt que dans
|
||||
// un commentaire. 8 archétypes → 7.
|
||||
OutStack.Add(MakeSlabVoidSource(P, Seed));
|
||||
OutStack.Add(MakeGridColumnMod(P, Seed));
|
||||
|
||||
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
|
||||
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||
}
|
||||
|
||||
void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
|
||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||
{
|
||||
|
||||
@@ -562,6 +562,22 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, Seed,
|
||||
OriginSpineRadius, StrateManager);
|
||||
break;
|
||||
|
||||
case ECaveGeneratorType::FlatPlain:
|
||||
case ECaveGeneratorType::CrystalChamber:
|
||||
// UN SEUL cas pour les deux, comme le `switch` de production juste en dessous :
|
||||
// `GetSlabDensity` ne les distingue pas non plus. Voir BuildSlabStack.
|
||||
// Même garde de strate dégénérée : GetSlabDensity court-circuite sur `1.0f`.
|
||||
if (CP_Slab.StrateTopWorldZ - CP_Slab.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
CP_UseOpStack = false;
|
||||
break;
|
||||
}
|
||||
OpCtx.StrateTopWorldZ = CP_Slab.StrateTopWorldZ;
|
||||
OpCtx.StrateBottomWorldZ = CP_Slab.StrateBottomWorldZ;
|
||||
VoxelDensityOps::BuildSlabStack(CP_OpStack, CP_Slab, Seed,
|
||||
OriginSpineRadius, StrateManager);
|
||||
break;
|
||||
default:
|
||||
// UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc
|
||||
// on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch`
|
||||
@@ -1807,8 +1823,17 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
// Signed noise allows both hills (noise > 0 → floor rises) and
|
||||
// valleys (noise < 0 → floor dips) for natural rolling ground.
|
||||
//
|
||||
// Z frequency is set very low (5% of XY) so the floor features are
|
||||
// broad and horizontal — like natural geological ground, not bumpy walls.
|
||||
// ⚠️ XY-PUR / XY-PURE (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni 2026-07-27).
|
||||
// La 3e coordonnée était `WorldZ * FF * 0.05f` : une hauteur de sol qui dépendait de
|
||||
// l'altitude d'où on la demandait. Le coefficient était minuscule, donc ça se lisait comme un
|
||||
// léger étirement vertical plutôt que comme un bug — mais ça bloquait le cache de colonnes T1.a
|
||||
// et rendait toute classification de boîte inexacte. Constante ⇒ la surface est une vraie
|
||||
// fonction de (X,Y). Le monde se re-tune une fois : on échantillonne une autre tranche du champ
|
||||
// de bruit, donc la forme du sol change (elle ne se dégrade pas).
|
||||
//
|
||||
// The 3rd coord was WorldZ * FF * 0.05f — a floor height that depended on the altitude you
|
||||
// asked from. Now a constant, so the surface is a genuine function of (X,Y): the T1.a column
|
||||
// cache and an exact box verdict both become available. Worlds re-tune once.
|
||||
|
||||
const float FloorZ = Params.StrateBottomWorldZ + StrateHeight * Params.FloorRelativeHeight;
|
||||
|
||||
@@ -1819,7 +1844,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
FloorNoise = FractalNoise3D(FVector(
|
||||
WorldX * FF + SeedF * 7.3f,
|
||||
WorldY * FF + SeedF * 11.1f,
|
||||
WorldZ * FF * 0.05f // Very low Z freq → horizontal ground features
|
||||
0.0f // XY-pur : plus aucune dépendance en Z / no Z dependence
|
||||
), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.FloorRoughness;
|
||||
}
|
||||
|
||||
@@ -1836,9 +1861,10 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
// This asymmetry (only downward protrusions, never upward pockets) creates
|
||||
// the crystal-forest / stalactite silhouette from below.
|
||||
//
|
||||
// Z frequency is also low so formations have horizontal extent — each
|
||||
// "crystal" or "stalactite" is wide and sweeps across the ceiling, not
|
||||
// a sharp spike (use high frequency for spike-like features if desired).
|
||||
// XY-PUR, même raison que le sol ci-dessus (§3.1). Le `+ 3000.0f` RESTE : ce n'est pas un
|
||||
// terme en Z, c'est le décalage qui décorrèle le champ du plafond de celui du sol.
|
||||
// XY-pure for the same reason as the floor. The + 3000.0f STAYS — it is not a Z term, it is
|
||||
// the offset that decorrelates the ceiling's noise field from the floor's.
|
||||
|
||||
const float CeilZ = Params.StrateBottomWorldZ + StrateHeight * Params.CeilingRelativeHeight;
|
||||
|
||||
@@ -1849,7 +1875,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
float RawNoise = FractalNoise3D(FVector(
|
||||
WorldX * CF + SeedF * 17.3f + 1000.0f,
|
||||
WorldY * CF + SeedF * 19.7f + 2000.0f,
|
||||
WorldZ * CF * 0.08f + 3000.0f // Low Z freq → formations extend horizontally
|
||||
3000.0f // XY-pur : décalage de décorrélation seul / offset only
|
||||
), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE;
|
||||
|
||||
// abs() → formations ONLY hang down, never push ceiling up into solid rock.
|
||||
|
||||
@@ -571,8 +571,10 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
|
||||
// and falls back to the switch, so the box can be ticked anywhere without breaking anything.
|
||||
switch (Def->GeneratorType)
|
||||
{
|
||||
case ECaveGeneratorType::Maze: return true;
|
||||
default: return false;
|
||||
case ECaveGeneratorType::Maze: return true; // Phase 1
|
||||
case ECaveGeneratorType::FlatPlain: // Phase 2 — les deux partagent
|
||||
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,28 @@ namespace VoxelDensityOps
|
||||
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
|
||||
|
||||
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
|
||||
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
|
||||
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
|
||||
* bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
|
||||
|
||||
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
|
||||
* `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed);
|
||||
|
||||
/**
|
||||
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
||||
* SlabVoidSource → GridColumnMod → [structural post ×3]
|
||||
*
|
||||
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
|
||||
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
|
||||
* `GetSlabDensity` — un jeu de valeurs par défaut, pas du code.
|
||||
*/
|
||||
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
|
||||
* ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]
|
||||
|
||||
Reference in New Issue
Block a user