feat: Phase 1 step 3 — wire the operator stack into GetDensityAt behind an opt-in
One branch on the density path, as OPSTACK-PLAN section 4 specified, and both systems coexist. - UVoxelStrateDefinition::bUseOperatorStack: the A/B switch section 2.6's acceptance bar needs. Flip it, regenerate, judge on a screenshot. - UVoxelStrateManager::UsesOperatorStackForChunk(): the ported-archetype list, written down in exactly one place. An unported archetype ignores the flag and falls back to the switch, so ticking the box anywhere is harmless today and only Maze changes behaviour. - GetDensityAt: CP_OpStack / CP_UseOpStack are resolved inside the SAME refetch block as the params, so the existing chunk + LayoutVersion key already covers them and there is no new invalidation logic to get wrong. Hot-path cost is one bool test per voxel; the stack is built per chunk, the same cadence as the param refetch. ApplyDisturbances and the diff layer stay outside the stack and run once for both paths, so the tail of the pipeline is unchanged. If UsesOperatorStackForChunk ever returns true for an archetype with no builder, the code clears the flag and falls back to the switch rather than generating an empty stack. An unported world is recoverable; a wrong one is not. UNVERIFIED: not compiled. Likely spots: the `else switch` form, FVoxelOpStack as a thread_local (move-only, reset by move-assigning a temporary), and the new include. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -773,3 +773,41 @@ brute force.
|
|||||||
opt-in, so a Maze strate can be A/B-switched in the editor and judged on a screenshot (§2.6's bar).
|
opt-in, so a Maze strate can be A/B-switched in the editor and judged on a screenshot (§2.6's bar).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-27 — Phase 1 step 3: the stack is WIRED IN, behind a per-strate opt-in.
|
||||||
|
|
||||||
|
**`OPSTACK-PLAN §4` Phase 1 step 3 done.** `GetDensityAt` gains exactly one branch, as the plan
|
||||||
|
specified, and both systems now coexist.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `VoxelStrateDefinition.h` — `bool bUseOperatorStack` (EditAnywhere, "Use Operator Stack
|
||||||
|
(experimental)"). The A/B switch §2.6's acceptance bar needs: flip it, regenerate, judge the
|
||||||
|
screenshot.
|
||||||
|
- `VoxelStrateManager.{h,cpp}` — `UsesOperatorStackForChunk()`. **The ported-archetype list lives
|
||||||
|
here and nowhere else**, so an unported archetype ignores the flag and falls back to the switch.
|
||||||
|
Ticking the box on any strate is therefore harmless today; only `Maze` changes behaviour.
|
||||||
|
- `VoxelGenerator.cpp` — `CP_OpStack` / `CP_UseOpStack` built in the SAME refetch block as the
|
||||||
|
params (so the chunk+`LayoutVersion` key already covers it, no new invalidation logic), plus one
|
||||||
|
`if (CP_UseOpStack)` on the dispatch.
|
||||||
|
|
||||||
|
**Cost on the hot path: one bool test per voxel.** The stack is built per chunk, never per voxel —
|
||||||
|
the same cadence as the existing param refetch. `ApplyDisturbances` and the diff layer are
|
||||||
|
deliberately left OUTSIDE the stack and run once for both paths, exactly as before, so the tail of
|
||||||
|
the pipeline is untouched.
|
||||||
|
|
||||||
|
**Defensive choice worth noting:** if `UsesOperatorStackForChunk` ever returns true for an archetype
|
||||||
|
with no builder, the code clears the flag and falls back to the switch rather than generating an
|
||||||
|
empty stack. A world that is *unported* is recoverable; a world that is *wrong* is not.
|
||||||
|
|
||||||
|
**UNVERIFIED:** not compiled. Likely error spots: the `else switch` form in `GetDensityAt`,
|
||||||
|
`FVoxelOpStack` as a `thread_local` (it is move-only — move-assign from a temporary is used to
|
||||||
|
reset it), and the new include in `VoxelGenerator.cpp`.
|
||||||
|
|
||||||
|
**What to look at in the editor:** set a Maze strate's `bUseOperatorStack`, regenerate, and compare
|
||||||
|
against the same seed with it off. **Pass = recognisably the same maze** — same corridor scale, same
|
||||||
|
connectivity, same feel. That is §2.6's bar, and it is the last thing Phase 1 needs.
|
||||||
|
|
||||||
|
**Next single action:** build, then the visual A/B. After that, Phase 2 — the port order in
|
||||||
|
`OPSTACK-DECOMPOSITION §10.4` starts with FlatPlain + CrystalChamber collapsing into one op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "VoxelBiomeDefinition.h"
|
#include "VoxelBiomeDefinition.h"
|
||||||
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
|
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
|
||||||
#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack
|
#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack
|
||||||
|
#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy
|
// SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy
|
||||||
@@ -493,6 +494,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
|||||||
// The key MUST include the layout version, not just the chunk coord. Symptom without it:
|
// The key MUST include the layout version, not just the chunk coord. Symptom without it:
|
||||||
// "I tweaked the strate asset, regenerated, and one patch kept the old shape."
|
// "I tweaked the strate asset, regenerated, and one patch kept the old shape."
|
||||||
thread_local uint32 CP_Version = 0xFFFFFFFFu;
|
thread_local uint32 CP_Version = 0xFFFFFFFFu;
|
||||||
|
// OPSTACK Phase 1 — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les
|
||||||
|
// params (donc même clé chunk+version, aucune logique d'invalidation en plus). Vide tant que
|
||||||
|
// la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté.
|
||||||
|
thread_local FVoxelOpStack CP_OpStack;
|
||||||
|
thread_local bool CP_UseOpStack = false;
|
||||||
|
|
||||||
const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
|
const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
|
||||||
if (ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
|
if (ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
|
||||||
@@ -523,9 +529,38 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
|||||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break;
|
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break;
|
||||||
}
|
}
|
||||||
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
|
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
|
||||||
|
|
||||||
|
// ── OPSTACK Phase 1 : (re)construire la pile si cette strate l'a demandée. ──
|
||||||
|
// Une seule branche ajoutée au chemin densité, et elle est FROIDE : la construction est
|
||||||
|
// par chunk (comme le refetch de params juste au-dessus), jamais par voxel.
|
||||||
|
CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord);
|
||||||
|
if (CP_UseOpStack)
|
||||||
|
{
|
||||||
|
CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile
|
||||||
|
switch (CP_GenType)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, 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`
|
||||||
|
// plutôt que générer du vide — un monde faux est pire qu'un monde non porté.
|
||||||
|
CP_UseOpStack = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (CP_GenType)
|
// Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention
|
||||||
|
// MC (négatif = solide) comme les fonctions d'archétype, donc les disturbances et la couche
|
||||||
|
// de diff qui suivent ne voient aucune différence.
|
||||||
|
if (CP_UseOpStack)
|
||||||
|
{
|
||||||
|
Result = CP_OpStack.EvalMC(WorldX, WorldY, WorldZ);
|
||||||
|
}
|
||||||
|
else switch (CP_GenType)
|
||||||
{
|
{
|
||||||
case ECaveGeneratorType::FlatPlain:
|
case ECaveGeneratorType::FlatPlain:
|
||||||
case ECaveGeneratorType::CrystalChamber:
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
|||||||
@@ -556,6 +556,26 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto
|
|||||||
return StrateLayout[SlotIdx].Definition->GeneratorType;
|
return StrateLayout[SlotIdx].Definition->GeneratorType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const
|
||||||
|
{
|
||||||
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
||||||
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; }
|
||||||
|
|
||||||
|
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
|
||||||
|
if (!Def->bUseOperatorStack) { return false; }
|
||||||
|
|
||||||
|
// LA LISTE DES ARCHÉTYPES PORTÉS — le seul endroit où elle est écrite. Un archétype non porté
|
||||||
|
// ignore le drapeau et retombe sur le `switch`, pour qu'on puisse cocher la case sur n'importe
|
||||||
|
// quelle strate sans rien casser en attendant son portage.
|
||||||
|
// THE PORTED-ARCHETYPE LIST, written down exactly once. An unported archetype ignores the flag
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const
|
bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const
|
||||||
{
|
{
|
||||||
if (StrateLayout.Num() == 0) return false;
|
if (StrateLayout.Num() == 0) return false;
|
||||||
|
|||||||
@@ -106,6 +106,29 @@ public:
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation")
|
||||||
ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork;
|
ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPERATOR STACK (experimental) — generate this strate through the composable density operator
|
||||||
|
* stack instead of the hardcoded archetype switch. Same world, different machinery.
|
||||||
|
*
|
||||||
|
* This is the A/B switch for `OPSTACK-PLAN §2.6`'s acceptance bar: flip it, regenerate, and
|
||||||
|
* judge on a screenshot that it is recognisably the same place. Both systems coexist
|
||||||
|
* indefinitely — the switch is not going away until every archetype is ported.
|
||||||
|
*
|
||||||
|
* ⚠️ ONLY `Maze` IS PORTED SO FAR. On any other GeneratorType this flag is ignored and the
|
||||||
|
* switch runs as before, so setting it is harmless but does nothing yet.
|
||||||
|
*
|
||||||
|
* ⚠️ Do NOT flip this on a strate mid-session and expect the old and new geometry to agree to
|
||||||
|
* the bit — they differ by ~1-2 ULP with ZERO isosurface crossings, so the shape is identical
|
||||||
|
* but the floats are not (`AUDIT-2026-07.md §C10`). Regenerate the world after changing it
|
||||||
|
* rather than letting old and new tiles sit side by side.
|
||||||
|
*
|
||||||
|
* Pile d'opérateurs (expérimental) : génère cette strate via la pile composable au lieu du
|
||||||
|
* `switch` d'archétype. Seul `Maze` est porté ; ailleurs le drapeau est ignoré.
|
||||||
|
*/
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation",
|
||||||
|
meta = (DisplayName = "Use Operator Stack (experimental)"))
|
||||||
|
bool bUseOperatorStack = false;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type)
|
// TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|||||||
@@ -201,6 +201,16 @@ public:
|
|||||||
*/
|
*/
|
||||||
ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const;
|
ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this chunk's strate opts into the density OPERATOR STACK instead of the hardcoded
|
||||||
|
* archetype switch (`UVoxelStrateDefinition::bUseOperatorStack`).
|
||||||
|
*
|
||||||
|
* Returns false for archetypes that have no port yet, so the flag can be set on any strate
|
||||||
|
* without changing its output until that archetype lands. Only `Maze` is ported today — this
|
||||||
|
* predicate is where that list grows, and it is deliberately the ONLY place it is written down.
|
||||||
|
*/
|
||||||
|
bool UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if this chunk is in the solid-bedrock GAP between two strates (inside the
|
* True if this chunk is in the solid-bedrock GAP between two strates (inside the
|
||||||
* overall stack's Z range but not in any strate slot). Chunks above the top strate
|
* overall stack's Z range but not in any strate slot). Chunks above the top strate
|
||||||
|
|||||||
Reference in New Issue
Block a user