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:
1 parent
90c40c8d5a
commit
d717bc74ff
13 files changed
+1182
-32
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -775,6 +886,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
#undef VF_ARCHETYPE_PARAMS_GETTER
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
//=========================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user