feat: Phase 1 — Maze ported to the operator stack, OFF the hot path
Maze decomposes into seven ops with no contortion:
ConstantRockSource -> LatticeCorridorSource -> SdfRoughnessMod -> SdfCarve
-> OriginSpine -> BoundarySeal -> PassageCarve
That is the answer to Phase 1's actual question (OPSTACK-PLAN section 4's
stop-trigger: "does the source/modifier split fall out naturally?"). It does.
Three of those ops are already shared: ConstantRockSource is the first line of
TunnelNetwork, Maze AND VerticalShafts; SdfCarve is the same six lines in all
three; the structural post is identical across all six density functions.
GetDensityAt and ClassifyTile are NOT touched. The archetype switch is still the
only path feeding the game, so nothing in a running world can change. The port
is validated instead by VoxelForge.OpStack.MazeEquivalence, which compares the
stack against GetMazeDensity over 20k points, re-checks purity across worker
threads, and brute-forces every box verdict the stack emits.
Two contract decisions, delegated and taken:
1. Eval is now two-channel (FVoxelOpSample { Density, Sdf }). Maze forces it:
its roughness perturbs the SDF, not the density, and on density the same
noise scales with the local gradient and is a visibly different effect. It is
also what lets two different sources SmoothMin together later, which is the
difference between a composed idea belonging somewhere and being punched into
it.
2. The stack's density channel is INTERNAL convention (positive = solid),
negated once by the caller. This REVERSES what the header said yesterday.
Every archetype body is already written that way, so each port becomes a
literal transcription instead of a sign-flip of every line -- on the plugin's
documented #1 source of confusion. The SDF channel keeps standard SDF
convention, so min() means opposite things on the two channels; the header
says so loudly.
Also extracts spine/seal/passage from VoxelGenerator.cpp into
Public/VoxelDensityPrimitives.h so the generator and the ops share ONE copy of
three world invariants. Forwarders keep the local names, so not one of the ~20
call sites changes; bodies are byte-identical.
One thing found while writing the seal's ClassifyBox and NOT silently fixed: at
the inner edge of a seal band, 1 - Dist/Thickness can round to exactly 0.0f, so
SealFactor*BaseDensity is 0, internal density lands on 0, and the mesher counts
that as AIR. Claiming AllSolid there would be a hole. The new op keeps a 1-voxel
safety margin before it forces. Today's ClassifyTile has no such margin -- the
window is hairline and needs the archetype to produce air at exactly that z, but
it is real. Reported rather than patched, since Phase 1 does not touch that path.
UNVERIFIED: not compiled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// VoxelDensityOpStack.h
|
||||
// La PILE : un conteneur ordonné d'opérateurs, plus les fabriques d'opérateurs concrets.
|
||||
// The STACK: an ordered container of operators, plus the concrete-operator factories.
|
||||
//
|
||||
// ⚠️ RIEN ICI N'ALIMENTE LE JEU. `UVoxelGenerator::GetDensityAt` et `ClassifyTile` ne sont pas
|
||||
// touchés ; le `switch` par archétype reste le seul chemin de production. Cette pile est construite
|
||||
// et exercée UNIQUEMENT par le test `VoxelForge.OpStack.MazeEquivalence`, qui la compare point par
|
||||
// point à `GetMazeDensity`. Le branchement attend un build vert (OPSTACK-PLAN §4, Phase 1, point 3).
|
||||
//
|
||||
// NOTHING HERE FEEDS THE GAME. GetDensityAt and ClassifyTile are untouched; the archetype switch is
|
||||
// still the only production path. This stack is built and exercised only by the equivalence test.
|
||||
//
|
||||
// POURQUOI CETTE FORME / WHY THIS SHAPE
|
||||
// La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais
|
||||
// **« est-ce que la séparation source / modifier tombe naturellement du code existant ? »**
|
||||
// (OPSTACK-PLAN §4, le déclencheur d'arrêt). En portant Maze hors du chemin chaud et en le
|
||||
// comparant à l'original, cette question reçoit une réponse MESURÉE plutôt qu'une opinion.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelDensityOp.h"
|
||||
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
||||
|
||||
class UVoxelStrateManager;
|
||||
|
||||
/**
|
||||
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
|
||||
*
|
||||
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
|
||||
* l'ordre spine → seal → passage est garanti par cette fonction, pas par l'auteur. Un auteur ne
|
||||
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
|
||||
* possible, les seals doivent tenir, les passages doivent percer).
|
||||
*
|
||||
* PROPRIÉTÉ (threading) : la pile est LUE par les workers. Les opérateurs concrets qui ont besoin
|
||||
* d'un cache par cellule/chunk le tiennent en `thread_local` à l'intérieur de leur `Eval`, comme le
|
||||
* fait déjà chaque fonction d'archétype. En Phase 3, quand les opérateurs deviendront des assets
|
||||
* partagés, il faudra un objet d'état PAR WORKER — noté ici pour que ça ne surprenne personne.
|
||||
*
|
||||
* THREADING: the stack is READ by workers. Concrete ops that need a per-cell/per-chunk cache keep it
|
||||
* thread_local inside Eval, exactly as every archetype function already does. Phase 3 (ops as shared
|
||||
* assets) will need a per-worker state object — flagged here so it is not a surprise.
|
||||
*/
|
||||
class VOXELFORGE_API FVoxelOpStack
|
||||
{
|
||||
public:
|
||||
void Add(TUniquePtr<IVoxelDensityOp> Op) { Ops.Add(MoveTemp(Op)); }
|
||||
|
||||
int32 Num() const { return Ops.Num(); }
|
||||
|
||||
/** Hoist chunk-constant work for every op. Une fois par chunk et par worker.
|
||||
* Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un
|
||||
* mensonge utile qui finirait par masquer une course. */
|
||||
void PrepareChunk(const FVoxelOpContext& Ctx)
|
||||
{
|
||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->PrepareChunk(Ctx); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Évalue la pile complète en un point. Rend la densité en convention INTERNE
|
||||
* (positif = solide) — l'appelant négate UNE FOIS pour le marching cubes.
|
||||
*
|
||||
* Returns INTERNAL-convention density (positive = solid). The caller negates once for MC.
|
||||
*/
|
||||
float EvalInternal(float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
FVoxelOpSample S;
|
||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); }
|
||||
return S.Density;
|
||||
}
|
||||
|
||||
/** Le même, négaté pour le mesher (négatif = solide). */
|
||||
float EvalMC(float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
return -EvalInternal(WorldX, WorldY, WorldZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
|
||||
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
|
||||
* opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui.
|
||||
*/
|
||||
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops)
|
||||
{
|
||||
VF_FoldOp(H, *Op, VoxelBox, Ctx);
|
||||
if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver
|
||||
}
|
||||
return H.Resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
|
||||
* spine (0,0) → seal de frontière → carve de passage.
|
||||
*
|
||||
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
|
||||
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
|
||||
* portées et que la question de convention MC-vs-interne sera tranchée pour de bon
|
||||
* (OPSTACK-DECOMPOSITION §10.2). Tant que la pile n'alimente pas le jeu, c'est sans effet.
|
||||
*
|
||||
* The diff layer is NOT here: it lives in GetDensityAt, AFTER the MC negate, with disturbances.
|
||||
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
|
||||
*
|
||||
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
|
||||
*/
|
||||
void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
|
||||
float SealThickness, float BaseDensity, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
private:
|
||||
TArray<TUniquePtr<IVoxelDensityOp>> Ops;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelDensityOps
|
||||
{
|
||||
/** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit.
|
||||
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
|
||||
|
||||
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
|
||||
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
|
||||
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
|
||||
* COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */
|
||||
* `ExtraReach` = tout ce qui peut ÉLARGIR la portée du couloir en aval (amplitude de rugosité +
|
||||
* rayon de blend du carve). La source répond pour la paire source+conversion dans
|
||||
* `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle
|
||||
* doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un
|
||||
* trou. / The source answers for the source+conversion pair, so it must know the downstream
|
||||
* margin: an Identity that is wrong is a hole. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
|
||||
int32 Seed, float ExtraReach);
|
||||
|
||||
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
|
||||
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
|
||||
* un gain d'authoring gratuit, et §2.6 autorise explicitement le re-tune. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfRoughnessMod(float Strength, float Frequency,
|
||||
int32 BaseOctaves, float ApplyWithin);
|
||||
|
||||
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
|
||||
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
|
||||
|
||||
/**
|
||||
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
|
||||
* ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]
|
||||
*
|
||||
* C'est le test de la Phase 1 : si Maze ne se décompose pas ainsi, l'abstraction est mauvaise
|
||||
* pour ce domaine (OPSTACK-PLAN §4, déclencheur d'arrêt).
|
||||
*/
|
||||
VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
}
|
||||
Reference in New Issue
Block a user