feat(editor): walk a composed candidate in PIE + why rooms read angular

PART B -- the thing Jahni asked for: "any way to test those in game?"
A candidate existed only as measurements and PNGs. Now he can stand in one.

  Select the VoxelWorld actor in PIE -> Live Edit|Composer ->
  ComposerSeed / ComposerCandidateIndex / ComposerTargetStrateIndex /
  bComposerRollStructure -> click Apply Composer Candidate.

The button and the automation tests call the SAME roll wrapper, which was
the load-bearing requirement: a second roll path could drift, and then he
would be judging a different world than the contact sheet describes. A
determinism sample is logged on apply (seed, index, recipe, archetype,
metrics, epoch, eight-point density verdict) so drift is caught the day it
happens rather than in a month.

Hazards handled, since this is the first composer code to touch the live
runtime path: preflight materialisation before any live mutation, generation
and decoration tasks drained, PassagesVersion bumped to invalidate
thread_local caches, reuse of the existing RegenerateAllChunks / Epoch
invalidation rather than a new path, no Initialize() or GeneratePassages()
during apply (the re-entrancy trap), DiffLayer locks untouched, and custom
recipes conservatively force ClassifyTile to Mixed.

Scope is deliberate: a DENSITY-ONLY overlay. Layout height, authored
passages, content, water and biome metadata stay from the existing slot;
RebuildStrates() clears it. A full composed layout replacement needs a safe
rebuild path that does not exist yet.

Editor-only, verified: a Shipping build succeeds with the bridge excluded
(96/96 actions), so §3.1's "offline only, never on a player's machine"
still holds.

⚠️ UNVERIFIED BY DESIGN: a button cannot be tested headlessly and Codex was
told not to claim it works. Jahni presses it and confirms the visual plus
the "determinism=PASS" log line.

PART A -- why rooms read angular, measured not guessed. One of three room
shapes is VoxelSDF::RoundedBox:
  ShapeA = (.8 Rxy, .8 Rxy, .8 Rz)   ShapeR = .25 Rxy
so rounding is 31% of the horizontal half-extent but 78% of the vertical.
Corners are rounded; the broad planar FACES survive. SmoothMin (blend 4)
softens room/tunnel junctions not isolated corners; the cave warp bends
geometry but preserves flat regions; roughness is surface-scale only.
The name is accurate -- "rounded box" is not "blob".

NOT ACTED ON. Jahni corrected his own read: the angular archetype is Maze
(lattice source, rectilinear BY DESIGN and correct), and TunnelNetwork is
"alright-ish". Codex's recommendation is recorded for if he ever wants it:
~5-10% boxes, 10-20% capsules, rest ellipsoids, or a larger ShapeR.

Also closed REVIEW_FINDINGS EVoxelPassageType/Style as WONTFIX, enacting a
2026-08-16 judgement that recommended closure and was left open.

Build clean (Editor + Shipping), 25 succeeded / 0 failed, run by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 22:03:01 +02:00
1 parent 90c40c8d5a
commit d717bc74ff
13 files changed
+1182 -32

No files matched your search

