With the operator stack as the only density path, the per-archetype density functions have no caller left in the game: - Delete UVoxelGenerator::GetSlabDensity, GetMazeDensity, GetSurfaceDensity, SurfaceDensityFromColumn, GetVerticalShaftDensity and GetFloatingIslandDensity (~1000 lines) with their declarations. - Drop GetDensityWithParams' OutWarpedPosition out-parameter; only the native GetDensityAt branch removed in the previous commit used it. - OpStack Slab/Maze/VerticalShaft/FloatingIsland and SurfaceHeight tests: remove the stack-vs-legacy comparisons; keep decomposition, finiteness, liveness, window-invariance, cache-cost and box-verdict checks (Shaft cache timing now measures the stack source). - PassageLandsInOpenSpace: the source-only footing probe evaluates the archetype stack built without a strate manager instead of the deleted functions. - Comments that cited the deleted functions now cite the operators (FSlabVoidSource, FLatticeCorridorSource, FIslandBlobSource, ...). GSurfColCache is documented as ClassifyTile's column cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URDGLsu7c4nunqNFPJEFPU
731 lines
38 KiB
C++
731 lines
38 KiB
C++
// 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.
|
||
//
|
||
// ⚠️ CECI EST LE CHEMIN DE GÉNÉRATION DU JEU.
|
||
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk pour chaque strate :
|
||
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork,
|
||
// Underwater (8 sur 8)**. `ClassifyTile` construit la même pile pour les archétypes de cave et plie
|
||
// `ClassifyBox`; les gaps et SurfaceWorld gardent leurs preuves exactes dédiées.
|
||
//
|
||
// THIS IS THE GAME'S GENERATION PATH. GetDensityAt builds the stack per chunk for every strate.
|
||
// ClassifyTile uses the same stack for cave archetypes; gaps and SurfaceWorld retain their exact
|
||
// hand-written proofs.
|
||
//
|
||
// POURQUOI CETTE FORME / WHY THIS SHAPE
|
||
// La question à laquelle cette forme répond 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). Chaque archétype a été porté puis comparé bit à bit à sa fonction
|
||
// d'origine avant qu'elle ne soit retirée : la réponse a été MESURÉE plutôt qu'une opinion.
|
||
|
||
#pragma once
|
||
|
||
#include "CoreMinimal.h"
|
||
#include "VoxelDensityOp.h"
|
||
#include "VoxelDensityProfile.h"
|
||
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
||
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
|
||
|
||
class UVoxelGenerator;
|
||
class UVoxelStrateManager;
|
||
struct FVoxelOpStackRecipe;
|
||
struct FVoxelStrateArchetypeParams;
|
||
struct FVoxelStrateRegionManifest;
|
||
enum class EVoxelStrateOpClass : uint8;
|
||
|
||
/** The declarations the offline structure roller reads before it ever materialises an op. */
|
||
struct VOXELFORGE_API FVoxelStrateOpContract
|
||
{
|
||
EVoxelOpRole Role = EVoxelOpRole::DetailModifier;
|
||
EVoxelOpChannelMask Reads = VoxelOpChannels::None;
|
||
EVoxelOpChannelMask Writes = VoxelOpChannels::None;
|
||
bool bAdditive = false;
|
||
EVoxelOpResourceMask RequiredResources = VoxelOpResources::None;
|
||
EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None;
|
||
};
|
||
|
||
/**
|
||
* A lowered evaluator selected when a stack has a proven, canonical implementation.
|
||
*
|
||
* The authored operator list remains present for classification, diagnostics, tests, and the
|
||
* fallback evaluator. This is a plan choice made while the stack is assembled, not a runtime
|
||
* CPU-feature dispatch and not a second semantic graph.
|
||
*/
|
||
enum class EVoxelOpFusedEvaluator : uint8
|
||
{
|
||
None,
|
||
TunnelNetwork,
|
||
};
|
||
|
||
/**
|
||
* 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 → seal XY 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 FVoxelOpStack
|
||
{
|
||
private:
|
||
/** Metadata is snapshotted when an op enters the stack so no declaration virtual is ever
|
||
* dispatched from `EvalSample`'s per-voxel loop. */
|
||
struct FOpEntry
|
||
{
|
||
TUniquePtr<IVoxelDensityOp> Op;
|
||
EVoxelOpChannelMask Reads = VoxelOpChannels::None;
|
||
EVoxelOpChannelMask Writes = VoxelOpChannels::None;
|
||
bool bAdditive = false;
|
||
EVoxelOpResourceMask RequiredResources = VoxelOpResources::None;
|
||
EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None;
|
||
const TCHAR* ProfileName = nullptr;
|
||
VoxelDensityProfile::EBucket ProfileBucket = VoxelDensityProfile::EBucket::OtherOp;
|
||
};
|
||
|
||
public:
|
||
// DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur :
|
||
// une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs
|
||
// polymorphes, ce qui n'a pas de sens ici (une pile n'existe qu'une fois par strate).
|
||
//
|
||
// ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur
|
||
// une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur
|
||
// d'affectation par copie — impossible à générer pour un `TArray<FOpEntry>`, d'où
|
||
// l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne.
|
||
//
|
||
// MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack
|
||
// uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC,
|
||
// dllexport forces instantiation of every implicit member including copy-assignment, which
|
||
// cannot be generated for a TArray<FOpEntry> (error C2280). Export the out-of-line
|
||
// method instead.
|
||
FVoxelOpStack() = default;
|
||
FVoxelOpStack(FVoxelOpStack&&) = default;
|
||
FVoxelOpStack& operator=(FVoxelOpStack&&) = default;
|
||
FVoxelOpStack(const FVoxelOpStack&) = delete;
|
||
FVoxelOpStack& operator=(const FVoxelOpStack&) = delete;
|
||
|
||
void Add(TUniquePtr<IVoxelDensityOp> Op)
|
||
{
|
||
// Adding an op invalidates any previously selected lowered plan. Factories mark the plan
|
||
// only after the complete authored graph has been assembled.
|
||
FusedEvaluator = EVoxelOpFusedEvaluator::None;
|
||
FOpEntry Entry;
|
||
Entry.Op = MoveTemp(Op);
|
||
if (Entry.Op.Get() != nullptr)
|
||
{
|
||
// Assembly-time metadata only. ValidateChannelOrder reuses this snapshot, and Eval
|
||
// never asks an operator for its declaration.
|
||
Entry.Reads = Entry.Op->ChannelReads();
|
||
Entry.Writes = Entry.Op->ChannelWrites();
|
||
Entry.bAdditive = Entry.Op->IsAdditive();
|
||
Entry.RequiredResources = Entry.Op->RequiredResources();
|
||
Entry.ProvidedResources = Entry.Op->ProvidedResources();
|
||
Entry.ProfileName = Entry.Op->DebugName();
|
||
Entry.ProfileBucket = VoxelDensityProfile::BucketFromName(Entry.ProfileName);
|
||
}
|
||
Ops.Add(MoveTemp(Entry));
|
||
}
|
||
|
||
int32 Num() const { return Ops.Num(); }
|
||
|
||
/** Select the lowered evaluator for this complete authored graph. */
|
||
void SetFusedEvaluator(EVoxelOpFusedEvaluator InEvaluator)
|
||
{
|
||
FusedEvaluator = InEvaluator;
|
||
}
|
||
|
||
/** The lowered plan, or None when the general interpreted/block path must be used. */
|
||
EVoxelOpFusedEvaluator GetFusedEvaluator() const
|
||
{
|
||
return FusedEvaluator;
|
||
}
|
||
|
||
/** True when this stack publishes the requested immutable geometry resource. */
|
||
bool ProvidesResource(EVoxelOpResourceMask Resource) const
|
||
{
|
||
if (Resource == VoxelOpResources::None)
|
||
{
|
||
return false;
|
||
}
|
||
for (const FOpEntry& Entry : Ops)
|
||
{
|
||
if ((Entry.ProvidedResources & Resource) == Resource)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Allocator-backed bytes owned by the stack, including each concrete operator allocation.
|
||
// The enclosing cache entry adds sizeof(FVoxelOpStack) separately.
|
||
SIZE_T GetAllocatedSize() const;
|
||
|
||
/** 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)
|
||
{
|
||
PreparedContext = Ctx;
|
||
bHasPreparedContext = true;
|
||
for (const FOpEntry& Entry : Ops) { Entry.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
|
||
{
|
||
return EvalSample(WorldX, WorldY, WorldZ).Density;
|
||
}
|
||
|
||
/** L'état COMPLET (densité + SDF) après toute la pile. Diagnostic : quand deux évaluations
|
||
* divergent, c'est le canal SDF qui dit si l'écart naît avant ou après la conversion.
|
||
* / The full state after the stack — the SDF channel is what says whether a divergence is
|
||
* born before or after the carve. */
|
||
FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const
|
||
{
|
||
FVoxelOpSample S;
|
||
// Fine operation timers are a diagnostic mode. Keeping their construction out of the
|
||
// ordinary scalar path matters for callers that still use fractional samples or an
|
||
// archetype without a block session; the block path already times one scope per op/block.
|
||
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||
for (const FOpEntry& Entry : Ops)
|
||
{
|
||
if (bProfileOps)
|
||
{
|
||
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
|
||
Entry.Op->Eval(WorldX, WorldY, WorldZ, S);
|
||
}
|
||
else
|
||
{
|
||
Entry.Op->Eval(WorldX, WorldY, WorldZ, S);
|
||
}
|
||
}
|
||
|
||
// The room-graph source publishes the authored floor ownership while evaluating the
|
||
// shape, but it must not be reasserted here. This stack is followed by the common
|
||
// disturbance/passage writers; composing the floor at this point would make every authored
|
||
// floor sample look like a post-hoc clamp (and counted 100% of them). The generator
|
||
// consumes the same immutable core result after those writers and composes the floor once,
|
||
// at the final MC boundary. The metadata remains on the sample for that hand-off and for
|
||
// diagnostics, but it is not allowed to modify density inside the operator stack.
|
||
return S;
|
||
}
|
||
|
||
/**
|
||
* Evaluate one regular lattice in operator order. The caller owns and initializes the sample
|
||
* array (normally to FVoxelOpSample defaults); each operator transforms that same array before
|
||
* the next operator runs. The timer is intentionally one scope per operator/block rather than
|
||
* one scope per operator/sample. Operators without a block implementation use the conservative
|
||
* scalar fallback supplied by IVoxelDensityOp, so this entry point is safe to introduce before
|
||
* converting the concrete operators.
|
||
*/
|
||
void EvalBlock(const FVoxelOpBlock& Block) const
|
||
{
|
||
if (Block.Samples == nullptr || Block.Step <= 0
|
||
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||
for (const FOpEntry& Entry : Ops)
|
||
{
|
||
if (bProfileOps)
|
||
{
|
||
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
|
||
Entry.Op->EvalBlock(Block);
|
||
}
|
||
else
|
||
{
|
||
Entry.Op->EvalBlock(Block);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Evaluate only the entries that the interval fold proved non-identity for this block. */
|
||
void EvalBlock(const FVoxelOpBlock& Block, const TArray<int32>& ActiveOps) const
|
||
{
|
||
if (Block.Samples == nullptr || Block.Step <= 0
|
||
|| Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full;
|
||
for (const int32 OpIndex : ActiveOps)
|
||
{
|
||
if (!Ops.IsValidIndex(OpIndex)) { continue; }
|
||
const FOpEntry& Entry = Ops[OpIndex];
|
||
if (bProfileOps)
|
||
{
|
||
VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket);
|
||
Entry.Op->EvalBlock(Block);
|
||
}
|
||
else
|
||
{
|
||
Entry.Op->EvalBlock(Block);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build a conservative active list for one exact mesher lattice block. An operator may be
|
||
* omitted only when its state-aware box contract says Identity and it does not publish SDF.
|
||
* SDF writers remain in the list even when their density effect is Identity: their SDF is a
|
||
* channel consumed by a later converter, and an unchanged-looking interval is not by itself a
|
||
* pointwise proof. Unknown costs the operator visit, never the geometry.
|
||
*/
|
||
int32 BuildActiveOpList(const FBox& VoxelBox, int32 InStep,
|
||
FIntVector InLatticeOrigin, TArray<int32>& OutActiveOps) const
|
||
{
|
||
OutActiveOps.Reset();
|
||
if (!bHasPreparedContext)
|
||
{
|
||
for (int32 Index = 0; Index < Ops.Num(); ++Index) { OutActiveOps.Add(Index); }
|
||
return OutActiveOps.Num();
|
||
}
|
||
|
||
FVoxelOpContext Ctx = PreparedContext;
|
||
Ctx.Step = FMath::Max(InStep, 1);
|
||
Ctx.LatticeOriginVoxels = InLatticeOrigin;
|
||
Ctx.bUseLatticeProof = true;
|
||
|
||
FVoxelBoxHypotheses H;
|
||
for (int32 Index = 0; Index < Ops.Num(); ++Index)
|
||
{
|
||
const FOpEntry& Entry = Ops[Index];
|
||
const bool bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0;
|
||
const bool bPublishesResource = Entry.ProvidedResources != VoxelOpResources::None;
|
||
const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx);
|
||
bool bCanSkip = false;
|
||
|
||
if (Forced != EVoxelTileClass::Mixed)
|
||
{
|
||
VF_ForceHypotheses(H, Forced, Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx));
|
||
if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
|
||
}
|
||
else
|
||
{
|
||
const EVoxelOpEffect Effect =
|
||
Entry.Op->EffectOverBox(VoxelBox, Ctx, H);
|
||
bCanSkip = Effect == EVoxelOpEffect::Identity
|
||
&& !bWritesSdf
|
||
&& !bPublishesResource;
|
||
VF_FoldEffect(H,
|
||
Effect,
|
||
Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H),
|
||
Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H));
|
||
if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
|
||
}
|
||
|
||
if (!bCanSkip) { OutActiveOps.Add(Index); }
|
||
}
|
||
return OutActiveOps.Num();
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
|
||
/** Return a same-sample structural-core result published by a source in this stack. */
|
||
bool TryGetLastTunnelCoreWorldEvaluation(
|
||
FTunnelCoreWorldEvaluation& OutEvaluation) const;
|
||
|
||
/**
|
||
* 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 (les seals dans leurs bandes) écrase ce que la pile avait conclu avant lui.
|
||
*/
|
||
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||
{
|
||
FVoxelBoxHypotheses H;
|
||
for (const FOpEntry& Entry : Ops)
|
||
{
|
||
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx,
|
||
(Entry.Writes & VoxelOpChannels::Sdf) != 0);
|
||
// Do not early-out on a dead hypothesis: a later forcing structural post may
|
||
// deliberately overwrite it (the XY edge seal is appended after passage tube/landing
|
||
// carving and support).
|
||
}
|
||
return H.Resolve();
|
||
}
|
||
|
||
/**
|
||
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
|
||
*
|
||
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même ordre,
|
||
* y compris le fait de laisser un post forçant ultérieur ressusciter une hypothèse morte. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
|
||
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
|
||
* l'autre change avec lui.
|
||
*
|
||
* `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer
|
||
* l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom
|
||
* lisible s'obtient par `GetOpDebugName(index)`.
|
||
*
|
||
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
|
||
* ClassifyBox, including later forcing posts that may overwrite a dead hypothesis — a
|
||
* diagnostic that takes a slightly different path sends you hunting in the wrong place.
|
||
*/
|
||
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||
int32& OutSolidKiller, int32& OutAirKiller) const
|
||
{
|
||
OutSolidKiller = INDEX_NONE;
|
||
OutAirKiller = INDEX_NONE;
|
||
|
||
FVoxelBoxHypotheses H;
|
||
for (int32 i = 0; i < Ops.Num(); ++i)
|
||
{
|
||
const bool bSolidBefore = H.bCanBeAllSolid;
|
||
const bool bAirBefore = H.bCanBeAllAir;
|
||
|
||
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx,
|
||
(Ops[i].Writes & VoxelOpChannels::Sdf) != 0);
|
||
|
||
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
|
||
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
|
||
|
||
}
|
||
return H.Resolve();
|
||
}
|
||
|
||
/**
|
||
* Per-operator box-bound audit. This deliberately replays the same fold as ClassifyBox while
|
||
* also brute-forcing Eval on the queried lattice. It is report/test infrastructure only: it
|
||
* must never be used to make a generation decision.
|
||
*/
|
||
struct FOpBoxDiagnostic
|
||
{
|
||
int32 Index = INDEX_NONE;
|
||
FString Name;
|
||
EVoxelOpEffect Effect = EVoxelOpEffect::Identity;
|
||
EVoxelTileClass ForcedVerdict = EVoxelTileClass::Mixed;
|
||
bool bForced = false;
|
||
bool bWritesSdf = false;
|
||
float ForcedMargin = 0.0f;
|
||
float MaxCarve = 0.0f;
|
||
float MaxFill = 0.0f;
|
||
bool bHasDensityDelta = false;
|
||
float ActualDensityDeltaMin = FLT_MAX;
|
||
float ActualDensityDeltaMax = -FLT_MAX;
|
||
bool bHasSdfValue = false;
|
||
float ActualSdfMin = FLT_MAX;
|
||
float ActualSdfMax = -FLT_MAX;
|
||
bool bHasSdfBound = false;
|
||
float SdfBoundMin = FLT_MAX;
|
||
float SdfBoundMax = FLT_MAX;
|
||
bool bCarveBoundViolated = false;
|
||
bool bFillBoundViolated = false;
|
||
uint64 SampleCount = 0;
|
||
};
|
||
|
||
VOXELFORGE_API void DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
|
||
int32 SampleStep, TArray<FOpBoxDiagnostic>& OutDiagnostics,
|
||
EVoxelTileClass* OutVerdict = nullptr) const;
|
||
|
||
/** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */
|
||
const TCHAR* GetOpDebugName(int32 Index) const
|
||
{
|
||
return Ops.IsValidIndex(Index) ? Ops[Index].Op->DebugName() : TEXT("(none)");
|
||
}
|
||
|
||
/**
|
||
* Validate the stack's channel dependency DAG without evaluating a voxel.
|
||
*
|
||
* Rule: each channel has a current version. A channel is unavailable until an earlier op
|
||
* writes it. The one deliberate exception is a root `FieldSource` that reads and writes the
|
||
* same channel as an identity fold (for example `min(FLT_MAX, Sdf)`); it may consume that
|
||
* channel's `FVoxelOpSample` initial identity while publishing the first real version. An op
|
||
* may read only the version visible on entry; a read/write op consumes that version and
|
||
* publishes the next one. A write-only op is a replacement/producer, so it is legal only as
|
||
* the first producer of that channel; allowing a second write-only producer would silently
|
||
* clobber the earlier field. An additive op must read every channel it writes, because an
|
||
* additive delta cannot introduce or replace a channel. All producer-to-consumer edges
|
||
* therefore point forward in the list, and the list is a legal topological ordering of the
|
||
* channel DAG. A later read deliberately sees the writer's post-op version; a consumer that
|
||
* needs a pre-write value must be placed before that writer. The declarations do not invent
|
||
* dependencies from names, roles, or Eval side effects.
|
||
*
|
||
* This is assembly/diagnostic work only. It must not be called from Eval or any voxel loop.
|
||
* Returns false and optionally describes the first violation.
|
||
*/
|
||
VOXELFORGE_API bool ValidateChannelOrder(FString* OutError = nullptr) const;
|
||
|
||
/**
|
||
* 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 → [posts spécifiques] → seal de limite XY.
|
||
*
|
||
* ⚠️ 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).
|
||
* Le seal XY reste toujours présent ; son rayon/thickness viennent de Ctx
|
||
* au moment de PrepareChunk (0 = monde non borné).
|
||
*/
|
||
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
|
||
float SealThickness, float BaseDensity, float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
bool bAppendEdgeSeal = true);
|
||
|
||
private:
|
||
TArray<FOpEntry> Ops;
|
||
FVoxelOpContext PreparedContext;
|
||
bool bHasPreparedContext = false;
|
||
EVoxelOpFusedEvaluator FusedEvaluator = EVoxelOpFusedEvaluator::None;
|
||
};
|
||
|
||
//=============================================================================
|
||
// FABRIQUES / FACTORIES
|
||
//=============================================================================
|
||
|
||
namespace VoxelDensityOps
|
||
{
|
||
/** Query the concrete op declaration used by the offline recipe roller. */
|
||
VOXELFORGE_API bool GetStrateOpContract(EVoxelStrateOpClass OpClass,
|
||
FVoxelStrateOpContract& OutContract);
|
||
|
||
/** 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 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert.
|
||
* `ClassifyBox` → **AllAir**, ce qu'aucune source n'avait encore su rendre — c'est ce qui rend
|
||
* une strate d'îles flottantes (surtout vide) sautable là où aucune île n'arrive. Racine de
|
||
* FloatingIslands. */
|
||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
|
||
|
||
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
|
||
* Écrit le canal SDF uniquement. Chaque nœud hors origine choisit un parent vers l'origine;
|
||
* les arêtes canoniques (nœud inférieur + axe) testent localement les deux parents. La source
|
||
* met en cache les décisions de la fenêtre {-1,0}³ par cellule; les chunks adjacents voient
|
||
* donc exactement le même graphe, sans collect global ni couture. */
|
||
/** The source answers only for its own SDF field. Its box query publishes an SDF interval;
|
||
* each later converter or modifier consumes that interval through the generic fold. It does
|
||
* not know, and must not answer for, any downstream operator. */
|
||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
|
||
int32 Seed);
|
||
|
||
/** 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.
|
||
* @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original
|
||
* écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où
|
||
* `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`,
|
||
* donc ce paramètre est une vraie différence, pas une précaution. */
|
||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity,
|
||
float MinDivisor = 0.0f);
|
||
|
||
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
|
||
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
|
||
* étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */
|
||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfFill(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 supremum prouvé de FBM (1.5, not the nominal parameter label). 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);
|
||
|
||
/**
|
||
* SurfaceWorld, COMPLET : colonne (sol + voûte) → densité, overhang 3D, post structurel, et le
|
||
* mélange de biomes quand `PerBiomeParams` est non vide.
|
||
*
|
||
* @param PerBiomeParams vide ⇒ pas de biomes, chemin d'origine strictement inchangé. Non vide
|
||
* ⇒ une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte
|
||
* sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`).
|
||
* @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes
|
||
* indices que `PerBiomeParams`. `nullptr` avec des params non vides ⇒
|
||
* biome 0 partout (dégradation sûre, pas un crash).
|
||
*/
|
||
VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
|
||
int32 Seed, float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
|
||
TArray<FSurfaceGenerationParams>(),
|
||
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr,
|
||
bool bAppendStructuralPosts = true);
|
||
|
||
/**
|
||
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
||
* SlabVoidSource → GridColumnMod → [structural post ×4]
|
||
*
|
||
* Deux des huit archétypes tiennent dans un opérateur (OPSTACK-PLAN §4) : leur différence est
|
||
* 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,
|
||
bool bAppendStructuralPosts = true);
|
||
|
||
/**
|
||
* VerticalShafts — 11 ops, dont les connecteurs air/sol et les quatre structural posts :
|
||
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge
|
||
* → ShaftConnectorAir → ShaftConnectorFloor → [structural post ×4]
|
||
*
|
||
* Trois de ces ops sont ceux de Maze (`§2.5`), avec une source différente et d'autres réglages
|
||
* (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
||
*/
|
||
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
|
||
int32 Seed, float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
bool bAppendStructuralPosts = true);
|
||
|
||
/**
|
||
* TunnelNetwork — **COMPLET** :
|
||
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
|
||
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
|
||
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [spine, boundary, passage, graph-air, XY seal]
|
||
*
|
||
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
|
||
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
|
||
* ONZE des douze modificateurs y lisent leurs champs. La rugosité (4b) lit les params de la
|
||
* STRATE, parce que dans l'original elle précède la déclaration du shadow.
|
||
*
|
||
* ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit
|
||
* pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE
|
||
* §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est
|
||
* d'avoir UNE définition de chaque idée.
|
||
*/
|
||
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
|
||
const FStrateGenerationParams& P,
|
||
int32 Seed, float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
bool bAppendStructuralPosts = true);
|
||
|
||
/**
|
||
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle
|
||
* de `FRoomGraphSource` évaluée sur ce thread. **Tests uniquement. N'entre dans aucune décision
|
||
* de génération.**
|
||
*
|
||
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
|
||
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une
|
||
* DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour où
|
||
* on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose
|
||
* donc ce que l'opérateur a réellement calculé.
|
||
*
|
||
* `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout ⇒ `Identity`).
|
||
* `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en
|
||
* avait aucune » — deux zéros de sens opposé.
|
||
*
|
||
* Reads back what the operator actually computed, rather than letting the test re-derive the
|
||
* criterion: a second copy would drift and would lie on the day it was believed.
|
||
*/
|
||
struct FRoomBoxDiagnostic
|
||
{
|
||
int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0;
|
||
int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0;
|
||
|
||
/** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte
|
||
* est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt
|
||
* qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de
|
||
* resserrement autour du mauvais terme. */
|
||
int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0;
|
||
float WarpDilation = 0.0f;
|
||
|
||
/** Conservative candidate for the world-space tunnel-core post that runs after EvalMC.
|
||
* This is derived from the final post's own world-chain bounds, not from the warped SDF
|
||
* source's broader threshold hit count. */
|
||
bool bMayHaveTunnelCoreAir = false;
|
||
bool bMayHaveTunnelSupportFloor = false;
|
||
bool bMayHaveTunnelCoreTail = false;
|
||
uint32 ExactTailQueries = 0;
|
||
uint32 ExactTailEvaluated = 0;
|
||
uint64 ExactTailCycles = 0;
|
||
uint64 PropagateCycles = 0;
|
||
uint64 ExactPrimitiveCycles = 0;
|
||
uint64 CacheWindowCycles = 0;
|
||
int32 NumRoomFloorJoins = 0;
|
||
};
|
||
|
||
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
|
||
|
||
/** Current worker-local room-graph cache footprint; profiling/diagnostic use only. */
|
||
VOXELFORGE_API void ReportWorkerRoomGraphCacheFootprint();
|
||
|
||
/** Current process-wide immutable room-graph proof-cache footprint; diagnostic use only. */
|
||
VOXELFORGE_API void ReportSharedRoomGraphCacheFootprint();
|
||
|
||
/** Drop completed proof caches from older manager lifetimes, or all of them when zero. */
|
||
VOXELFORGE_API void TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId = 0);
|
||
|
||
/**
|
||
* FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** :
|
||
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4]
|
||
*
|
||
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
|
||
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
|
||
* `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans
|
||
* une ligne de changement (4ᵉ archétype). Seul le blob d'île est nouveau.
|
||
*
|
||
* The stack that runs backwards: void source + fill instead of rock source + carve, using the
|
||
* SAME operators with the opposite sign.
|
||
*/
|
||
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
|
||
int32 Seed, float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
bool bAppendStructuralPosts = true);
|
||
|
||
/**
|
||
* 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,
|
||
bool bAppendStructuralPosts = true);
|
||
}
|
||
|
||
/** Materialise an offline recipe. Structural posts are appended by default; lateral region cores
|
||
* deliberately pass false so the parent stack can append the global posts exactly once. */
|
||
VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe,
|
||
const FVoxelStrateArchetypeParams& Params,
|
||
int32 Seed, float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
FVoxelOpStack& OutStack,
|
||
FVoxelOpContext& OutContext,
|
||
FString* OutError = nullptr,
|
||
bool bAppendStructuralPosts = true);
|
||
|
||
/** Build the region combiner stack. Creative cores are independent; structural posts are global. */
|
||
#if WITH_EDITOR
|
||
VOXELFORGE_API bool VF_BuildStrateRegionStack(
|
||
const FVoxelStrateRegionManifest& Manifest,
|
||
float SpineRadius,
|
||
const UVoxelStrateManager* StrateManager,
|
||
FVoxelOpStack& OutStack,
|
||
FVoxelOpContext& OutContext,
|
||
FString* OutError = nullptr);
|
||
#endif
|