644339def5
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>
831 lines
43 KiB
C++
831 lines
43 KiB
C++
// 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 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
|
||
//=========================================================================
|
||
// 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; }
|
||
|
||
// ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ — ne pas « simplifier ».
|
||
// L'original écrit `FractalNoise3D(FVector(WorldX * 0.12f, ...), Eff(3))`, et
|
||
// FractalNoise3D fait `VoxelNoise::FBM((float)Position.X, ...)`. FVector étant en
|
||
// DOUBLE (UE5), le produit flottant y transite par un double avant d'être re-arrondi
|
||
// en float. Passer directement des floats saute cet aller-retour, et sous /fp:fast
|
||
// les deux chemins ne s'arrondissent pas au même endroit : ~1 ULP d'écart sur le SDF,
|
||
// qui ressort en 1 ULP sur la densité finale. Reproduire le détour, c'est reproduire
|
||
// l'arrondi. HYPOTHÈSE NON ENCORE VÉRIFIÉE : elle prédit que MazeEquivalence passe de
|
||
// 454 écarts à 0. Si le prochain run montre encore des écarts, c'est que la divergence
|
||
// vient d'ailleurs (candidat suivant : contraction FMA entre unités de compilation).
|
||
//
|
||
// THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. The original goes
|
||
// float -> double (FVector is double in UE5) -> float; going straight through floats
|
||
// skips a rounding step, and under /fp:fast the two paths round in different places.
|
||
// Reproducing the detour reproduces the rounding.
|
||
const FVector NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency);
|
||
InOut.Sdf += VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
|
||
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 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)
|
||
//=========================================================================
|
||
// 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);
|
||
}
|
||
|
||
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)
|
||
{
|
||
// 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);
|
||
}
|
||
}
|