+17
View File
@@ -118,6 +118,15 @@ Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
max edge reach, and decisions must not depend on the stored window.**
The editor composer walk-through preserves this invariant by avoiding a layout rebuild. AVoxelWorld::ApplyComposerCandidate
pauses and drains active generation, installs one temporary override on an existing slot, increments
the manager's layout/passages version, and then reuses RegenerateAllChunks to bump GenerationEpoch.
The slot's Z range and passages therefore remain stable; worker tasks copy the immutable override during
their versioned refetch. A parameter candidate uses the production native stack mapping, while a
structure candidate uses VF_BuildStackFromRecipe; custom recipes conservatively return Mixed from
ClassifyTile so no unproven box bound can skip their mesh. The bridge and its editable properties are
WITH_EDITOR/WITH_EDITORONLY_DATA only and do not exist in a shipping build.
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
@@ -658,6 +667,14 @@ driven by `EditorBrush*` props.
### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE)
- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine +
regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages).
- ApplyComposerCandidate — editor-only PIE walk-through for one offline candidate. Set
ComposerSeed, ComposerCandidateIndex, ComposerTargetStrateIndex (0 = topmost), and
bComposerRollStructure in Live Edit|Composer, then click Apply Composer Candidate. It uses the
shared composer roll, overlays density on the existing slot, pauses/drains workers, bumps the
manager version, and reuses RegenerateAllChunks for the epoch/re-stream. It does not rebuild
passages or change the authoritative Settings->Seed; RebuildStrates removes the overlay. The
action logs recipe, archetype, air/largest-component/walkable/feature metrics, and an eight-point
bit-level density verdict. The button itself still requires owner verification in PIE.
- `ValidateDeterminism` (F2) — one-click §8.4 regression test: samples chunk-boundary points under
two different thread_local cache alignments (left-chunk warm vs right-chunk warm) + a repeat
pass; every delta must be EXACTLY 0. Run it after any hot-path refactor that claims
+11 -2
View File
@@ -413,13 +413,14 @@ Maps depth→strate at runtime; owns passages.
| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. |
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. |
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. |
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize` and by the editor composer override). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so live changes never serve stale data. |
| `GetStrateForChunk` | 466 | Chunk → definition. |
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. |
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
| `SetComposerOverrideForStrate` / `GetComposerOverrideForChunk` | editor-only | Temporary density-only candidate overlay for one existing slot. Copies immutable params/recipe into the worker refetch path, leaves layout/content/passages intact, and bumps `PassagesVersion` to invalidate generator memos. |
| `BuildParamsFromDefinition` (static) | 1399 | Returns the definition's base `GenerationParams` only. Terrain-op assets are passed separately to `BuildChunkCache`, which applies one selected op per room and pre-bakes Column/Pit/Chimney features. |
**`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset`
@@ -462,7 +463,8 @@ selection → `FStrateGenerationParams::Lerp` or reflected native-family blend
field range jitter (or the season-zero ±25% own-magnitude bootstrap for near-zero ranges) →
editor-reflection clamps → ordered-pair repair. Bool
`bTunnelsFlowTowardOrigin` inherits from the dominant parent; integer/enum SNAP fields are not
jittered. This API is not called by runtime generation. The settings audit found **one unique path**,
jittered. Normal runtime generation never calls this API; the editor-only AVoxelWorld bridge does so
only from its PIE button. The settings audit found **one unique path**,
`/Game/VoxelForge/DA_Strate3`, which explains the old one-vector corpus; the project assets were
never in that pool.
@@ -475,6 +477,13 @@ it. `RequiredResources` / `ProvidedResources` close the room-state hole that cha
could not express. `VoxelForgeComposerStructureRollTest` rolls 64 candidates, measures them through
the offline sampler, and brute-forces every uniform box verdict from the novel stack itself.
Public/VoxelWorld.h + Private/VoxelWorld.cpp — ApplyComposerCandidate exposes the four
Live Edit|Composer properties and the CallInEditor button. It preflights the candidate, pauses
generation before installing the manager override, and calls the existing epoch-aware full reset.
The post-apply check compares eight densities against the shared native/recipe stack where the live
world has no extra diff, disturbance, or biome context. It reports SKIPPED rather than comparing
unlike fields.
**`Public/VoxelStratePreview.h` + `Private/VoxelStratePreview.cpp`** — editor/automation-only PNG
renderer and self-contained contact sheet. It consumes `FVoxelStrateSampleGrid` from the measurement
pass, emits deterministic centre-Y XZ and data-selected XY slices via `IImageWrapper`, and shows filled-cell
+24 -1
View File
@@ -84,7 +84,10 @@ a model over the candidates at zero runtime cost. **And Jahni reviews and vetoes
publishes** — the system does the work, he keeps the veto.
**What ships is small:** the season's seed plus the chosen strates' parameter vectors. Clients generate
terrain from those, deterministically, exactly as today. **No runtime composition anywhere.**
terrain from those, deterministically, exactly as today. **No composition is shipped or run in the
normal game path.** The editor-only PIE inspection button described below is a deliberate debugging
exception: it overlays one already-built slot so the owner can walk a measured candidate; it is not
cooked, does not compose a season, and is cleared by RebuildStrates.
### 3.2 Structure: how a stack gets assembled
@@ -194,6 +197,26 @@ through the offline Tier 2 sampler; no runtime generation path calls this API. T
test took **76.466 s**. The complete `VoxelForge` namespace then passed **24/24 tests (0 failed,
0 not run)** in **339.797 s**.
### 3.2a Editor walk-through hand-off (not a runtime composer)
AVoxelWorld::ApplyComposerCandidate is the one editor-only bridge from this offline API to a live
PIE world. It calls VF_RollStrateCandidate(Corpus, ComposerSeed, ComposerCandidateIndex,
bComposerRollStructure). That wrapper delegates to the exact parameter/recipe calls exercised by
the offline composer tests, then installs the result
as a temporary density override on ComposerTargetStrateIndex. The layout slot's authored definition,
height, content, and generated passages are intentionally unchanged; this avoids calling
GeneratePassages while Initialize is half-built. The action pauses/drains generation, bumps the
manager layout version, and reuses RegenerateAllChunks so GenerationEpoch invalidates stale mesh
results and the normal streamer rebuilds the world.
The Details-panel properties are ComposerSeed, ComposerCandidateIndex, ComposerTargetStrateIndex,
and bComposerRollStructure, under Live Edit|Composer. The button is Apply Composer Candidate; it
is usable only during PIE. RebuildStrates clears the temporary override. A post-apply eight-point
density check compares the live generator with the same prepared candidate stack bit-for-bit when
no diff edits, disturbance post, or surface-biome context makes the standalone oracle incomplete.
The log line carries the recipe/archetype and the coarse metrics so it can be matched to a
contact-sheet row. This hand-off is editor-only and is not part of the shipped composer contract.
### 3.3 Parameters: the range problem
**Verified facts, not remembered:** `FStrateGenerationParams` has **73 scalar fields** (+1 bool) — not
+4 -1
View File
@@ -90,7 +90,10 @@ Legend: ✅ verified against code · ◻️ checklist box.
like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)*
- [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` +
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
- [ ] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both in
- [x] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — ✅ **CLOSED 2026-09-04 as WONTFIX**, enacting
the 2026-08-16 judgement below, which recommended closure and was then left open by oversight. No
code change; the two enums stay.
Two overlapping passage-shape enums, both in
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
**JUDGED 2026-08-16 — LEAVE THEM. Recommendation: close this item rather than act on it.** They
read as duplicates from the index and are not: `EVoxelPassageType` (`VoxelStrateTypes.h` ~42) is
@@ -816,8 +816,18 @@ bool FVoxelForgeComposerParameterRollTest::RunTest(const FString& Parameters)
const double RollAndMeasureStartSeconds = FPlatformTime::Seconds();
for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex)
{
#if WITH_EDITOR
// The PIE hand-off and this test deliberately cross the same seam. The wrapper only
// packages the exact parameter-roll call below; it must not grow a second RNG path.
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
Corpus, AuthoredSettings->Seed, CandidateIndex, false);
const FVoxelStrateRollInfo& Roll = Candidate.ParameterRoll;
TestTrue(FString::Printf(TEXT("candidate %d hand-off roll is valid"), CandidateIndex),
Candidate.bValid && Roll.bValid);
#else
const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailed(
Corpus, AuthoredSettings->Seed, CandidateIndex);
#endif
TestTrue(FString::Printf(TEXT("candidate %d roll is valid"), CandidateIndex), Roll.bValid);
TotalBootstrapJitterFields += Roll.BootstrapJitterFieldCount;
@@ -367,8 +367,17 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters)
const double RollAndMeasureStartSeconds = FPlatformTime::Seconds();
for (int32 CandidateIndex = 0; CandidateIndex < NumCandidates; ++CandidateIndex)
{
#if WITH_EDITOR
// Keep the editor hand-off and the structure test on the same recipe/block roll seam.
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
Corpus, AuthoredSettings->Seed, CandidateIndex, true);
TestTrue(FString::Printf(TEXT("candidate %d hand-off roll is valid"), CandidateIndex),
Candidate.bValid);
const FVoxelOpStackRecipe Recipe = Candidate.Recipe;
#else
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(
AuthoredSettings->Seed, CandidateIndex);
#endif
const FVoxelOpStackRecipe RepeatRecipe = VF_RollStrateStructure(
AuthoredSettings->Seed, CandidateIndex);
TestTrue(FString::Printf(TEXT("candidate %d recipe reroll is identical"), CandidateIndex),
@@ -404,11 +413,24 @@ bool FVoxelForgeComposerStructureRollTest::RunTest(const FString& Parameters)
FVoxelStrateArchetypeParams CandidateParams;
bool bParameterRollsValid = true;
double CandidateDistanceSquared = 0.0;
for (const FBlockSpec& Spec : GBlockSpecs)
for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(GBlockSpecs); ++BlockIndex)
{
#if WITH_EDITOR
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
if (!Candidate.StructureBlockRolls.IsValidIndex(BlockIndex))
{
bParameterRollsValid = false;
AddError(FString::Printf(TEXT("candidate %d missing parameter block %d"),
CandidateIndex, static_cast<int32>(Spec.Block)));
continue;
}
const FVoxelStrateRollInfo& BlockRoll = Candidate.StructureBlockRolls[BlockIndex];
#else
const FBlockSpec& Spec = GBlockSpecs[BlockIndex];
const FVoxelStrateRollInfo BlockRoll = VF_RollStrateParamsDetailedForArchetype(
Corpus, Spec.Archetype,
AuthoredSettings->Seed ^ static_cast<int32>(Spec.SeedSalt), CandidateIndex);
#endif
if (!BlockRoll.bValid)
{
bParameterRollsValid = false;
@@ -18,6 +18,10 @@
#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it
#include "VoxelStats.h"
#if WITH_EDITOR
#include "VoxelStrateComposer.h"
#endif
#include <atomic>
//=============================================================================
@@ -589,6 +593,63 @@ namespace
}
}
#if WITH_EDITOR
bool VF_BuildNativeStrateStackForCandidate(
ECaveGeneratorType Archetype,
const FVoxelStrateArchetypeParams& Params,
int32 InSeed,
float SpineRadius,
float InWorldRadiusVoxels,
float InEdgeSealThickness,
const UVoxelStrateManager* StrateManager,
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext)
{
FVoxelStackParamRefs Refs;
switch (Archetype)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
Refs.Slab = &Params.SlabParams;
break;
case ECaveGeneratorType::Maze:
Refs.Maze = &Params.MazeParams;
break;
case ECaveGeneratorType::SurfaceWorld:
// The live surface path can additionally carry biome-specific parameter arrays and a
// generator-owned biome field. The editor sanity check deliberately handles the exact
// no-biome/native case only; the caller reports a biome target as unavailable rather than
// comparing unlike fields.
Refs.Surface = &Params.SurfaceParams;
break;
case ECaveGeneratorType::VerticalShafts:
Refs.Vert = &Params.VerticalShaftParams;
break;
case ECaveGeneratorType::FloatingIslands:
Refs.Float = &Params.FloatingIslandParams;
break;
case ECaveGeneratorType::Underwater:
case ECaveGeneratorType::TunnelNetwork:
Refs.Tunnel = &Params.TunnelNetworkParams;
break;
default:
return false;
}
OutContext = FVoxelOpContext();
OutContext.Seed = static_cast<uint32>(InSeed);
OutContext.LayoutVersion = StrateManager != nullptr ? StrateManager->GetLayoutVersion() : 0;
OutContext.WorldRadiusVoxels = InWorldRadiusVoxels;
OutContext.EdgeSealThickness = InEdgeSealThickness;
if (!VF_BuildOpStackForChunk(Archetype, Refs, InSeed, SpineRadius, StrateManager,
OutStack, OutContext))
{
return false;
}
return true;
}
#endif
UVoxelGenerator::UVoxelGenerator()
: DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1)
{
@@ -675,6 +736,15 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// 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;
#if WITH_EDITOR
// Editor-only composer override. The candidate recipe is copied into this worker-local
// cache on the same versioned refetch as the native params; no worker reads mutable editor
// state while the world is applying a new candidate.
thread_local bool CP_UseComposerRecipe = false;
thread_local int32 CP_ComposerSeed = 0;
thread_local FVoxelStrateArchetypeParams CP_ComposerParams;
thread_local FVoxelOpStackRecipe CP_ComposerRecipe;
#endif
const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
@@ -691,6 +761,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
#if WITH_EDITOR
CP_UseComposerRecipe = false;
ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork;
bool bComposerUseRecipe = false;
if (StrateManager->GetComposerOverrideForChunk(
ChunkCoord, CP_ComposerSeed, ComposerArchetype, CP_ComposerParams,
bComposerUseRecipe, CP_ComposerRecipe))
{
CP_GenType = ComposerArchetype;
CP_UseComposerRecipe = bComposerUseRecipe;
}
#endif
switch (CP_GenType)
{
case ECaveGeneratorType::FlatPlain:
@@ -725,6 +807,35 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
if (CP_UseOpStack)
{
CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile
#if WITH_EDITOR
if (CP_UseComposerRecipe)
{
FVoxelOpContext ComposerContext;
if (VF_BuildStackFromRecipe(
CP_ComposerRecipe, CP_ComposerParams, Seed,
OriginSpineRadius, StrateManager, CP_OpStack, ComposerContext, nullptr))
{
ComposerContext.ChunkCoord = ChunkCoord;
ComposerContext.Step = 1;
ComposerContext.LayoutVersion = LayoutVersion;
// The recipe builder supplies the candidate's vertical seal to the
// structural post. The XY edge seal is global and uses live settings.
ComposerContext.WorldRadiusVoxels = WorldRadiusVoxels;
ComposerContext.EdgeSealThickness = EdgeSealThickness;
CP_OpStack.PrepareChunk(ComposerContext);
}
else
{
// The editor action validates before installing the override. Keep this
// fallback safe if a future recipe changes underneath PIE: the native
// candidate params remain available instead of yielding an uninitialised
// stack or a hole.
CP_UseOpStack = false;
}
}
else
#endif
{
FVoxelOpContext OpCtx;
OpCtx.ChunkCoord = ChunkCoord;
OpCtx.Seed = (uint32)Seed;
@@ -773,6 +884,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// which is the point: the first op that hoists real per-chunk work must find the
// call already here.
if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); }
}
}
}
@@ -2784,6 +2896,16 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (!StrateManager) return EVoxelTileClass::Mixed;
#if WITH_EDITOR
// A custom recipe is deliberately not fed to the native ClassifyBox proof yet. Returning
// Mixed keeps the live walk-through hole-safe: GenerateMesh samples the exact recipe stack,
// while no optimistic AllAir/AllSolid result can skip a candidate tile.
if (StrateManager->HasComposerRecipeOverride())
{
return EVoxelTileClass::Mixed;
}
#endif
bool bCanSolid = true; // "tout le treillis est solide" encore prouvable
bool bCanAir = true; // "tout le treillis est air" encore prouvable
@@ -2270,6 +2270,131 @@ FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index)
return Recipe;
}
#if WITH_EDITOR
namespace
{
struct FComposerStructureBlockSpec
{
EVoxelStrateParamBlock Block;
ECaveGeneratorType Archetype;
uint32 SeedSalt;
};
// Keep these salts in the composer implementation so the editor action and the offline
// structure test cannot silently acquire different block streams.
static const FComposerStructureBlockSpec GComposerStructureBlocks[] =
{
{ EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u },
{ EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u },
{ EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u },
{ EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u },
{ EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u },
{ EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu },
};
void VF_CopyComposerBlock(FVoxelStrateArchetypeParams& OutParams,
const FVoxelStrateRollInfo& Roll,
ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams;
break;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
OutParams.SlabParams = Roll.ArchetypeParams.SlabParams;
break;
case ECaveGeneratorType::Maze:
OutParams.MazeParams = Roll.ArchetypeParams.MazeParams;
break;
case ECaveGeneratorType::SurfaceWorld:
OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams;
break;
case ECaveGeneratorType::VerticalShafts:
OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams;
break;
case ECaveGeneratorType::FloatingIslands:
OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams;
break;
default:
break;
}
}
ECaveGeneratorType VF_ComposerArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe)
{
switch (Recipe.StructuralParamBlock)
{
case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork;
case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain;
case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze;
case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld;
case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts;
case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands;
default: return ECaveGeneratorType::TunnelNetwork;
}
}
}
FVoxelStrateComposerCandidate VF_RollStrateCandidate(
const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index, bool bRollStructure)
{
FVoxelStrateComposerCandidate Candidate;
Candidate.Seed = Seed;
Candidate.Index = Index;
Candidate.bStructureRoll = bRollStructure;
if (!bRollStructure)
{
// This is the exact Tier 4a call used by the parameter-roll measurement test.
Candidate.ParameterRoll = VF_RollStrateParamsDetailed(Corpus, Seed, Index);
if (!Candidate.ParameterRoll.bValid)
{
Candidate.FailureReason = Candidate.ParameterRoll.FailureReason;
return Candidate;
}
Candidate.Archetype = Candidate.ParameterRoll.Archetype;
Candidate.ArchetypeParams = Candidate.ParameterRoll.ArchetypeParams;
Candidate.bValid = true;
return Candidate;
}
// This is the exact Tier 4b structure call plus the six independent native-family rolls used
// to materialise every structure candidate in the offline test.
Candidate.Recipe = VF_RollStrateStructure(Seed, Index);
Candidate.Archetype = VF_ComposerArchetypeForRecipe(Candidate.Recipe);
Candidate.StructureBlockRolls.Reserve(UE_ARRAY_COUNT(GComposerStructureBlocks));
bool bAllBlocksValid = true;
for (const FComposerStructureBlockSpec& Spec : GComposerStructureBlocks)
{
const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailedForArchetype(
Corpus, Spec.Archetype, Seed ^ static_cast<int32>(Spec.SeedSalt), Index);
Candidate.StructureBlockRolls.Add(Roll);
if (!Roll.bValid)
{
bAllBlocksValid = false;
if (Candidate.FailureReason.IsEmpty())
{
Candidate.FailureReason = FString::Printf(
TEXT("parameter block %d (%s) failed: %s"),
static_cast<int32>(Spec.Block),
VF_GetStrateArchetypeName(Spec.Archetype),
*Roll.FailureReason);
}
continue;
}
VF_CopyComposerBlock(Candidate.ArchetypeParams, Roll, Spec.Archetype);
}
Candidate.bValid = bAllBlocksValid;
return Candidate;
}
#endif
FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe)
{
FString Modifiers;
+303 -24
View File
@@ -9,6 +9,36 @@
#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo
#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten)
#if WITH_EDITOR
#include "VoxelStrateComposer.h"
struct FVoxelStrateComposerSlotOverride
{
int32 CandidateSeed = 0;
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
FVoxelStrateArchetypeParams Params;
bool bUseRecipe = false;
FVoxelOpStackRecipe Recipe;
};
static void VF_SetComposerRuntimeBounds(
FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ)
{
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
}
#endif
// Fractal Brownian Motion (layered Perlin) along a 1D parameter, ~[-1,1].
// Independent octaves at increasing frequency / decreasing amplitude give an organic,
// non-repeating wander — the key to a worm that SQUIRMS instead of zig-zagging (1D) or
@@ -35,6 +65,11 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
}
StrateLayout.Empty();
#if WITH_EDITOR
// A full layout rebuild discards any temporary walk-through candidate. The world calls this
// under FScopedGenerationPause, so no worker can observe the map while it is being cleared.
ComposerOverrides.Reset();
#endif
const int32 TotalStrates = Settings->TotalStrates;
@@ -240,6 +275,125 @@ void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
GeneratePassages();
}
#if WITH_EDITOR
const FVoxelStrateComposerSlotOverride* UVoxelStrateManager::FindComposerOverride(
int32 StrateIndex) const
{
const TSharedPtr<FVoxelStrateComposerSlotOverride>* Found = ComposerOverrides.Find(StrateIndex);
return Found != nullptr ? Found->Get() : nullptr;
}
bool UVoxelStrateManager::SetComposerOverrideForStrate(
int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype,
const FVoxelStrateArchetypeParams& Params, bool bUseRecipe,
const FVoxelOpStackRecipe* Recipe, FString& OutError)
{
OutError.Reset();
const FStrateSlot* TargetSlot = nullptr;
for (const FStrateSlot& Slot : StrateLayout)
{
if (Slot.StrateIndex == StrateIndex)
{
TargetSlot = &Slot;
break;
}
}
if (TargetSlot == nullptr || TargetSlot->Definition == nullptr)
{
OutError = FString::Printf(TEXT("Strate index %d is not present in the live layout."), StrateIndex);
return false;
}
switch (Archetype)
{
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
case ECaveGeneratorType::Maze:
case ECaveGeneratorType::SurfaceWorld:
case ECaveGeneratorType::VerticalShafts:
case ECaveGeneratorType::FloatingIslands:
case ECaveGeneratorType::Underwater:
break;
default:
OutError = TEXT("The composer returned an unsupported strate archetype.");
return false;
}
if (bUseRecipe && Recipe == nullptr)
{
OutError = TEXT("A structure candidate did not provide a recipe.");
return false;
}
TSharedPtr<FVoxelStrateComposerSlotOverride> Override =
MakeShared<FVoxelStrateComposerSlotOverride>();
Override->CandidateSeed = CandidateSeed;
Override->Archetype = Archetype;
Override->Params = Params;
VF_SetComposerRuntimeBounds(
Override->Params,
(float)(TargetSlot->TopChunkZ + 1) * CHUNK_SIZE,
(float)TargetSlot->BottomChunkZ * CHUNK_SIZE);
Override->bUseRecipe = bUseRecipe;
if (Recipe != nullptr)
{
Override->Recipe = *Recipe;
}
// Only one slot is overridden at a time. Keeping this map small also makes the worker-side
// copy-on-chunk-refetch cheap. Passage geometry is deliberately not regenerated: the layout
// and its passages are unchanged, and this is the same manager state used by the offline
// candidate measurement harness.
ComposerOverrides.Reset();
ComposerOverrides.Add(StrateIndex, MoveTemp(Override));
// This is both the passage-shortlist invalidation counter and the cache key used by the
// generator's per-chunk params/stack memos. The world has already paused all readers.
++PassagesVersion;
return true;
}
bool UVoxelStrateManager::GetComposerOverrideForChunk(
const FIntVector& ChunkCoord, int32& OutCandidateSeed,
ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams,
bool& bOutUseRecipe, FVoxelOpStackRecipe& OutRecipe) const
{
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0)
{
return false;
}
const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(StrateLayout[SlotIdx].StrateIndex);
if (Override == nullptr)
{
return false;
}
OutCandidateSeed = Override->CandidateSeed;
OutArchetype = Override->Archetype;
OutParams = Override->Params;
bOutUseRecipe = Override->bUseRecipe;
OutRecipe = Override->Recipe;
return true;
}
bool UVoxelStrateManager::HasComposerRecipeOverride() const
{
for (const TPair<int32, TSharedPtr<FVoxelStrateComposerSlotOverride>>& Pair : ComposerOverrides)
{
if (Pair.Value.IsValid() && Pair.Value->bUseRecipe)
{
return true;
}
}
return false;
}
#endif
//=============================================================================
// PASSAGE GENERATION
//=============================================================================
@@ -935,6 +1089,14 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto
return ECaveGeneratorType::TunnelNetwork;
}
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(StrateLayout[SlotIdx].StrateIndex))
{
return Override->Archetype;
}
#endif
return StrateLayout[SlotIdx].Definition->GeneratorType;
}
@@ -943,6 +1105,15 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; }
#if WITH_EDITOR
if (FindComposerOverride(StrateLayout[SlotIdx].StrateIndex) != nullptr)
{
// Temporary composer candidates are measured through the stack path. Do not inherit an
// unrelated asset flag from the slot being replaced.
return true;
}
#endif
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
if (!Def->bUseOperatorStack) { return false; }
@@ -1018,8 +1189,23 @@ FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVecto
const FStrateSlot& Slot = StrateLayout[SlotIdx];
// Copy the designer-authored slab params from the strate definition.
FSlabGenerationParams Result = Slot.Definition->SlabParams;
// Copy the designer-authored slab params from the strate definition, unless an editor
// composer candidate owns this slot.
FSlabGenerationParams Result;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(Slot.StrateIndex))
{
Result = (Override->Archetype == ECaveGeneratorType::FlatPlain
|| Override->Archetype == ECaveGeneratorType::CrystalChamber)
? Override->Params.SlabParams
: Slot.Definition->SlabParams;
}
else
#endif
{
Result = Slot.Definition->SlabParams;
}
// Fill in the runtime Z bounds (voxel coordinates, same convention as
// FStrateGenerationParams::StrateTopWorldZ / StrateBottomWorldZ).
@@ -1034,32 +1220,111 @@ FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVecto
// PER-ARCHETYPE PARAM GETTERS
//=============================================================================
// Each mirrors GetSlabParamsForChunk: copy designer params, fill runtime Z bounds.
// No cross-boundary blending — archetypes meet at Hard boundaries. A macro keeps
// the boilerplate (slot lookup + fallback + Z bounds) in one place.
// No cross-boundary blending — archetypes meet at Hard boundaries.
#define VF_ARCHETYPE_PARAMS_GETTER(FnName, StructType, DefMember) \
StructType UVoxelStrateManager::FnName(const FIntVector& ChunkCoord) const \
{ \
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); \
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) \
{ \
StructType Empty; \
Empty.BaseDensity = -1.0f; \
return Empty; \
} \
const FStrateSlot& Slot = StrateLayout[SlotIdx]; \
StructType Result = Slot.Definition->DefMember; \
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; \
Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; \
return Result; \
FMazeGenerationParams UVoxelStrateManager::GetMazeParamsForChunk(const FIntVector& ChunkCoord) const
{
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
{
FMazeGenerationParams Empty;
Empty.BaseDensity = -1.0f;
return Empty;
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FMazeGenerationParams Result = Slot.Definition->MazeParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
if (Override->Archetype == ECaveGeneratorType::Maze)
{
Result = Override->Params.MazeParams;
}
}
#endif
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
return Result;
}
VF_ARCHETYPE_PARAMS_GETTER(GetMazeParamsForChunk, FMazeGenerationParams, MazeParams)
VF_ARCHETYPE_PARAMS_GETTER(GetSurfaceParamsForChunk, FSurfaceGenerationParams, SurfaceParams)
VF_ARCHETYPE_PARAMS_GETTER(GetVerticalShaftParamsForChunk, FVerticalShaftParams, VerticalShaftParams)
VF_ARCHETYPE_PARAMS_GETTER(GetFloatingIslandParamsForChunk, FFloatingIslandParams, FloatingIslandParams)
FSurfaceGenerationParams UVoxelStrateManager::GetSurfaceParamsForChunk(const FIntVector& ChunkCoord) const
{
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
{
FSurfaceGenerationParams Empty;
Empty.BaseDensity = -1.0f;
return Empty;
}
#undef VF_ARCHETYPE_PARAMS_GETTER
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FSurfaceGenerationParams Result = Slot.Definition->SurfaceParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
if (Override->Archetype == ECaveGeneratorType::SurfaceWorld)
{
Result = Override->Params.SurfaceParams;
}
}
#endif
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
return Result;
}
FVerticalShaftParams UVoxelStrateManager::GetVerticalShaftParamsForChunk(const FIntVector& ChunkCoord) const
{
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
{
FVerticalShaftParams Empty;
Empty.BaseDensity = -1.0f;
return Empty;
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FVerticalShaftParams Result = Slot.Definition->VerticalShaftParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
if (Override->Archetype == ECaveGeneratorType::VerticalShafts)
{
Result = Override->Params.VerticalShaftParams;
}
}
#endif
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
return Result;
}
FFloatingIslandParams UVoxelStrateManager::GetFloatingIslandParamsForChunk(const FIntVector& ChunkCoord) const
{
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
{
FFloatingIslandParams Empty;
Empty.BaseDensity = -1.0f;
return Empty;
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
FFloatingIslandParams Result = Slot.Definition->FloatingIslandParams;
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex))
{
if (Override->Archetype == ECaveGeneratorType::FloatingIslands)
{
Result = Override->Params.FloatingIslandParams;
}
}
#endif
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
return Result;
}
FBiomeContext UVoxelStrateManager::GetBiomeContextForChunk(const FIntVector& ChunkCoord) const
{
@@ -1156,6 +1421,20 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto
}
const FStrateSlot& Slot = StrateLayout[SlotIdx];
#if WITH_EDITOR
if (const FVoxelStrateComposerSlotOverride* Override =
FindComposerOverride(Slot.StrateIndex))
{
FStrateGenerationParams Result = Override->Params.TunnelNetworkParams;
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
// A candidate is a hard replacement of this slot. Do not blend its rolled vector with an
// authored neighbour at a boundary; this is also how the offline fixture measures it.
return Result;
}
#endif
FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition);
//=========================================================================
+431
View File
@@ -33,6 +33,11 @@
#include "Misc/Paths.h"
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0)
#if WITH_EDITOR
#include "VoxelStrateComposer.h"
#include "VoxelStrateMeasure.h"
#endif
AVoxelWorld::AVoxelWorld()
{
PrimaryActorTick.bCanEverTick = true;
@@ -127,6 +132,228 @@ private:
bool bAcquired = false;
};
#if WITH_EDITOR
namespace
{
void VF_SetComposerRuntimeBounds(FVoxelStrateArchetypeParams& Params,
float TopWorldZ, float BottomWorldZ)
{
Params.TunnelNetworkParams.StrateTopWorldZ = TopWorldZ;
Params.TunnelNetworkParams.StrateBottomWorldZ = BottomWorldZ;
Params.SlabParams.StrateTopWorldZ = TopWorldZ;
Params.SlabParams.StrateBottomWorldZ = BottomWorldZ;
Params.MazeParams.StrateTopWorldZ = TopWorldZ;
Params.MazeParams.StrateBottomWorldZ = BottomWorldZ;
Params.SurfaceParams.StrateTopWorldZ = TopWorldZ;
Params.SurfaceParams.StrateBottomWorldZ = BottomWorldZ;
Params.VerticalShaftParams.StrateTopWorldZ = TopWorldZ;
Params.VerticalShaftParams.StrateBottomWorldZ = BottomWorldZ;
Params.FloatingIslandParams.StrateTopWorldZ = TopWorldZ;
Params.FloatingIslandParams.StrateBottomWorldZ = BottomWorldZ;
}
float VF_ComposerBoundarySeal(const FVoxelStrateArchetypeParams& Params,
ECaveGeneratorType Archetype)
{
switch (Archetype)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
return Params.SlabParams.BoundarySealThickness;
case ECaveGeneratorType::Maze:
return Params.MazeParams.BoundarySealThickness;
case ECaveGeneratorType::SurfaceWorld:
return Params.SurfaceParams.BoundarySealThickness;
case ECaveGeneratorType::VerticalShafts:
return Params.VerticalShaftParams.BoundarySealThickness;
case ECaveGeneratorType::FloatingIslands:
return Params.FloatingIslandParams.BoundarySealThickness;
case ECaveGeneratorType::Underwater:
case ECaveGeneratorType::TunnelNetwork:
default:
return Params.TunnelNetworkParams.BoundarySealThickness;
}
}
class FComposerStackDensitySampler final : public IVoxelStrateDensitySampler
{
public:
explicit FComposerStackDensitySampler(const FVoxelOpStack& InStack)
: Stack(InStack) {}
float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
{
return Stack.EvalMC(WorldX, WorldY, WorldZ);
}
private:
const FVoxelOpStack& Stack;
};
bool VF_BuildComposerReferenceStack(
const FVoxelStrateComposerCandidate& Candidate,
const UVoxelGenerator& Generator,
const UVoxelStrateManager& StrateManager,
int32 TargetTopChunkZ,
int32 TargetBottomChunkZ,
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext,
FString& OutError)
{
const float TopWorldZ = (float)(TargetTopChunkZ + 1) * CHUNK_SIZE;
const float BottomWorldZ = (float)TargetBottomChunkZ * CHUNK_SIZE;
FVoxelStrateArchetypeParams Params = Candidate.ArchetypeParams;
VF_SetComposerRuntimeBounds(Params, TopWorldZ, BottomWorldZ);
bool bBuilt = false;
if (Candidate.bStructureRoll)
{
bBuilt = VF_BuildStackFromRecipe(
Candidate.Recipe, Params, Generator.Seed, Generator.OriginSpineRadius,
&StrateManager, OutStack, OutContext, &OutError);
}
else
{
bBuilt = VF_BuildNativeStrateStackForCandidate(
Candidate.Archetype, Params, Generator.Seed, Generator.OriginSpineRadius,
Generator.WorldRadiusVoxels, Generator.EdgeSealThickness, &StrateManager,
OutStack, OutContext);
if (!bBuilt && OutError.IsEmpty())
{
OutError = TEXT("The production native candidate stack could not be materialised.");
}
}
if (!bBuilt)
{
return false;
}
// The recipe builder deliberately defaults the world-edge context to 0 for offline
// measurements. A live candidate must use the actual world's edge law, while its
// vertical bounds and structural seal remain candidate-owned.
const int32 MidChunkZ = TargetBottomChunkZ
+ (TargetTopChunkZ - TargetBottomChunkZ) / 2;
OutContext.ChunkCoord = FIntVector(0, 0, MidChunkZ);
OutContext.Step = 1;
OutContext.LayoutVersion = StrateManager.GetLayoutVersion();
OutContext.WorldRadiusVoxels = Generator.WorldRadiusVoxels;
OutContext.EdgeSealThickness = Generator.EdgeSealThickness;
OutStack.PrepareChunk(OutContext);
return true;
}
bool VF_ComposerReferenceIsComparable(
const AVoxelWorld& World,
const FStrateSlot& TargetSlot,
ECaveGeneratorType Archetype,
const FIntVector& RepresentativeChunk,
FString& OutReason)
{
OutReason.Reset();
if (World.DiffLayer && World.DiffLayer->HasAnyMods())
{
OutReason = TEXT("diff-layer-modifications-present");
return false;
}
const FStrateDisturbanceParams Disturbances =
World.StrateManager->GetDisturbanceParamsForChunk(RepresentativeChunk);
if (Disturbances.ChasmDensity > 0.0f
|| Disturbances.BridgeDensity > 0.0f
|| Disturbances.RidgeDensity > 0.0f)
{
OutReason = TEXT("live-disturbance-post-present");
return false;
}
// The production SurfaceWorld stack may include a generator-owned biome field and
// per-biome params. The standalone candidate stack intentionally does not invent that
// context; fall back to the live metric and mark the density check as unavailable.
if (Archetype == ECaveGeneratorType::SurfaceWorld
&& TargetSlot.Definition != nullptr
&& TargetSlot.Definition->Biomes.Num() > 0)
{
OutReason = TEXT("surface-biome-context-present");
return false;
}
return true;
}
FVoxelStrateMeasureSettings VF_ComposerMeasureSettings()
{
FVoxelStrateMeasureSettings Settings;
Settings.SampleStep = 4;
Settings.RadiusInVoxels = 256;
Settings.CenterXY = FVector2D::ZeroVector;
Settings.MaxCells = 8000000;
Settings.MaxRouteRetries = 16;
Settings.HeadroomCells = 2;
Settings.InteriorMarginVoxels = -1;
return Settings;
}
bool VF_ComposerDensitySanityCheck(
const UVoxelGenerator& Generator,
const FVoxelOpStack& ReferenceStack,
int32 TargetTopChunkZ,
int32 TargetBottomChunkZ,
float& OutMaxDelta,
int32& OutMismatches,
int32& OutSamples)
{
// Keep all probes inside one representative chunk column where possible. This exercises
// both the live thread-local refetch and the prepared reference stack without making the
// check depend on a particular room being present at a hand-picked coordinate.
static const FIntVector Offsets[] =
{
FIntVector(1, 1, -15),
FIntVector(7, 13, -7),
FIntVector(17, 3, 1),
FIntVector(29, 27, 9),
FIntVector(3, 23, 17),
FIntVector(19, 19, 25),
FIntVector(11, 29, 31),
FIntVector(27, 5, -23),
};
const int32 MidChunkZ = TargetBottomChunkZ
+ (TargetTopChunkZ - TargetBottomChunkZ) / 2;
const int32 BottomWorldZ = TargetBottomChunkZ * CHUNK_SIZE;
const int32 TopWorldZExclusive = (TargetTopChunkZ + 1) * CHUNK_SIZE;
const int32 MidWorldZ = MidChunkZ * CHUNK_SIZE + CHUNK_SIZE / 2;
OutMaxDelta = 0.0f;
OutMismatches = 0;
OutSamples = 0;
for (const FIntVector& Offset : Offsets)
{
const float X = (float)Offset.X;
const float Y = (float)Offset.Y;
const float Z = (float)FMath::Clamp(
MidWorldZ + Offset.Z, BottomWorldZ + 1, TopWorldZExclusive - 1);
const float Live = Generator.GetDensityAt(X, Y, Z);
const float Reference = ReferenceStack.EvalMC(X, Y, Z);
uint32 LiveBits = 0;
uint32 ReferenceBits = 0;
FMemory::Memcpy(&LiveBits, &Live, sizeof(LiveBits));
FMemory::Memcpy(&ReferenceBits, &Reference, sizeof(ReferenceBits));
const bool bBitEqual = LiveBits == ReferenceBits;
if (!bBitEqual)
{
++OutMismatches;
}
const float Delta = (FMath::IsFinite(Live) && FMath::IsFinite(Reference))
? FMath::Abs(Live - Reference) : FLT_MAX;
OutMaxDelta = FMath::Max(OutMaxDelta, Delta);
++OutSamples;
}
return OutMismatches == 0;
}
}
#endif
//=============================================================================
// LIVE EDIT — regenerate all chunks when params change in the Details panel
//=============================================================================
@@ -270,6 +497,210 @@ void AVoxelWorld::ValidateDeterminism()
}
#if WITH_EDITOR
void AVoxelWorld::ApplyComposerCandidate()
{
UWorld* World = GetWorld();
if (World == nullptr || !World->IsPlayInEditor())
{
UE_LOG(LogTemp, Warning,
TEXT("[VoxelWorld] ApplyComposerCandidate: run this button from a PIE world."));
return;
}
if (bShuttingDown.load(std::memory_order_acquire)
|| bGenerationPaused.load(std::memory_order_acquire))
{
UE_LOG(LogTemp, Warning,
TEXT("[VoxelWorld] ApplyComposerCandidate: generation is already stopping or paused."));
return;
}
if (!Settings || !Generator || !StrateManager)
{
UE_LOG(LogTemp, Warning,
TEXT("[VoxelWorld] ApplyComposerCandidate: BeginPlay has not initialized Settings, Generator, and StrateManager."));
return;
}
const TArray<FStrateSlot>& Layout = StrateManager->GetLayout();
const FStrateSlot* TargetSlot = nullptr;
for (const FStrateSlot& Slot : Layout)
{
if (Slot.StrateIndex == ComposerTargetStrateIndex)
{
TargetSlot = &Slot;
break;
}
}
if (TargetSlot == nullptr || TargetSlot->Definition == nullptr)
{
UE_LOG(LogTemp, Warning,
TEXT("[VoxelWorld] ApplyComposerCandidate: ComposerTargetStrateIndex=%d is not a live layout slot (layout has %d slots)."),
ComposerTargetStrateIndex, Layout.Num());
return;
}
FVoxelStrateCorpus Corpus;
FString CorpusReport;
if (!Corpus.LoadFromAssetRegistry(CorpusReport))
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] ApplyComposerCandidate: composer corpus is unavailable: %s"),
*CorpusReport);
return;
}
const FVoxelStrateComposerCandidate Candidate = VF_RollStrateCandidate(
Corpus, ComposerSeed, ComposerCandidateIndex, bComposerRollStructure);
if (!Candidate.bValid)
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d roll failed: %s"),
ComposerSeed, ComposerCandidateIndex, *Candidate.FailureReason);
return;
}
const int32 TargetTopChunkZ = TargetSlot->TopChunkZ;
const int32 TargetBottomChunkZ = TargetSlot->BottomChunkZ;
const float TargetTopWorldZ = (float)(TargetTopChunkZ + 1) * CHUNK_SIZE;
const float TargetBottomWorldZ = (float)TargetBottomChunkZ * CHUNK_SIZE;
const FIntVector RepresentativeChunk(
0, 0, TargetBottomChunkZ + (TargetTopChunkZ - TargetBottomChunkZ) / 2);
// Materialise before mutating the live manager. A bad recipe or unsupported native family
// therefore leaves the running world untouched.
FVoxelStrateArchetypeParams PreflightParams = Candidate.ArchetypeParams;
VF_SetComposerRuntimeBounds(PreflightParams, TargetTopWorldZ, TargetBottomWorldZ);
FVoxelOpStack PreflightStack;
FVoxelOpContext PreflightContext;
FString PreflightError;
bool bPreflightBuilt = false;
if (Candidate.bStructureRoll)
{
bPreflightBuilt = VF_BuildStackFromRecipe(
Candidate.Recipe, PreflightParams, Generator->Seed, Generator->OriginSpineRadius,
StrateManager, PreflightStack, PreflightContext, &PreflightError);
}
else
{
bPreflightBuilt = VF_BuildNativeStrateStackForCandidate(
Candidate.Archetype, PreflightParams, Generator->Seed, Generator->OriginSpineRadius,
Generator->WorldRadiusVoxels, Generator->EdgeSealThickness, StrateManager,
PreflightStack, PreflightContext);
if (!bPreflightBuilt)
{
PreflightError = TEXT("The production native candidate stack could not be materialised.");
}
}
if (!bPreflightBuilt)
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"),
ComposerSeed, ComposerCandidateIndex, *PreflightError);
return;
}
FString ApplyError;
{
// The manager override is a density-only replacement. It does not call Initialize or
// GeneratePassages while the layout is half-built: the existing slot Z span, content
// definition, and passage geometry remain stable. The pause drains current workers before
// the map is changed, and the subsequent RegenerateAllChunks supplies the epoch bump.
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] ApplyComposerCandidate: generation pause timed out; no mutation applied."));
return;
}
if (!StrateManager->SetComposerOverrideForStrate(
ComposerTargetStrateIndex, Candidate.Seed, Candidate.Archetype,
Candidate.ArchetypeParams, Candidate.bStructureRoll,
Candidate.bStructureRoll ? &Candidate.Recipe : nullptr, ApplyError))
{
UE_LOG(LogTemp, Error,
TEXT("[VoxelWorld] ApplyComposerCandidate: seed=%d index=%d was not applied: %s"),
ComposerSeed, ComposerCandidateIndex, *ApplyError);
return;
}
// These are the same game-thread-owned side systems reset by RebuildStrates. The full
// chunk reset below also resets the density volume and preserves DiffLayer edits.
if (AtmosphereManager) { AtmosphereManager->Reset(); }
if (ContentManager) { ContentManager->ClearAll(); }
}
// Reuse the established seed/live-edit invalidation path: GenerationEpoch increments here,
// stale ProcessQueue results are dropped, and the normal Tick submits a fresh stream around
// the player. No async worker can observe the half-applied manager state.
RegenerateAllChunks();
FVoxelOpStack ReferenceStack;
FVoxelOpContext ReferenceContext;
FString ReferenceError;
const bool bReferenceBuilt = VF_BuildComposerReferenceStack(
Candidate, *Generator, *StrateManager, TargetTopChunkZ, TargetBottomChunkZ,
ReferenceStack, ReferenceContext, ReferenceError);
FString ComparableReason;
const bool bReferenceComparable = bReferenceBuilt
&& VF_ComposerReferenceIsComparable(
*this, *TargetSlot, Candidate.Archetype, RepresentativeChunk, ComparableReason);
const FVoxelStrateMeasureSettings MeasureSettings = VF_ComposerMeasureSettings();
FVoxelStrateMetrics Metrics;
FString MetricSource;
if (bReferenceComparable)
{
FComposerStackDensitySampler Sampler(ReferenceStack);
Metrics = VF_MeasureStrateWithSampler(
Sampler, (int32)TargetBottomWorldZ, (int32)TargetTopWorldZ,
VF_ComposerBoundarySeal(PreflightParams, Candidate.Archetype), MeasureSettings);
MetricSource = TEXT("candidate-stack");
}
else
{
// A live diff, disturbance post, or surface biome context makes a standalone stack an
// unlike oracle. Keep the owner's metric useful by measuring the actual applied generator;
// the log records why the bit-level oracle was skipped.
Metrics = VF_MeasureStrate(
*Generator, *StrateManager, ComposerTargetStrateIndex, MeasureSettings);
MetricSource = TEXT("live-generator");
}
float MaxDensityDelta = 0.0f;
int32 DensityMismatches = 0;
int32 DensitySamples = 0;
bool bDensityMatch = false;
if (bReferenceComparable)
{
bDensityMatch = VF_ComposerDensitySanityCheck(
*Generator, ReferenceStack, TargetTopChunkZ, TargetBottomChunkZ,
MaxDensityDelta, DensityMismatches, DensitySamples);
}
const FString RecipeText = Candidate.bStructureRoll
? VF_FormatStrateStructureRecipe(Candidate.Recipe)
: TEXT("parameter-roll");
const FString DeterminismText = !bReferenceBuilt
? FString::Printf(TEXT("UNAVAILABLE(%s)"), *ReferenceError)
: !bReferenceComparable
? FString::Printf(TEXT("SKIPPED(%s)"), *ComparableReason)
: FString::Printf(TEXT("%s/%d"), bDensityMatch ? TEXT("PASS") : TEXT("FAIL"),
DensitySamples);
const float AirFraction = Metrics.bValid ? Metrics.AirFraction : -1.0f;
const float LargestShare = Metrics.bValid ? Metrics.LargestComponentShare : -1.0f;
const float WalkableFraction = Metrics.bValid ? Metrics.WalkableFraction : -1.0f;
const float FeatureScale = Metrics.bValid ? Metrics.MedianFeatureScale : -1.0f;
UE_LOG(LogTemp, Log,
TEXT("[VoxelWorld] ComposerCandidate applied seed=%d index=%d target=%d recipe=%s archetype=%s metrics=%s air=%.6f largest=%.6f walkable=%.6f feature=%.3f components=%d determinism=%s max_delta=%.9g mismatches=%d epoch=%u"),
Candidate.Seed, Candidate.Index, ComposerTargetStrateIndex, *RecipeText,
VF_GetStrateArchetypeName(Candidate.Archetype), *MetricSource,
AirFraction, LargestShare, WalkableFraction, FeatureScale,
Metrics.bValid ? Metrics.NumAirComponents : -1,
*DeterminismText, MaxDensityDelta, DensityMismatches, GenerationEpoch);
}
void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
@@ -15,6 +15,7 @@
class UVoxelSettings;
class UVoxelStrateDefinition;
class UVoxelStrateManager;
/** The one-bit identity choice at the root of a rolled structure. */
UENUM(BlueprintType)
@@ -211,6 +212,32 @@ struct VOXELFORGE_API FVoxelStrateRollInfo
TArray<FString> BootstrapJitterFieldNames;
};
#if WITH_EDITOR
/**
* One candidate materialised by the same deterministic calls used by the offline composer tests.
* This is an editor-only hand-off object: it is never a reflected asset and never part of the
* shipping generation contract.
*/
struct VOXELFORGE_API FVoxelStrateComposerCandidate
{
bool bValid = false;
FString FailureReason;
int32 Seed = 0;
int32 Index = 0;
bool bStructureRoll = false;
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
FVoxelStrateArchetypeParams ArchetypeParams;
FVoxelOpStackRecipe Recipe;
// Kept so automation/editor callers can audit the exact six independent structure blocks
// without re-rolling them through a second implementation.
FVoxelStrateRollInfo ParameterRoll;
TArray<FVoxelStrateRollInfo> StructureBlockRolls;
};
#endif
/**
* Known-good strate vectors plus their measured field spread.
*
@@ -293,6 +320,32 @@ VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype(
/** Pure structure roll: no retained RNG state and no corpus/runtime dependency. */
VOXELFORGE_API FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index);
#if WITH_EDITOR
/**
* Roll one candidate for the editor walk-through path. Parameter rolls call
* VF_RollStrateParamsDetailed; structure rolls call VF_RollStrateStructure and the exact six
* VF_RollStrateParamsDetailedForArchetype block rolls used by the structure test.
*/
VOXELFORGE_API FVoxelStrateComposerCandidate VF_RollStrateCandidate(
const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index, bool bRollStructure);
/**
* Build a parameter-roll candidate through the same archetype-to-stack mapping used by production
* GetDensityAt/ClassifyTile. This is editor-only so the offline composer remains absent from a
* shipping build; the result is used only for the post-apply density sanity check.
*/
VOXELFORGE_API bool VF_BuildNativeStrateStackForCandidate(
ECaveGeneratorType Archetype,
const FVoxelStrateArchetypeParams& Params,
int32 Seed,
float SpineRadius,
float WorldRadiusVoxels,
float EdgeSealThickness,
const UVoxelStrateManager* StrateManager,
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext);
#endif
/** Stable compact representation and equality/hash helpers for manifests and reports. */
VOXELFORGE_API FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe);
VOXELFORGE_API uint32 VF_HashStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe);
+35 -3
View File
@@ -24,6 +24,12 @@
class UVoxelSettings;
#if WITH_EDITOR
struct FVoxelStrateArchetypeParams;
struct FVoxelOpStackRecipe;
struct FVoxelStrateComposerSlotOverride;
#endif
/**
* FVoxelPassage — A navigable connection between two strates.
*
@@ -143,6 +149,27 @@ public:
*/
void Initialize(UVoxelSettings* Settings, int32 WorldSeed);
#if WITH_EDITOR
/**
* Install an editor-only candidate over one already-built slot. The slot's definition,
* dimensions, content, and passages remain intact; only the density archetype/parameters
* (or the materialised recipe) are replaced. The caller must hold the world's generation pause.
*/
bool SetComposerOverrideForStrate(
int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype,
const FVoxelStrateArchetypeParams& Params, bool bUseRecipe,
const FVoxelOpStackRecipe* Recipe, FString& OutError);
/** Read the immutable editor override for a chunk into worker-local storage. */
bool GetComposerOverrideForChunk(
const FIntVector& ChunkCoord, int32& OutCandidateSeed,
ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams,
bool& bOutUseRecipe, FVoxelOpStackRecipe& OutRecipe) const;
/** Custom recipes cannot be safely classified by the native ClassifyBox proof yet. */
bool HasComposerRecipeOverride() const;
#endif
//=========================================================================
// QUERIES
//=========================================================================
@@ -326,9 +353,9 @@ protected:
// Passages connecting consecutive strates
TArray<FVoxelPassage> Passages;
// Bumped every time Passages is rebuilt (GeneratePassages). EvaluateModifierSDF keeps a
// thread_local per-chunk shortlist of nearby passages and uses this to invalidate it when
// the passage set changes — so stale indices are never read after a rebuild.
// Bumped every time Passages is rebuilt (GeneratePassages), and for an editor composer
// override. EvaluateModifierSDF and all generator thread_local memos use this to invalidate
// cached passage/strate data after either kind of live change.
uint32 PassagesVersion = 0;
// How many chunks at strate boundaries are blended (transition zone)
@@ -360,6 +387,11 @@ protected:
// Returns INDEX into StrateLayout, or -1 if not found.
int32 FindSlotIndexForChunkZ(int32 ChunkZ) const;
#if WITH_EDITOR
const FVoxelStrateComposerSlotOverride* FindComposerOverride(int32 StrateIndex) const;
TMap<int32, TSharedPtr<FVoxelStrateComposerSlotOverride>> ComposerOverrides;
#endif
// Build FStrateGenerationParams from a strate definition:
// copies base GenerationParams, then applies all referenced terrain op assets.
// This is the single point where terrain op data assets are merged into params.
+24
View File
@@ -452,6 +452,30 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit")
bool bLiveEditStrates = false;
#if WITH_EDITORONLY_DATA
/** Seed passed to the deterministic offline composer roll. This does not change Settings->Seed. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer")
int32 ComposerSeed = 0;
/** Candidate row passed to VF_RollStrateCandidate (the offline tests use the same index). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer", meta = (ClampMin = "0"))
int32 ComposerCandidateIndex = 0;
/** Existing layout slot to replace; 0 is the topmost strate. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer", meta = (ClampMin = "0"))
int32 ComposerTargetStrateIndex = 0;
/** False rolls one native parameter vector; true rolls the structure recipe and its six blocks. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer")
bool bComposerRollStructure = false;
#endif
#if WITH_EDITOR
/** Roll and apply the selected offline composer candidate to one live PIE slot. */
UFUNCTION(CallInEditor, Category = "Live Edit|Composer")
void ApplyComposerCandidate();
#endif
/** Click to force-regenerate all chunks right now (useful during PIE). */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit")
void RegenerateAllChunks();