The last unbuilt Tier 1 item. A passage was a bore through rock with no floor inside the walkable angle, so its mouth was not a standing position and neither was anything near it -- the departure mouth measured a FOUR-CELL player-fit component. You arrived and there was nowhere to stand. Both ends of all 7 passages now open into a landing, sized from the body: capsule diameter 2.72 vox floor >= 12 vox (3 m) to stand and turn capsule height 7.04 vox room >= 12 vox tall, stock mouth gives 18 (4.5 m) 3-voxel floor slab, tube tangent to it -- no lip to climb out of connector 5-vox radius: 2.5 m clear width, 3 m clear height ramps over 44 deg get a deterministic level dog-leg ⭐ THE MOUTHS ARE NO LONGER SPECKS: archetype components largest arrival-departure Maze 309 -> 340 176 -> 3210 43-81 -> 3210-3210 TunnelNetwork 467 -> 227 1292 -> 3841 2-1 -> 3841-269 Underwater 86 -> 117 35 -> 3838 3-11 -> 3838-267 FlatPlain 45 -> 11 259 -> 2130 1-1 -> 268-273 CrystalChamber 197 -> 90 24 -> 286 1-1 -> 286-274 FloatingIslands 2 -> 1 3 -> 72 1-1 -> 72-72 Law 1/8 -> 2/7 connected (1 refused on MaxCells), and critically ZERO connections now run through a four-cell pocket. Every landing floor was verified against the FINAL density, after all posts: 14/14 pass the 44 deg slope bracket, worst room gradient .059, worst connector .522. 14/14 deterministic root joins. 0 seal failures. 0 box-verdict violations across 82 proved + 174 mixed, 109,142 voxels. Op-stack bit-identical, order-independence green over 10 slots / 31 passages / 3 pool orders. Cache 0.665 us rebuild, 0.646 us hot. Post order: origin spine -> vertical seal -> passage tube + landing floor -> XY edge seal. Landings clamp 4 voxels inside the vertical seal; the XY seal stays last and wins at world edges. ⚠️ ARCHITECTURAL NOTE, not a detail: landings join a deterministic annular ROOT HUB at (0,0), built identically for every endpoint -- no nearest- component search, no passage ordering. That is what makes the join guaranteed rather than likely, and it extends the (0,0) spine concept from a landmark into a structural network. It also means routes tend to pass through the centre, which is a gameplay consequence worth Jahni's eye. ⚠️ AND IT DOES NOT CLOSE THE LAW. Codex is explicit: the landings guarantee membership in the new root network, NOT in the archetype's pre-existing fit component. The whole-strate Maze check still shows a 71.9-voxel gap between 1,235 and 2,938-cell components. So the native corridor networks remain disconnected from the root network in places. Real, separate, next. 52 build-and-measure cycles, ~9 hours, finding real geometry bugs on the way (a landing floor overlapping the ramp cells and hiding a floor discontinuity; the disturbance pass refilling connector headroom). Build clean, 32/32 success, 0 failed. Showcase regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
2300 lines
103 KiB
C++
2300 lines
103 KiB
C++
// VoxelStrateManager.cpp
|
|
// Runtime strate layout generation and queries.
|
|
|
|
#include "VoxelStrateManager.h"
|
|
#include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests
|
|
#include "VoxelSettings.h"
|
|
#include "VoxelSeasonAsset.h"
|
|
#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord
|
|
#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash
|
|
#include "VoxelDensityPrimitives.h" // Shared passage carve polarity/strength
|
|
#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo
|
|
#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten)
|
|
#include "UObject/UObjectGlobals.h"
|
|
|
|
#if WITH_EDITOR
|
|
#include "VoxelStrateComposer.h"
|
|
|
|
struct FVoxelStrateComposerSlotOverride
|
|
{
|
|
int32 CandidateSeed = 0;
|
|
ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork;
|
|
FVoxelStrateArchetypeParams Params;
|
|
bool bUseRecipe = false;
|
|
FVoxelOpStackRecipe Recipe;
|
|
bool bUseRegions = false;
|
|
FVoxelStrateRegionManifest Regions;
|
|
};
|
|
|
|
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
|
|
// orbiting (single-octave 2-channel = a spiral). Each axis samples this with its own seed.
|
|
static float PassageFBM(float X, float Seed)
|
|
{
|
|
float Total = 0.0f, Amp = 1.0f, Freq = 1.0f, MaxV = 0.0f;
|
|
for (int32 O = 0; O < 4; ++O)
|
|
{
|
|
Total += FMath::PerlinNoise3D(FVector(X * Freq + Seed, Seed * 1.7f + O * 13.0f, O * 5.0f)) * Amp;
|
|
MaxV += Amp;
|
|
Amp *= 0.5f;
|
|
Freq *= 2.0f;
|
|
}
|
|
return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
/**
|
|
* One shared passage cache for the tube SDF and landing-floor fill.
|
|
*
|
|
* The source query and all landing dimensions are resolved by GeneratePassages. This cache
|
|
* only narrows the immutable passage array once per (manager, version, chunk); neither the
|
|
* player-fit stencil nor a topology search can leak into the voxel loop.
|
|
*/
|
|
struct FPassageEvaluationCache
|
|
{
|
|
const UVoxelStrateManager* Owner = nullptr;
|
|
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
|
uint32 Version = 0xFFFFFFFFu;
|
|
TArray<int32> Nearby;
|
|
};
|
|
|
|
FPassageEvaluationCache& VF_GetPassageEvaluationCache()
|
|
{
|
|
thread_local FPassageEvaluationCache Cache;
|
|
return Cache;
|
|
}
|
|
|
|
const TArray<int32>& VF_GetNearbyPassages(
|
|
const UVoxelStrateManager* Manager,
|
|
const FIntVector& ChunkCoord)
|
|
{
|
|
FPassageEvaluationCache& Cache = VF_GetPassageEvaluationCache();
|
|
const uint32 Version = Manager ? Manager->GetLayoutVersion() : 0u;
|
|
if (Cache.Owner == Manager && Cache.Chunk == ChunkCoord && Cache.Version == Version)
|
|
{
|
|
return Cache.Nearby;
|
|
}
|
|
|
|
Cache.Owner = Manager;
|
|
Cache.Chunk = ChunkCoord;
|
|
Cache.Version = Version;
|
|
Cache.Nearby.Reset();
|
|
if (!Manager) return Cache.Nearby;
|
|
|
|
const FVector ChunkCenter(
|
|
(ChunkCoord.X + 0.5f) * (float)CHUNK_SIZE,
|
|
(ChunkCoord.Y + 0.5f) * (float)CHUNK_SIZE,
|
|
(ChunkCoord.Z + 0.5f) * (float)CHUNK_SIZE);
|
|
const float ChunkRadius = (float)CHUNK_SIZE * 0.8660254f + 3.0f;
|
|
const TArray<FVoxelPassage>& Passages = Manager->GetPassages();
|
|
for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex)
|
|
{
|
|
const FVoxelPassage& Passage = Passages[PassageIndex];
|
|
const float Reach = Passage.BoundRadius + ChunkRadius;
|
|
if (FVector::DistSquared(ChunkCenter, Passage.BoundCenter) <= Reach * Reach)
|
|
{
|
|
Cache.Nearby.Add(PassageIndex);
|
|
}
|
|
}
|
|
return Cache.Nearby;
|
|
}
|
|
}
|
|
|
|
bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
|
|
{
|
|
if (!Settings)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[StrateManager] No settings provided!"));
|
|
return false;
|
|
}
|
|
|
|
FVoxelSeasonManifest SeasonManifest;
|
|
bool bUseSeason = false;
|
|
if (!Settings->Season.IsNull())
|
|
{
|
|
UVoxelSeasonAsset* SeasonAsset = Settings->Season.LoadSynchronous();
|
|
FString SeasonReport;
|
|
if (SeasonAsset == nullptr
|
|
|| !SeasonAsset->LoadManifest(SeasonManifest, SeasonReport))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[StrateManager] Season asset is assigned but unusable: %s"),
|
|
SeasonAsset ? *SeasonReport : *Settings->Season.ToString());
|
|
return false; // Fail closed: falling back to the authored pool would diverge peers.
|
|
}
|
|
bUseSeason = true;
|
|
WorldSeed = SeasonManifest.Seed;
|
|
}
|
|
|
|
StrateLayout.Empty();
|
|
SeasonStrates.Empty();
|
|
ActiveSeasonContentHash.Reset();
|
|
#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 = bUseSeason
|
|
? SeasonManifest.Strates.Num() : Settings->TotalStrates;
|
|
const int32 LayoutGapChunks = bUseSeason
|
|
? SeasonManifest.InterStrateGapChunks
|
|
: FMath::Max(0, Settings->InterStrateGapChunks);
|
|
|
|
//=========================================================================
|
|
// STEP 1: Build shuffled pool (seed-based randomization)
|
|
//=========================================================================
|
|
// Copy the pool and shuffle it deterministically using the world seed.
|
|
// Fixed strates are excluded from the shuffle — they always use their
|
|
// assigned definition regardless of seed.
|
|
|
|
using FLoadedPoolEntry = TPair<FString, UVoxelStrateDefinition*>;
|
|
TArray<FLoadedPoolEntry> ShuffledPool;
|
|
if (!bUseSeason)
|
|
{
|
|
for (const TSoftObjectPtr<UVoxelStrateDefinition>& SoftPtr : Settings->StratePool)
|
|
{
|
|
// Load the asset (synchronous for now — could be async later)
|
|
UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous();
|
|
if (Def)
|
|
{
|
|
ShuffledPool.Emplace(SoftPtr.ToString(), Def);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by the soft asset path before shuffling. Pointer addresses depend on load order and
|
|
// would make the layout machine-dependent. The pool is a set for layout purposes: editor
|
|
// reordering must not change the input sequence seen by Fisher-Yates.
|
|
// Trier par chemin de soft asset avant le shuffle. Les adresses de pointeurs dépendent de
|
|
// l'ordre de chargement et rendraient le layout dépendant de la machine. Le pool est un set
|
|
// pour le layout : réordonner l'asset dans l'éditeur ne doit pas changer l'entrée de Fisher-Yates.
|
|
ShuffledPool.Sort([](const FLoadedPoolEntry& A, const FLoadedPoolEntry& B)
|
|
{
|
|
return FCString::Strcmp(*A.Key, *B.Key) < 0;
|
|
});
|
|
|
|
// Seed-based shuffle using Fisher-Yates
|
|
// FRandomStream gives us deterministic random numbers from a seed
|
|
FRandomStream Rng(WorldSeed);
|
|
for (int32 i = ShuffledPool.Num() - 1; i > 0; i--)
|
|
{
|
|
int32 j = Rng.RandRange(0, i);
|
|
ShuffledPool.Swap(i, j);
|
|
}
|
|
|
|
//=========================================================================
|
|
// STEP 2: Assign definitions to each strate slot
|
|
//=========================================================================
|
|
// Walk through strate indices 0..TotalStrates-1.
|
|
// Fixed strates use their pinned definition.
|
|
// Random strates cycle through the shuffled pool.
|
|
|
|
int32 PoolCursor = 0; // Current position in the shuffled pool
|
|
|
|
// Pre-load fixed strate definitions
|
|
TMap<int32, UVoxelStrateDefinition*> LoadedFixed;
|
|
if (!bUseSeason)
|
|
{
|
|
for (auto& Pair : Settings->FixedStrates)
|
|
{
|
|
UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous();
|
|
if (Def)
|
|
{
|
|
LoadedFixed.Add(Pair.Key, Def);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Current Z position (in chunks). Starts at 0 and goes downward (negative).
|
|
int32 CurrentTopZ = 0;
|
|
|
|
for (int32 i = 0; i < TotalStrates; i++)
|
|
{
|
|
FStrateSlot Slot;
|
|
Slot.StrateIndex = i;
|
|
|
|
if (bUseSeason)
|
|
{
|
|
const FVoxelSeasonStrate& SeasonStrate = SeasonManifest.Strates[i];
|
|
if (!SeasonStrate.SourceDefinitionPath.IsEmpty())
|
|
{
|
|
TSoftObjectPtr<UVoxelStrateDefinition> SourceDefinition(
|
|
FSoftObjectPath(SeasonStrate.SourceDefinitionPath));
|
|
UVoxelStrateDefinition* LoadedDefinition = SourceDefinition.LoadSynchronous();
|
|
if (LoadedDefinition == nullptr)
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[StrateManager] Season slot %d could not load authored definition '%s'."),
|
|
i, *SeasonStrate.SourceDefinitionPath);
|
|
StrateLayout.Empty();
|
|
SeasonStrates.Empty();
|
|
return false;
|
|
}
|
|
// Keep the authored content/passage/visual bag without mutating the cooked asset,
|
|
// then make the manifest's density identity authoritative below.
|
|
Slot.Definition = DuplicateObject<UVoxelStrateDefinition>(LoadedDefinition, this);
|
|
}
|
|
else
|
|
{
|
|
// The current density manifest has no wider content record. Keep all consumers
|
|
// supplied with a stable definition object, but do not guess an authored theme.
|
|
Slot.Definition = NewObject<UVoxelStrateDefinition>(this, NAME_None, RF_Transient);
|
|
Slot.Definition->StrateName = FText::FromString(
|
|
FString::Printf(TEXT("Season %d Strate %d"), SeasonManifest.Season, i));
|
|
Slot.Definition->TransitionType = EVoxelStrateTransition::Hard;
|
|
// Passage configuration is not part of schema v2. Inventing the UObject default
|
|
// here would add an unreviewed tunnel to every generated boundary and make the
|
|
// runtime field differ from the field that passed composition. The origin spine
|
|
// remains the guaranteed connection until a later schema explicitly stores these.
|
|
Slot.Definition->PassageConfig.Connections = 0;
|
|
}
|
|
|
|
Slot.Definition->GeneratorType = SeasonStrate.Archetype;
|
|
Slot.Definition->bUseOperatorStack = SeasonStrate.bUsesRecipe;
|
|
Slot.Definition->StrateHeightInChunks = SeasonStrate.HeightInChunks;
|
|
Slot.Definition->GenerationParams = SeasonStrate.Params.TunnelNetworkParams;
|
|
Slot.Definition->SlabParams = SeasonStrate.Params.SlabParams;
|
|
Slot.Definition->MazeParams = SeasonStrate.Params.MazeParams;
|
|
Slot.Definition->SurfaceParams = SeasonStrate.Params.SurfaceParams;
|
|
Slot.Definition->VerticalShaftParams = SeasonStrate.Params.VerticalShaftParams;
|
|
Slot.Definition->FloatingIslandParams = SeasonStrate.Params.FloatingIslandParams;
|
|
|
|
Slot.HeightInChunks = SeasonStrate.HeightInChunks;
|
|
Slot.TopChunkZ = SeasonStrate.TopWorldZ / CHUNK_SIZE - 1;
|
|
Slot.BottomChunkZ = SeasonStrate.BottomWorldZ / CHUNK_SIZE;
|
|
if (SeasonStrate.TopWorldZ % CHUNK_SIZE != 0
|
|
|| SeasonStrate.BottomWorldZ % CHUNK_SIZE != 0
|
|
|| Slot.TopChunkZ != CurrentTopZ
|
|
|| Slot.BottomChunkZ != CurrentTopZ - (Slot.HeightInChunks - 1))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i);
|
|
StrateLayout.Empty();
|
|
SeasonStrates.Empty();
|
|
return false;
|
|
}
|
|
SeasonStrates.Add(SeasonStrate);
|
|
}
|
|
else
|
|
{
|
|
// Pick definition: fixed or from pool
|
|
UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i);
|
|
if (FixedDef && *FixedDef)
|
|
{
|
|
Slot.Definition = *FixedDef;
|
|
}
|
|
else if (ShuffledPool.Num() > 0)
|
|
{
|
|
// Cycle through the pool (wraps around if more strates than pool entries)
|
|
Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value;
|
|
PoolCursor++;
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i);
|
|
continue;
|
|
}
|
|
|
|
// Compute Z range from definition's height
|
|
Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks;
|
|
Slot.TopChunkZ = CurrentTopZ;
|
|
Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1);
|
|
}
|
|
|
|
// Move the cursor down for the next strate, leaving a solid-bedrock gap of
|
|
// InterStrateGapChunks chunks between this strate and the next.
|
|
CurrentTopZ = Slot.BottomChunkZ - 1 - LayoutGapChunks;
|
|
|
|
StrateLayout.Add(Slot);
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Strate %d: '%s' | Z chunks [%d to %d] | %d chunks tall"),
|
|
i,
|
|
*Slot.Definition->StrateName.ToString(),
|
|
Slot.TopChunkZ,
|
|
Slot.BottomChunkZ,
|
|
Slot.HeightInChunks);
|
|
}
|
|
|
|
// Diagnostic de configuration, une seule fois par construction de layout. SurfaceWorld est
|
|
// volontairement exclu : son chemin T1.d exact-lattice ne dépend pas de ce drapeau.
|
|
// Configuration diagnostic once per layout build. SurfaceWorld is deliberately excluded:
|
|
// its exact-lattice T1.d path does not depend on this flag.
|
|
int32 NumCaveSlots = 0;
|
|
int32 NumOperatorStackDisabledCaves = 0;
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
const ECaveGeneratorType SlotArchetype = bUseSeason
|
|
? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType;
|
|
if (!Slot.Definition || SlotArchetype == ECaveGeneratorType::SurfaceWorld)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
++NumCaveSlots;
|
|
const bool bUsesStack = bUseSeason
|
|
? SeasonStrates[Slot.StrateIndex].bUsesRecipe : Slot.Definition->bUseOperatorStack;
|
|
if (!bUsesStack)
|
|
{
|
|
++NumOperatorStackDisabledCaves;
|
|
}
|
|
}
|
|
|
|
// ⚠️ WARNING EN ÉDITEUR/JEU, JAMAIS EN TEST. Les tests `Determinism.*` construisent
|
|
// DÉLIBÉRÉMENT un monde non opt-in — c'est leur oracle de comparaison — et le framework
|
|
// d'automatisation compte un Warning comme un échec. Un diagnostic ne doit pas casser la suite
|
|
// qu'il est censé éclairer. Le message reste écrit UNE fois : seule la verbosité change.
|
|
// Warning in editor/game where it is actionable, never in tests: the Determinism.* tests build
|
|
// a non-opted-in world ON PURPOSE as their comparison oracle, and the automation framework
|
|
// treats a Warning as a failure. One message, two verbosities.
|
|
const bool bQuietDiagnostic = GIsAutomationTesting;
|
|
|
|
if (NumOperatorStackDisabledCaves > 0)
|
|
{
|
|
const FString Summary = FString::Printf(
|
|
TEXT("[StrateManager] Operator-stack opt-in: %d/%d cave layout slots have Use Operator Stack disabled. These slots cannot use operator-stack ClassifyBox/T1.d; enable the asset setting on the listed definitions if that is intended."),
|
|
NumOperatorStackDisabledCaves, NumCaveSlots);
|
|
|
|
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Summary); }
|
|
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Summary); }
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogTemp, Log,
|
|
TEXT("[StrateManager] Operator-stack opt-in: all %d cave layout slots have Use Operator Stack enabled."),
|
|
NumCaveSlots);
|
|
}
|
|
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
const bool bSeasonStack = bUseSeason
|
|
&& SeasonStrates[Slot.StrateIndex].bUsesRecipe;
|
|
const ECaveGeneratorType SlotArchetype = bUseSeason
|
|
? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType;
|
|
if (!Slot.Definition
|
|
|| SlotArchetype == ECaveGeneratorType::SurfaceWorld
|
|
|| (bUseSeason ? bSeasonStack : Slot.Definition->bUseOperatorStack))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FString Line = FString::Printf(
|
|
TEXT("[StrateManager] cave slot=%d name='%s' Z chunks=[%d to %d] bUseOperatorStack=false"),
|
|
Slot.StrateIndex,
|
|
*Slot.Definition->StrateName.ToString(),
|
|
Slot.TopChunkZ,
|
|
Slot.BottomChunkZ);
|
|
|
|
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Line); }
|
|
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Line); }
|
|
}
|
|
|
|
CachedSeed = WorldSeed;
|
|
bOpenSurfaceEntry = Settings->bOpenSurfaceEntry;
|
|
OriginSpineRadius = bUseSeason
|
|
? SeasonManifest.OriginSpineRadius : Settings->OriginSpineRadius;
|
|
InterStrateGapChunks = LayoutGapChunks;
|
|
if (bUseSeason)
|
|
{
|
|
ActiveSeasonContentHash = SeasonManifest.ContentHash;
|
|
}
|
|
// Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig).
|
|
|
|
//=========================================================================
|
|
// STEP 3: Load terrain operation assets
|
|
//=========================================================================
|
|
// Each strate definition references terrain ops as soft pointers.
|
|
// We load them synchronously here so they're available during generation.
|
|
// Without this, BuildParamsFromDefinition's Entry.Operation.Get() would
|
|
// return null if the assets haven't been loaded yet.
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (!Slot.Definition) continue;
|
|
if (bUseSeason && SeasonStrates[Slot.StrateIndex].bUsesRecipe) continue;
|
|
|
|
for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations)
|
|
{
|
|
if (!Entry.Operation.IsNull())
|
|
{
|
|
Entry.Operation.LoadSynchronous();
|
|
}
|
|
}
|
|
}
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Initialized %d strates (seed=%d)"),
|
|
StrateLayout.Num(), WorldSeed);
|
|
|
|
// Generate passages between consecutive strates
|
|
GeneratePassages();
|
|
return bUseSeason ? IsUsingSeason() : true;
|
|
}
|
|
|
|
#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,
|
|
const FVoxelStrateRegionManifest* InRegions)
|
|
{
|
|
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;
|
|
}
|
|
|
|
if (InRegions != nullptr && InRegions->RegionCount > 1)
|
|
{
|
|
if (!InRegions->IsValid())
|
|
{
|
|
OutError = InRegions->FailureReason.IsEmpty()
|
|
? TEXT("The composer returned an invalid lateral region manifest.")
|
|
: InRegions->FailureReason;
|
|
return false;
|
|
}
|
|
Override->bUseRegions = true;
|
|
Override->Regions = *InRegions;
|
|
VF_RekeyStrateRegionManifest(Override->Regions, CandidateSeed, StrateIndex);
|
|
Override->Regions.StrateTopWorldZ = (float)(TargetSlot->TopChunkZ + 1) * CHUNK_SIZE;
|
|
Override->Regions.StrateBottomWorldZ = (float)TargetSlot->BottomChunkZ * CHUNK_SIZE;
|
|
VF_SetStrateArchetypeRuntimeBounds(Override->Params,
|
|
Override->Regions.StrateTopWorldZ,
|
|
Override->Regions.StrateBottomWorldZ);
|
|
for (FVoxelStrateRegion& Region : Override->Regions.Regions)
|
|
{
|
|
VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams,
|
|
Override->Regions.StrateTopWorldZ,
|
|
Override->Regions.StrateBottomWorldZ);
|
|
}
|
|
Override->Regions.bHasGlobalStructuralParams = true;
|
|
}
|
|
|
|
// 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::GetComposerRegionOverrideForChunk(
|
|
const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const
|
|
{
|
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVoxelStrateComposerSlotOverride* Override =
|
|
FindComposerOverride(StrateLayout[SlotIdx].StrateIndex);
|
|
if (Override == nullptr || !Override->bUseRegions)
|
|
{
|
|
return false;
|
|
}
|
|
OutRegions = Override->Regions;
|
|
return true;
|
|
}
|
|
|
|
#endif
|
|
|
|
bool UVoxelStrateManager::GetRecipeForChunk(
|
|
const FIntVector& ChunkCoord, int32& OutRecipeSeed,
|
|
ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams,
|
|
FVoxelOpStackRecipe& OutRecipe) const
|
|
{
|
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0) return false;
|
|
|
|
#if WITH_EDITOR
|
|
if (const FVoxelStrateComposerSlotOverride* Override =
|
|
FindComposerOverride(StrateLayout[SlotIdx].StrateIndex))
|
|
{
|
|
if (!Override->bUseRecipe || Override->bUseRegions) return false;
|
|
OutRecipeSeed = Override->CandidateSeed;
|
|
OutArchetype = Override->Archetype;
|
|
OutParams = Override->Params;
|
|
OutRecipe = Override->Recipe;
|
|
return true;
|
|
}
|
|
#endif
|
|
if (SeasonStrates.IsValidIndex(SlotIdx))
|
|
{
|
|
const FVoxelSeasonStrate& Strate = SeasonStrates[SlotIdx];
|
|
if (!Strate.bUsesRecipe || Strate.bUsesRegions) return false;
|
|
OutRecipeSeed = Strate.Seed;
|
|
OutArchetype = Strate.Archetype;
|
|
OutParams = Strate.Params;
|
|
OutRecipe = Strate.Recipe;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
//=============================================================================
|
|
// PASSAGE GENERATION
|
|
//=============================================================================
|
|
|
|
void UVoxelStrateManager::GeneratePassages()
|
|
{
|
|
Passages.Empty();
|
|
|
|
if (StrateLayout.Num() < 1) return;
|
|
|
|
// Deterministic per-value hashes from the world seed. Every draw is keyed by its boundary
|
|
// strate index, connection index, and a unique salt, so one passage cannot shift another.
|
|
// Hachages déterministes par valeur depuis le seed du monde. Chaque tirage est indexé par la
|
|
// strate de frontière, la connexion et un sel unique : un passage ne peut plus décaler l'autre.
|
|
const uint32 PassageSeed = static_cast<uint32>(CachedSeed) ^ 0x50A55A6Eu; // "PASSAGE"
|
|
constexpr uint32 PassageSaltAngle = 0xA1100001u;
|
|
constexpr uint32 PassageSaltDistance = 0xA1100002u;
|
|
constexpr uint32 PassageSaltUpperReach = 0xA1100003u;
|
|
constexpr uint32 PassageSaltLowerReach = 0xA1100004u;
|
|
constexpr uint32 PassageSaltWormFreq = 0xA1100005u;
|
|
constexpr uint32 PassageSaltNoiseX = 0xA1100006u;
|
|
constexpr uint32 PassageSaltNoiseY = 0xA1100007u;
|
|
constexpr uint32 PassageSaltNoiseZ = 0xA1100008u;
|
|
constexpr uint32 PassageSaltPhase = 0xA1100009u;
|
|
constexpr uint32 PassageSaltBendFreq = 0xA110000Au;
|
|
constexpr uint32 PassageSaltUpperDoor = 0xA110000Bu;
|
|
constexpr uint32 PassageSaltLowerDoor = 0xA110000Cu;
|
|
|
|
int32 TotalPassages = 0;
|
|
int32 NumAimedAtUpperPlayerFit = 0;
|
|
int32 NumAimedAtLowerPlayerFit = 0;
|
|
int32 NumUpperRootConnectors = 0;
|
|
int32 NumLowerRootConnectors = 0;
|
|
TSet<FString> NoQueryArchetypes;
|
|
|
|
const auto ArchetypeName = [](ECaveGeneratorType Archetype)
|
|
{
|
|
if (const UEnum* ArchetypeEnum = StaticEnum<ECaveGeneratorType>())
|
|
{
|
|
return ArchetypeEnum->GetNameStringByValue(static_cast<int64>(Archetype));
|
|
}
|
|
return FString::Printf(TEXT("Value_%d"), static_cast<int32>(Archetype));
|
|
};
|
|
|
|
const auto MaxLateralSnapFor = [](const UVoxelStrateDefinition& Definition) -> float
|
|
{
|
|
switch (Definition.GeneratorType)
|
|
{
|
|
case ECaveGeneratorType::Maze:
|
|
// One maze lattice cell is the largest deliberate correction. It keeps a shortcut
|
|
// within the same local maze neighborhood and protects its configured spine-distance
|
|
// distribution from silently becoming a long-range teleport.
|
|
return FMath::Max(Definition.MazeParams.CellSize, 1.0f);
|
|
|
|
case ECaveGeneratorType::VerticalShafts:
|
|
// A density of 0.6 does not guarantee an occupied cell in the immediate grid
|
|
// neighbourhood. Allow the nearest occupied shaft to be two grid cells away while
|
|
// remaining a bounded local query; the landing query still returns a pose inside
|
|
// that shaft's deterministic feature core, so this cannot create a non-topological
|
|
// mouth.
|
|
return FMath::Max(2.0f * Definition.VerticalShaftParams.ShaftSpacing, 1.0f);
|
|
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
// One island grid spacing protects the intentional radial placement while still
|
|
// allowing a passage to reach a neighboring blob rather than an arbitrary far island.
|
|
return FMath::Max(Definition.FloatingIslandParams.IslandSpacing, 1.0f);
|
|
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
case ECaveGeneratorType::Underwater:
|
|
// Une salle est une CIBLE ÉPARSE en XY, exactement comme un couloir de labyrinthe : le
|
|
// budget doit donc être réel. Zéro ici était un bug — la requête trouvait la salle la
|
|
// plus proche puis atterrissait à côté d'elle dans 92,8 % des cas (balayage d'un million
|
|
// de seeds). Un espacement de salles est le voisinage local naturel.
|
|
//
|
|
// A room is an XY-SPARSE target, exactly like a maze corridor, so the budget must be
|
|
// real. Zero here was the bug: the query found the nearest room and then landed beside
|
|
// it 92.8% of the time (million-seed sweep). One room spacing is the natural local
|
|
// neighbourhood, and it protects the configured spine-distance distribution.
|
|
return FMath::Max(Definition.GenerationParams.RoomSpacing, 1.0f);
|
|
|
|
case ECaveGeneratorType::FlatPlain:
|
|
case ECaveGeneratorType::CrystalChamber:
|
|
default:
|
|
// Les slabs sont une bande de vide CONTINUE en XY : le XY demandé est déjà à
|
|
// l'intérieur, donc aucun déplacement latéral n'est nécessaire. Zéro est correct ici.
|
|
// A slab's void band is XY-CONTINUOUS, so the requested XY is already inside it and no
|
|
// lateral movement is needed. Zero is correct here, unlike for rooms.
|
|
return 0.0f;
|
|
}
|
|
};
|
|
|
|
const auto BoundarySealThicknessFor = [](const UVoxelStrateDefinition& Definition) -> float
|
|
{
|
|
switch (Definition.GeneratorType)
|
|
{
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
case ECaveGeneratorType::Underwater:
|
|
return Definition.GenerationParams.BoundarySealThickness;
|
|
|
|
case ECaveGeneratorType::FlatPlain:
|
|
case ECaveGeneratorType::CrystalChamber:
|
|
return Definition.SlabParams.BoundarySealThickness;
|
|
|
|
case ECaveGeneratorType::Maze:
|
|
return Definition.MazeParams.BoundarySealThickness;
|
|
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
return Definition.SurfaceParams.BoundarySealThickness;
|
|
|
|
case ECaveGeneratorType::VerticalShafts:
|
|
return Definition.VerticalShaftParams.BoundarySealThickness;
|
|
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
return Definition.FloatingIslandParams.BoundarySealThickness;
|
|
|
|
default:
|
|
return 0.0f;
|
|
}
|
|
};
|
|
|
|
const auto CommonRootFloorFor = [](float StrateTopZ, float StrateBottomZ,
|
|
float BoundarySealThickness) -> float
|
|
{
|
|
// The two mouth queries may legitimately return floors near opposite strate faces. A
|
|
// shared root level is therefore derived from the strate bounds, never from passage
|
|
// iteration order. Leave the floor slab and the full 12-voxel connector clearance beyond
|
|
// the four-voxel passage blend away from both seals; the landing builder repeats this
|
|
// clamp as its final safety boundary.
|
|
const float Seal = FMath::Max(BoundarySealThickness, 0.0f);
|
|
const float InnerBottom = StrateBottomZ + Seal;
|
|
const float InnerTop = StrateTopZ - Seal;
|
|
const float ConnectorHeight = FMath::Max(
|
|
12.0f, FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f);
|
|
constexpr float PassageBlendSafety = 4.0f;
|
|
const float MinimumFloor = InnerBottom + 3.0f + PassageBlendSafety;
|
|
const float MaximumFloor = InnerTop - ConnectorHeight - PassageBlendSafety;
|
|
if (FMath::IsFinite(MinimumFloor) && FMath::IsFinite(MaximumFloor)
|
|
&& MaximumFloor >= MinimumFloor)
|
|
{
|
|
return 0.5f * (MinimumFloor + MaximumFloor);
|
|
}
|
|
return 0.5f * (InnerBottom + InnerTop);
|
|
};
|
|
|
|
//=========================================================================
|
|
// INTER-STRATE PASSAGES: tunnels connecting consecutive strates.
|
|
// Each passage is randomly assigned one of 5 types, which determines
|
|
// its shape, radius, and control point layout.
|
|
//=========================================================================
|
|
for (int32 i = 0; i < StrateLayout.Num() - 1; i++)
|
|
{
|
|
const FStrateSlot& Upper = StrateLayout[i];
|
|
const FStrateSlot& Lower = StrateLayout[i + 1];
|
|
|
|
// This (upper) strate's PassageConfig controls the descent tunnels to the layer
|
|
// below. The (0,0) spine descent is separate (player-dug); these are the shortcuts.
|
|
const UVoxelStrateDefinition* UpperDef = Upper.Definition;
|
|
if (!UpperDef) continue;
|
|
const FStratePassageConfig& Cfg = UpperDef->PassageConfig;
|
|
|
|
// Upper strate floor and lower strate ceiling (differ when there's a bedrock gap).
|
|
const float UpperTopZ = (float)(Upper.TopChunkZ + 1) * CHUNK_SIZE;
|
|
const float UpperBottomZ = (float)(Upper.BottomChunkZ) * CHUNK_SIZE;
|
|
const float LowerTopZ = (float)(Lower.TopChunkZ + 1) * CHUNK_SIZE;
|
|
const float UpperMax = (float)Upper.HeightInChunks * CHUNK_SIZE * 0.9f;
|
|
const float LowerMax = (float)Lower.HeightInChunks * CHUNK_SIZE * 0.9f;
|
|
|
|
const float DistLo = FMath::Min(Cfg.DistanceMin, Cfg.DistanceMax);
|
|
const float DistHi = FMath::Max(Cfg.DistanceMin, Cfg.DistanceMax);
|
|
|
|
const int32 Conns = FMath::Max(0, Cfg.Connections);
|
|
for (int32 c = 0; c < Conns; c++)
|
|
{
|
|
const auto PassageRandom01 = [PassageSeed, i, c](uint32 Salt)
|
|
{
|
|
return VoxelHash::ToFloat01(VoxelHash::Cell(i, c, PassageSeed ^ Salt));
|
|
};
|
|
const auto PassageRandomRange = [&PassageRandom01](float Min, float Max, uint32 Salt)
|
|
{
|
|
return FMath::Lerp(Min, Max, PassageRandom01(Salt));
|
|
};
|
|
|
|
FVoxelPassage Passage;
|
|
Passage.UpperStrateIndex = i;
|
|
Passage.LowerStrateIndex = i + 1;
|
|
// PassageType has no random draw in the current implementation; preserve its existing
|
|
// default while PassageConfig::Style controls the generated control-point shape.
|
|
// PassageType n'a pas de tirage aléatoire ici ; conserver son défaut, tandis que
|
|
// PassageConfig::Style contrôle la forme des points de contrôle générés.
|
|
|
|
// PLACEMENT: random angle, distance from the (0,0) spine within config range.
|
|
const float Angle = PassageRandom01(PassageSaltAngle) * (2.0f * PI);
|
|
const float Distance = PassageRandomRange(DistLo, DistHi, PassageSaltDistance);
|
|
const float PX = FMath::Cos(Angle) * Distance;
|
|
const float PY = FMath::Sin(Angle) * Distance;
|
|
|
|
++TotalPassages;
|
|
|
|
// Ask each mouth's own strate for a source-level landing point. These queries are pure
|
|
// and safe during Initialize: they do not construct an operator stack or call back
|
|
// into the manager. Cave room graphs use the queried strate's seed from
|
|
// MakeStrateSeed; slab, maze, shaft, and island fields use the world seed consumed by
|
|
// their source. The two queries are deliberately independent: a passage never reads
|
|
// another passage's endpoint or any live layout state.
|
|
FVector SuggestedUpperPoint = FVector::ZeroVector;
|
|
bool bAimedUpperAtPlayerFitPoint = false;
|
|
if (UpperDef)
|
|
{
|
|
const bool bUsesRoomSeed =
|
|
UpperDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
|
|| UpperDef->GeneratorType == ECaveGeneratorType::Underwater;
|
|
const int32 UpperQuerySeed = bUsesRoomSeed
|
|
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
|
static_cast<uint32>(CachedSeed), Upper.StrateIndex))
|
|
: CachedSeed;
|
|
|
|
bAimedUpperAtPlayerFitPoint = VF_SuggestLandingPoint(
|
|
UpperDef->GeneratorType,
|
|
UpperDef->GenerationParams,
|
|
UpperDef->SlabParams,
|
|
UpperDef->MazeParams,
|
|
UpperDef->VerticalShaftParams,
|
|
UpperDef->FloatingIslandParams,
|
|
UpperQuerySeed,
|
|
UpperTopZ,
|
|
UpperBottomZ,
|
|
PX,
|
|
PY,
|
|
MaxLateralSnapFor(*UpperDef),
|
|
SuggestedUpperPoint);
|
|
|
|
if (bAimedUpperAtPlayerFitPoint)
|
|
{
|
|
++NumAimedAtUpperPlayerFit;
|
|
}
|
|
else
|
|
{
|
|
NoQueryArchetypes.Add(ArchetypeName(UpperDef->GeneratorType));
|
|
}
|
|
}
|
|
|
|
FVector SuggestedLowerPoint = FVector::ZeroVector;
|
|
bool bAimedLowerAtPlayerFitPoint = false;
|
|
const UVoxelStrateDefinition* LowerDef = Lower.Definition;
|
|
if (LowerDef)
|
|
{
|
|
const bool bUsesRoomSeed =
|
|
LowerDef->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
|
|| LowerDef->GeneratorType == ECaveGeneratorType::Underwater;
|
|
const int32 LowerQuerySeed = bUsesRoomSeed
|
|
? static_cast<int32>(VoxelCaveMorphology::MakeStrateSeed(
|
|
static_cast<uint32>(CachedSeed), Lower.StrateIndex))
|
|
: CachedSeed;
|
|
|
|
const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef);
|
|
bAimedLowerAtPlayerFitPoint = VF_SuggestLandingPoint(
|
|
LowerDef->GeneratorType,
|
|
LowerDef->GenerationParams,
|
|
LowerDef->SlabParams,
|
|
LowerDef->MazeParams,
|
|
LowerDef->VerticalShaftParams,
|
|
LowerDef->FloatingIslandParams,
|
|
LowerQuerySeed,
|
|
LowerTopZ,
|
|
(float)(Lower.BottomChunkZ) * CHUNK_SIZE,
|
|
PX,
|
|
PY,
|
|
MaxLateralSnap,
|
|
SuggestedLowerPoint);
|
|
|
|
if (bAimedLowerAtPlayerFitPoint)
|
|
{
|
|
++NumAimedAtLowerPlayerFit;
|
|
}
|
|
else
|
|
{
|
|
NoQueryArchetypes.Add(ArchetypeName(LowerDef->GeneratorType));
|
|
}
|
|
}
|
|
|
|
// LENGTH: reach into each strate, capped to the interior.
|
|
const float UpperReach = FMath::Min(
|
|
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax);
|
|
const float LowerReach = FMath::Min(
|
|
PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax);
|
|
float TopZ = UpperBottomZ + UpperReach;
|
|
float BottomZ = LowerTopZ - LowerReach;
|
|
float UpperX = PX;
|
|
float UpperY = PY;
|
|
float LowerX = PX;
|
|
float LowerY = PY;
|
|
Passage.RequestedUpperPoint = FVector(PX, PY, TopZ);
|
|
Passage.RequestedLowerPoint = FVector(PX, PY, BottomZ);
|
|
|
|
if (bAimedUpperAtPlayerFitPoint)
|
|
{
|
|
const float UpperSealThickness = BoundarySealThicknessFor(*UpperDef);
|
|
const float InnerBottomZ = UpperBottomZ + UpperSealThickness;
|
|
const float InnerTopZ = UpperTopZ - UpperSealThickness;
|
|
if (FMath::IsFinite(SuggestedUpperPoint.X)
|
|
&& FMath::IsFinite(SuggestedUpperPoint.Y)
|
|
&& FMath::IsFinite(SuggestedUpperPoint.Z)
|
|
&& FMath::IsFinite(InnerBottomZ)
|
|
&& FMath::IsFinite(InnerTopZ)
|
|
&& InnerBottomZ < InnerTopZ)
|
|
{
|
|
// VF_SuggestLandingPoint already guarantees a strict interior answer. Keep a
|
|
// tiny margin in the final clamp so a future source query cannot land on a
|
|
// seal boundary through rounding.
|
|
UpperX = SuggestedUpperPoint.X;
|
|
UpperY = SuggestedUpperPoint.Y;
|
|
const float StrictMargin = FMath::Min(
|
|
KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f);
|
|
TopZ = FMath::Clamp(
|
|
SuggestedUpperPoint.Z,
|
|
InnerBottomZ + StrictMargin,
|
|
InnerTopZ - StrictMargin);
|
|
}
|
|
}
|
|
|
|
if (bAimedLowerAtPlayerFitPoint)
|
|
{
|
|
const float LowerSealThickness = BoundarySealThicknessFor(*LowerDef);
|
|
const float LowerBottomZ = (float)(Lower.BottomChunkZ) * CHUNK_SIZE;
|
|
const float InnerBottomZ = LowerBottomZ + LowerSealThickness;
|
|
const float InnerTopZ = LowerTopZ - LowerSealThickness;
|
|
if (FMath::IsFinite(SuggestedLowerPoint.X)
|
|
&& FMath::IsFinite(SuggestedLowerPoint.Y)
|
|
&& FMath::IsFinite(SuggestedLowerPoint.Z)
|
|
&& FMath::IsFinite(InnerBottomZ)
|
|
&& FMath::IsFinite(InnerTopZ)
|
|
&& InnerBottomZ < InnerTopZ)
|
|
{
|
|
// VF_SuggestLandingPoint already guarantees a strict interior answer. Keep a
|
|
// tiny margin in the final clamp so a future source query cannot land on a
|
|
// seal boundary through rounding.
|
|
LowerX = SuggestedLowerPoint.X;
|
|
LowerY = SuggestedLowerPoint.Y;
|
|
const float StrictMargin = FMath::Min(
|
|
KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f);
|
|
BottomZ = FMath::Clamp(
|
|
SuggestedLowerPoint.Z,
|
|
InnerBottomZ + StrictMargin,
|
|
InnerTopZ - StrictMargin);
|
|
}
|
|
}
|
|
|
|
const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48);
|
|
Passage.ControlPoints.Reset();
|
|
Passage.ControlRadii.Reset();
|
|
Passage.ControlPoints.Reserve(Segments + 1);
|
|
Passage.ControlRadii.Reserve(Segments + 1);
|
|
|
|
// WIDTH profile: mouth radius at the ends, mid radius in the centre (taper/bulge).
|
|
auto RadiusAt = [&](float t) { return FMath::Lerp(Cfg.MouthRadius, Cfg.MidRadius, FMath::Sin(t * PI)); };
|
|
|
|
// Per-passage shape seeds.
|
|
const float WormFreq = PassageRandomRange(0.8f, 1.8f, PassageSaltWormFreq); // (vertical wobble only)
|
|
const float NSeedX = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseX);
|
|
const float NSeedY = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseY);
|
|
const float NSeedZ = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseZ);
|
|
const float PhaseA = PassageRandom01(PassageSaltPhase) * (2.0f * PI);
|
|
// Base fBM frequency for the worm's wander (octaves add finer detail on top).
|
|
const float BendFreq = PassageRandomRange(1.5f, 2.5f, PassageSaltBendFreq);
|
|
|
|
for (int32 s = 0; s <= Segments; s++)
|
|
{
|
|
const float T = (float)s / (float)Segments;
|
|
float Z = FMath::Lerp(TopZ, BottomZ, T);
|
|
const float Env = FMath::Sin(T * PI); // 0 at both ends → mouths stay anchored
|
|
float OX = 0.0f, OY = 0.0f;
|
|
|
|
switch (Cfg.Style)
|
|
{
|
|
case EVoxelPassageStyle::Straight:
|
|
// No authored lateral offset. A snapped lower mouth can still make this
|
|
// segment slanted, so the endpoint interpolation below remains intentional.
|
|
break;
|
|
|
|
case EVoxelPassageStyle::Spiral:
|
|
{
|
|
const float Ang = PhaseA + T * Cfg.SpiralTurns * 2.0f * PI;
|
|
OX = FMath::Cos(Ang) * Cfg.SpiralRadius * Env;
|
|
OY = FMath::Sin(Ang) * Cfg.SpiralRadius * Env;
|
|
break;
|
|
}
|
|
|
|
case EVoxelPassageStyle::Cascading:
|
|
{
|
|
// Switchback staircase: each tread offsets in a new deterministic direction.
|
|
const int32 Steps = FMath::Clamp(Cfg.CascadeSteps, 1, 16);
|
|
const int32 Idx = FMath::Min((int32)(T * Steps), Steps - 1);
|
|
const float SA = PhaseA + (float)Idx * 2.39996f; // golden-angle spread
|
|
OX = FMath::Cos(SA) * Cfg.CascadeLedge * Env;
|
|
OY = FMath::Sin(SA) * Cfg.CascadeLedge * Env;
|
|
break;
|
|
}
|
|
|
|
case EVoxelPassageStyle::Worm:
|
|
default:
|
|
{
|
|
// SQUIRM: displace the descent independently on X and Y with multi-octave
|
|
// fBM (different seeds → uncorrelated). Independent fBM per axis is a true
|
|
// 2D organic wander — it curls and meanders "here and there" rather than
|
|
// oscillating along one line (zig-zag) or orbiting the axis (spiral).
|
|
// Flat-top envelope keeps full motion along the length but anchors the mouths.
|
|
const float WormEnv = FMath::Clamp(FMath::Sin(T * PI) * 3.0f, 0.0f, 1.0f);
|
|
OX = PassageFBM(T * BendFreq, NSeedX) * VOXEL_NOISE_SCALE * Cfg.Wander * WormEnv;
|
|
OY = PassageFBM(T * BendFreq, NSeedY + 53.0f) * VOXEL_NOISE_SCALE * Cfg.Wander * WormEnv;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Vertical wobble (all styles): dips/rises along the descent, anchored at ends.
|
|
if (Cfg.VerticalWobble > 0.0f)
|
|
{
|
|
const float NZ = FMath::PerlinNoise3D(FVector(T * WormFreq * 1.3f + NSeedZ, NSeedZ * 0.5f, 27.0f));
|
|
Z += NZ * VOXEL_NOISE_SCALE * Cfg.VerticalWobble * Env;
|
|
}
|
|
|
|
const float BaseX = FMath::Lerp(UpperX, LowerX, T);
|
|
const float BaseY = FMath::Lerp(UpperY, LowerY, T);
|
|
FVector ControlPoint(BaseX + OX, BaseY + OY, Z);
|
|
// The style envelope is mathematically zero at a mouth, but evaluating sin(PI)
|
|
// leaves a tiny float residue. Pin both endpoints so the full query result is the
|
|
// actual aimed mouth, including any lateral snap, and never merely an approximation.
|
|
if (s == 0)
|
|
{
|
|
ControlPoint = FVector(UpperX, UpperY, TopZ);
|
|
}
|
|
else if (s == Segments)
|
|
{
|
|
ControlPoint = FVector(LowerX, LowerY, BottomZ);
|
|
}
|
|
Passage.ControlPoints.Add(ControlPoint);
|
|
Passage.ControlRadii.Add(RadiusAt(T));
|
|
}
|
|
|
|
// The old control-point endpoints were also the player's standing points. That made
|
|
// a sloped/vertical tube open directly under the capsule. Keep the queried points as
|
|
// explicit standing anchors, and move only the tube's first/last point to a doorway
|
|
// tangent to the landing floor. The endpoint-to-anchor distinction is the geometry
|
|
// that turns a mouth into a place.
|
|
auto DoorDirectionFrom = [](const FVector& Delta, float Angle) -> FVector
|
|
{
|
|
FVector Direction(Delta.X, Delta.Y, 0.0f);
|
|
if (!Direction.Normalize())
|
|
{
|
|
Direction = FVector(FMath::Cos(Angle), FMath::Sin(Angle), 0.0f);
|
|
if (!Direction.Normalize())
|
|
{
|
|
Direction = FVector(1.0f, 0.0f, 0.0f);
|
|
}
|
|
}
|
|
return Direction;
|
|
};
|
|
|
|
const FVector UpperDoorDirection = DoorDirectionFrom(
|
|
Passage.ControlPoints.Num() > 1
|
|
? Passage.ControlPoints[1] - Passage.ControlPoints[0]
|
|
: FVector::ZeroVector,
|
|
PassageRandom01(PassageSaltUpperDoor) * 2.0f * PI);
|
|
const FVector LowerDoorDirection = DoorDirectionFrom(
|
|
Passage.ControlPoints.Num() > 1
|
|
? Passage.ControlPoints[Passage.ControlPoints.Num() - 2]
|
|
- Passage.ControlPoints.Last()
|
|
: FVector::ZeroVector,
|
|
PassageRandom01(PassageSaltLowerDoor) * 2.0f * PI);
|
|
|
|
// A source-fit answer proves only a local pose; none of the pure queries performs a
|
|
// flood fill over the live strate. Every mouth therefore receives the same explicit
|
|
// flat-floor connector to the deterministic root, including answered mouths. This
|
|
// is what makes the join guaranteed rather than merely probable.
|
|
Passage.UpperLanding = VF_BuildPassageLanding(
|
|
FVector(UpperX, UpperY, TopZ),
|
|
Cfg.MouthRadius,
|
|
UpperDoorDirection,
|
|
UpperTopZ,
|
|
UpperBottomZ,
|
|
UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f,
|
|
bAimedUpperAtPlayerFitPoint,
|
|
/*bHasNetworkConnector=*/true,
|
|
FVector(0.0f, 0.0f,
|
|
CommonRootFloorFor(
|
|
UpperTopZ, UpperBottomZ,
|
|
UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f)),
|
|
OriginSpineRadius);
|
|
Passage.LowerLanding = VF_BuildPassageLanding(
|
|
FVector(LowerX, LowerY, BottomZ),
|
|
Cfg.MouthRadius,
|
|
LowerDoorDirection,
|
|
LowerTopZ,
|
|
(float)(Lower.BottomChunkZ) * CHUNK_SIZE,
|
|
LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f,
|
|
bAimedLowerAtPlayerFitPoint,
|
|
/*bHasNetworkConnector=*/true,
|
|
FVector(0.0f, 0.0f,
|
|
CommonRootFloorFor(
|
|
LowerTopZ, (float)(Lower.BottomChunkZ) * CHUNK_SIZE,
|
|
LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f)),
|
|
OriginSpineRadius);
|
|
Passage.UpperPoint = Passage.UpperLanding.StandingPoint;
|
|
Passage.LowerPoint = Passage.LowerLanding.StandingPoint;
|
|
if (Passage.UpperLanding.bHasNetworkConnector) ++NumUpperRootConnectors;
|
|
if (Passage.LowerLanding.bHasNetworkConnector) ++NumLowerRootConnectors;
|
|
|
|
if (Passage.ControlPoints.Num() > 0)
|
|
{
|
|
Passage.ControlPoints[0] = Passage.UpperLanding.DoorPoint;
|
|
Passage.ControlPoints.Last() = Passage.LowerLanding.DoorPoint;
|
|
}
|
|
Passage.Radius = 0.0f;
|
|
for (const float ControlRadius : Passage.ControlRadii)
|
|
{
|
|
Passage.Radius = FMath::Max(Passage.Radius, ControlRadius);
|
|
}
|
|
// Fallback / bounds if an invalid authored width produced no positive profile.
|
|
Passage.Radius = FMath::Max(Passage.Radius, FMath::Max(Cfg.MouthRadius, Cfg.MidRadius));
|
|
Passage.Radius = FMath::Max(Passage.Radius,
|
|
FMath::Max(Passage.UpperLanding.ConnectorRadius,
|
|
Passage.LowerLanding.ConnectorRadius));
|
|
|
|
// Bounding sphere over the tube, both rooms, both floor slabs, and both optional root
|
|
// connectors (+ widest radius + blend) for culling. Under-sizing this sphere would
|
|
// cull a real landing and leave a sealed pocket, so the room's full box diagonal is
|
|
// included rather than treating the standing anchor as a point.
|
|
{
|
|
FVector BoundsMin(FLT_MAX, FLT_MAX, FLT_MAX);
|
|
FVector BoundsMax(-FLT_MAX, -FLT_MAX, -FLT_MAX);
|
|
auto IncludePoint = [&BoundsMin, &BoundsMax](const FVector& Point, float Pad)
|
|
{
|
|
BoundsMin.X = FMath::Min(BoundsMin.X, Point.X - Pad);
|
|
BoundsMin.Y = FMath::Min(BoundsMin.Y, Point.Y - Pad);
|
|
BoundsMin.Z = FMath::Min(BoundsMin.Z, Point.Z - Pad);
|
|
BoundsMax.X = FMath::Max(BoundsMax.X, Point.X + Pad);
|
|
BoundsMax.Y = FMath::Max(BoundsMax.Y, Point.Y + Pad);
|
|
BoundsMax.Z = FMath::Max(BoundsMax.Z, Point.Z + Pad);
|
|
};
|
|
for (const FVector& CP : Passage.ControlPoints)
|
|
{
|
|
IncludePoint(CP, Passage.Radius + 4.0f);
|
|
}
|
|
const auto IncludeLanding = [&IncludePoint](const FVoxelPassageLanding& Landing)
|
|
{
|
|
const float Height = FMath::Max(Landing.CeilingZ - Landing.FloorZ, 0.0f);
|
|
const FVector RoomCenter(
|
|
Landing.StandingPoint.X,
|
|
Landing.StandingPoint.Y,
|
|
(Landing.FloorZ + Landing.CeilingZ) * 0.5f);
|
|
const float RoomRadius = FMath::Sqrt(
|
|
2.0f * FMath::Square(Landing.HalfWidth)
|
|
+ 0.25f * FMath::Square(Height))
|
|
+ Landing.FloorThickness + 4.0f;
|
|
IncludePoint(RoomCenter, RoomRadius);
|
|
if (Landing.bHasNetworkConnector)
|
|
{
|
|
const float HubRadius = Landing.RootSpineRadius
|
|
+ Landing.ConnectorRadius;
|
|
const float HubHalfHeight = 0.5f * FMath::Max(
|
|
Landing.RootCeilingZ - Landing.RootFloorZ, 0.0f);
|
|
IncludePoint(FVector(0.0f, 0.0f,
|
|
(Landing.RootFloorZ + Landing.RootCeilingZ) * 0.5f),
|
|
FMath::Sqrt(FMath::Square(HubRadius)
|
|
+ FMath::Square(HubHalfHeight))
|
|
+ Landing.FloorThickness + 4.0f);
|
|
const float ConnectorHalfHeight = FMath::Max(
|
|
0.5f * FMath::Max(
|
|
Landing.ConnectorCeilingZ - Landing.FloorZ, 0.0f),
|
|
Landing.ConnectorRadius);
|
|
IncludePoint(Landing.ConnectorStart,
|
|
ConnectorHalfHeight + 4.0f);
|
|
if (Landing.bHasConnectorBend)
|
|
{
|
|
IncludePoint(Landing.ConnectorControl,
|
|
ConnectorHalfHeight + 4.0f);
|
|
}
|
|
IncludePoint(Landing.ConnectorEnd,
|
|
ConnectorHalfHeight + 4.0f);
|
|
}
|
|
};
|
|
IncludeLanding(Passage.UpperLanding);
|
|
IncludeLanding(Passage.LowerLanding);
|
|
|
|
const FVector Center = (BoundsMin + BoundsMax) * 0.5f;
|
|
float MaxDistSq = 0.0f;
|
|
MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, BoundsMin));
|
|
MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, BoundsMax));
|
|
const float R = FMath::Sqrt(MaxDistSq);
|
|
Passage.BoundCenter = Center;
|
|
Passage.BoundRadius = R;
|
|
Passage.BoundRadiusSq = R * R;
|
|
}
|
|
|
|
Passages.Add(Passage);
|
|
}
|
|
}
|
|
|
|
TArray<FString> SortedNoQueryArchetypes;
|
|
for (const FString& Archetype : NoQueryArchetypes)
|
|
{
|
|
SortedNoQueryArchetypes.Add(Archetype);
|
|
}
|
|
SortedNoQueryArchetypes.Sort();
|
|
FString NoQueryList = TEXT("none");
|
|
if (SortedNoQueryArchetypes.Num() > 0)
|
|
{
|
|
NoQueryList = FString::Join(SortedNoQueryArchetypes, TEXT(", "));
|
|
}
|
|
|
|
UE_LOG(LogTemp, Log,
|
|
TEXT("[StrateManager] Passage landings: upper %d/%d and lower %d/%d source-fit; root floor connectors %d/%d; %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."),
|
|
NumAimedAtUpperPlayerFit,
|
|
TotalPassages,
|
|
NumAimedAtLowerPlayerFit,
|
|
TotalPassages,
|
|
NumUpperRootConnectors,
|
|
NumLowerRootConnectors,
|
|
(TotalPassages * 2) - NumAimedAtUpperPlayerFit - NumAimedAtLowerPlayerFit,
|
|
TotalPassages * 2,
|
|
*NoQueryList);
|
|
|
|
//=========================================================================
|
|
// SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection.
|
|
// A straight vertical shaft at (0,0) piercing the TOP seal of the topmost
|
|
// strate, so the world begins with "a hole opened to the surface". All other
|
|
// (0,0) descents between strates remain player-dug.
|
|
//=========================================================================
|
|
if (bOpenSurfaceEntry && StrateLayout.Num() > 0)
|
|
{
|
|
const FStrateSlot& Top = StrateLayout[0];
|
|
const float TopZ = (float)(Top.TopChunkZ + 1) * CHUNK_SIZE;
|
|
|
|
FVoxelPassage Entry;
|
|
Entry.UpperStrateIndex = 0;
|
|
Entry.LowerStrateIndex = 0;
|
|
Entry.PassageType = EVoxelPassageType::VerticalShaft;
|
|
Entry.Radius = FMath::Max(OriginSpineRadius * 0.7f, 4.0f);
|
|
// From a little above the strate top (open air outside all strates) down
|
|
// past the seal into the interior, so the seal at (0,0) is breached.
|
|
Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE);
|
|
Entry.LowerPoint = FVector(0.0f, 0.0f, TopZ - CHUNK_SIZE);
|
|
{
|
|
const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f;
|
|
const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f;
|
|
Entry.BoundCenter = C;
|
|
Entry.BoundRadius = R;
|
|
Entry.BoundRadiusSq = R * R;
|
|
}
|
|
Passages.Add(Entry);
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Surface entry shaft at (0,0) topZ=%.0f R=%.1f"),
|
|
TopZ, Entry.Radius);
|
|
}
|
|
|
|
// Invalidate any thread_local per-chunk passage shortlists (see EvaluateModifierSDF).
|
|
++PassagesVersion;
|
|
}
|
|
|
|
//=============================================================================
|
|
// MODIFIER SDF (inter-strate passages)
|
|
//=============================================================================
|
|
|
|
float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const
|
|
{
|
|
const FIntVector ChunkCoord(
|
|
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE));
|
|
|
|
// The shortlist is rebuilt once for a (manager, version, chunk) and is shared by the tube,
|
|
// landing, and floor paths. No source-fit stencil or topology search is allowed below it.
|
|
const TArray<int32>& Nearby = VF_GetNearbyPassages(this, ChunkCoord);
|
|
|
|
if (Nearby.Num() == 0) return FLT_MAX; // no passage near this chunk → no carve
|
|
|
|
float MinSDF = FLT_MAX;
|
|
const float BlendK = 3.0f; // Smooth blend for passage junctions
|
|
|
|
//=========================================================================
|
|
// PASSAGES — tapered capsule chains between strates (per-strate PassageConfig).
|
|
// Each passage is a control-point chain with per-point radii; the (0,0) surface
|
|
// entry is a simple straight tube. A bounding-sphere reject skips far passages.
|
|
//=========================================================================
|
|
const FVector Pos(WorldX, WorldY, WorldZ);
|
|
for (int32 PIdx : Nearby)
|
|
{
|
|
const FVoxelPassage& P = Passages[PIdx];
|
|
// BOUNDING-SPHERE REJECT: skip passages this voxel can't possibly be inside.
|
|
// EvaluateModifierSDF runs PER VOXEL and used to evaluate every passage's full
|
|
// capsule chain unconditionally — the dominant lag source once passages became
|
|
// 12-segment worms. Now far passages cost a single squared-distance compare.
|
|
if (FVector::DistSquared(Pos, P.BoundCenter) > P.BoundRadiusSq) continue;
|
|
|
|
if (P.ControlPoints.Num() >= 2)
|
|
{
|
|
// Tapered capsule chain along the control points. ControlRadii (if present)
|
|
// gives the per-point width so the tunnel can flare at the mouths and pinch
|
|
// in the middle; otherwise the uniform Radius is used.
|
|
const bool bTaper = (P.ControlRadii.Num() == P.ControlPoints.Num());
|
|
float PassageSDF = FLT_MAX;
|
|
for (int32 j = 0; j < P.ControlPoints.Num() - 1; j++)
|
|
{
|
|
const float rA = bTaper ? P.ControlRadii[j] : P.Radius;
|
|
const float rB = bTaper ? P.ControlRadii[j + 1] : P.Radius;
|
|
const float SegSDF = VoxelSDF::TaperedCapsule(
|
|
Pos, P.ControlPoints[j], P.ControlPoints[j + 1], rA, rB);
|
|
PassageSDF = VoxelSDF::SmoothMin(PassageSDF, SegSDF, BlendK);
|
|
}
|
|
MinSDF = VoxelSDF::SmoothMin(MinSDF, PassageSDF, BlendK);
|
|
}
|
|
else
|
|
{
|
|
// Fallback: straight uniform tube Upper→Lower (e.g. the (0,0) surface entry).
|
|
const float PassageSDF = VoxelSDF::Capsule(Pos, P.UpperPoint, P.LowerPoint, P.Radius);
|
|
MinSDF = VoxelSDF::SmoothMin(MinSDF, PassageSDF, BlendK);
|
|
}
|
|
|
|
// A landing is a real room with a hard flat-floor half-space, not a sphere around the
|
|
// tube endpoint. The optional connector is the same fixed SDF geometry selected during
|
|
// GeneratePassages; it never performs a source query here.
|
|
const float UpperLandingSDF = VF_EvaluatePassageLandingSDF(Pos, P.UpperLanding);
|
|
const float LowerLandingSDF = VF_EvaluatePassageLandingSDF(Pos, P.LowerLanding);
|
|
MinSDF = VoxelSDF::SmoothMin(MinSDF, UpperLandingSDF, BlendK);
|
|
MinSDF = VoxelSDF::SmoothMin(MinSDF, LowerLandingSDF, BlendK);
|
|
}
|
|
|
|
return MinSDF;
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageModifier(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
const float ModSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ);
|
|
VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness);
|
|
ApplyPassageLandingAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness);
|
|
|
|
const FIntVector ChunkCoord(
|
|
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE));
|
|
const TArray<int32>& Nearby = VF_GetNearbyPassages(this, ChunkCoord);
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = Passages[PassageIndex];
|
|
if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageLandingFloor(Position, Passage.LowerLanding))
|
|
{
|
|
// This is the one bidirectional part of PassageCarveOp: a floor is a proved solid
|
|
// support slab. It is deliberately applied after the air carve so a tube can never
|
|
// tunnel through the floor and leave the player over a void.
|
|
Density = FMath::Max(Density, BaseDensity);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageLandingAir(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
const FIntVector ChunkCoord(
|
|
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE));
|
|
const TArray<int32>& Nearby = VF_GetNearbyPassages(this, ChunkCoord);
|
|
if (Nearby.Num() == 0) return;
|
|
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
float MinLandingSDF = FLT_MAX;
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = Passages[PassageIndex];
|
|
if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq)
|
|
{
|
|
continue;
|
|
}
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
const float LandingSDF = VF_EvaluatePassageLandingSDF(Position, *Landing);
|
|
if (LandingSDF < MinLandingSDF)
|
|
{
|
|
MinLandingSDF = LandingSDF;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (MinLandingSDF == FLT_MAX) return;
|
|
|
|
// Keep the same smooth interior blend used by the passage op. This landing-specific writer is
|
|
// idempotent because the MC-facing post may run after the structural op already saw the same
|
|
// voxel. The landing's support floor is restored by the floor writer after this MC-facing pass.
|
|
VF_ApplyPassageLandingCarving(Density, MinLandingSDF, BaseDensity, SealThickness);
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageLandingAirMC(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
// ApplyDisturbances is deliberately an MC-space post-process and can add a bridge or ridge
|
|
// on top of the structural passage. Reassert only landing air here; the support slab is
|
|
// restored by ApplyPassageLandingFloorMC immediately afterwards. This stays on the same
|
|
// thread-local passage shortlist as the hot voxel path and performs no source/topology work.
|
|
float InternalDensity = -Density;
|
|
ApplyPassageLandingAir(
|
|
InternalDensity, WorldX, WorldY, WorldZ, BaseDensity, SealThickness);
|
|
Density = -InternalDensity;
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageLandingFloorMC(
|
|
float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const
|
|
{
|
|
const FIntVector ChunkCoord(
|
|
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE));
|
|
const TArray<int32>& Nearby = VF_GetNearbyPassages(this, ChunkCoord);
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = Passages[PassageIndex];
|
|
if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageLandingFloor(Position, Passage.LowerLanding))
|
|
{
|
|
// Result is in MC convention here (negative = solid). This reassertion is the
|
|
// structural floor backstop after the optional MC-space disturbance layer.
|
|
Density = FMath::Min(Density, -BaseDensity);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const
|
|
{
|
|
// Le carve d'un passage atteint ModSDF < PASSAGE_BLEND_RADIUS (4, VoxelGenerator.cpp) au-delà de
|
|
// sa surface ; BoundRadius inclut déjà rayon + blend, on re-pad par sécurité (conservatif).
|
|
constexpr float CarvePad = 4.0f;
|
|
for (const FVoxelPassage& P : Passages)
|
|
{
|
|
// Point de la boîte le plus proche du centre de la sphère → test sphère/AABB.
|
|
const FVector C(
|
|
FMath::Clamp(P.BoundCenter.X, MinVoxel.X, MaxVoxel.X),
|
|
FMath::Clamp(P.BoundCenter.Y, MinVoxel.Y, MaxVoxel.Y),
|
|
FMath::Clamp(P.BoundCenter.Z, MinVoxel.Z, MaxVoxel.Z));
|
|
const float Reach = P.BoundRadius + CarvePad;
|
|
if (FVector::DistSquared(C, P.BoundCenter) <= Reach * Reach)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageLandingFloorNearBox(
|
|
const FVector& MinVoxel, const FVector& MaxVoxel) const
|
|
{
|
|
// A conservative AABB test is enough for the floor's only solid write. False positives cost
|
|
// a tile; a false negative could classify an air tile uniformly and remove the player's floor.
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (Landing->HalfWidth <= 0.0f || Landing->FloorThickness <= 0.0f)
|
|
{
|
|
continue;
|
|
}
|
|
const float Pad = 1.0f;
|
|
const float FloorMinX = Landing->StandingPoint.X - Landing->HalfWidth - Pad;
|
|
const float FloorMaxX = Landing->StandingPoint.X + Landing->HalfWidth + Pad;
|
|
const float FloorMinY = Landing->StandingPoint.Y - Landing->HalfWidth - Pad;
|
|
const float FloorMaxY = Landing->StandingPoint.Y + Landing->HalfWidth + Pad;
|
|
const float FloorMinZ = Landing->FloorZ - Landing->FloorThickness - Pad;
|
|
const float FloorMaxZ = Landing->FloorZ + Pad;
|
|
if (FloorMaxX >= MinVoxel.X && FloorMinX <= MaxVoxel.X
|
|
&& FloorMaxY >= MinVoxel.Y && FloorMinY <= MaxVoxel.Y
|
|
&& FloorMaxZ >= MinVoxel.Z && FloorMinZ <= MaxVoxel.Z)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// The fallback connector carries the same support slab away from the room. Its
|
|
// segment is not covered by the room AABB above; omitting it would let a tile that
|
|
// contains only the connector floor prove AllAir and erase the guaranteed join.
|
|
if (Landing->bHasNetworkConnector && Landing->ConnectorRadius > 0.0f
|
|
&& FMath::IsFinite(Landing->ConnectorStart.X)
|
|
&& FMath::IsFinite(Landing->ConnectorStart.Y)
|
|
&& FMath::IsFinite(Landing->ConnectorStart.Z)
|
|
&& FMath::IsFinite(Landing->ConnectorEnd.X)
|
|
&& FMath::IsFinite(Landing->ConnectorEnd.Y)
|
|
&& FMath::IsFinite(Landing->ConnectorEnd.Z))
|
|
{
|
|
const float ConnectorPad = Landing->ConnectorRadius + Pad;
|
|
float ConnectorMinX = FMath::Min(
|
|
Landing->ConnectorStart.X, Landing->ConnectorEnd.X) - ConnectorPad;
|
|
float ConnectorMaxX = FMath::Max(
|
|
Landing->ConnectorStart.X, Landing->ConnectorEnd.X) + ConnectorPad;
|
|
float ConnectorMinY = FMath::Min(
|
|
Landing->ConnectorStart.Y, Landing->ConnectorEnd.Y) - ConnectorPad;
|
|
float ConnectorMaxY = FMath::Max(
|
|
Landing->ConnectorStart.Y, Landing->ConnectorEnd.Y) + ConnectorPad;
|
|
if (Landing->bHasConnectorBend)
|
|
{
|
|
ConnectorMinX = FMath::Min(
|
|
ConnectorMinX, Landing->ConnectorControl.X - ConnectorPad);
|
|
ConnectorMaxX = FMath::Max(
|
|
ConnectorMaxX, Landing->ConnectorControl.X + ConnectorPad);
|
|
ConnectorMinY = FMath::Min(
|
|
ConnectorMinY, Landing->ConnectorControl.Y - ConnectorPad);
|
|
ConnectorMaxY = FMath::Max(
|
|
ConnectorMaxY, Landing->ConnectorControl.Y + ConnectorPad);
|
|
}
|
|
const float ConnectorMinZ = FMath::Min(
|
|
Landing->FloorZ, Landing->RootFloorZ)
|
|
- Landing->FloorThickness - Pad;
|
|
const float ConnectorMaxZ = FMath::Max(
|
|
Landing->FloorZ, Landing->RootFloorZ) + Pad;
|
|
if (ConnectorMaxX >= MinVoxel.X && ConnectorMinX <= MaxVoxel.X
|
|
&& ConnectorMaxY >= MinVoxel.Y && ConnectorMinY <= MaxVoxel.Y
|
|
&& ConnectorMaxZ >= MinVoxel.Z && ConnectorMinZ <= MaxVoxel.Z)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (Landing->RootSpineRadius >= 0.0f)
|
|
{
|
|
const float HubPad = Landing->RootSpineRadius
|
|
+ Landing->ConnectorRadius + Pad;
|
|
const float HubMinZ = Landing->RootFloorZ
|
|
- Landing->FloorThickness - Pad;
|
|
const float HubMaxZ = Landing->RootFloorZ + Pad;
|
|
if (HubPad >= 0.0f
|
|
&& HubPad >= MinVoxel.X && -HubPad <= MaxVoxel.X
|
|
&& HubPad >= MinVoxel.Y && -HubPad <= MaxVoxel.Y
|
|
&& HubMaxZ >= MinVoxel.Z && HubMinZ <= MaxVoxel.Z)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
//=============================================================================
|
|
// QUERIES
|
|
//=============================================================================
|
|
|
|
int32 UVoxelStrateManager::FindSlotIndexForChunkZ(int32 ChunkZ) const
|
|
{
|
|
// Linear search through strate layout.
|
|
// With ~10-20 strates this is fine. If we ever have hundreds,
|
|
// switch to binary search (layout is sorted by Z).
|
|
for (int32 i = 0; i < StrateLayout.Num(); i++)
|
|
{
|
|
const FStrateSlot& Slot = StrateLayout[i];
|
|
if (ChunkZ <= Slot.TopChunkZ && ChunkZ >= Slot.BottomChunkZ)
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
UVoxelStrateDefinition* UVoxelStrateManager::GetStrateAt(float WorldZ) const
|
|
{
|
|
// Convert world Z to chunk Z coordinate
|
|
int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE);
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ);
|
|
if (SlotIdx >= 0)
|
|
{
|
|
return StrateLayout[SlotIdx].Definition;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
int32 UVoxelStrateManager::GetStrateIndex(float WorldZ) const
|
|
{
|
|
int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE);
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ);
|
|
if (SlotIdx >= 0)
|
|
{
|
|
return StrateLayout[SlotIdx].StrateIndex;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
UVoxelStrateDefinition* UVoxelStrateManager::GetStrateForChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx >= 0)
|
|
{
|
|
return StrateLayout[SlotIdx].Definition;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool UVoxelStrateManager::GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const
|
|
{
|
|
// Strate-aware vertical streaming. Returns the chunk-Z span of the strate containing
|
|
// ChunkZ; false if ChunkZ is in the inter-strate gap (or outside the layout) — there the
|
|
// caller leaves the vertical view UNCLAMPED, since the gap is a brief see-both-sides
|
|
// descent transition. TopChunkZ > BottomChunkZ (Z decreases downward).
|
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ);
|
|
if (SlotIdx < 0)
|
|
{
|
|
return false;
|
|
}
|
|
OutTopChunkZ = StrateLayout[SlotIdx].TopChunkZ;
|
|
OutBottomChunkZ = StrateLayout[SlotIdx].BottomChunkZ;
|
|
return true;
|
|
}
|
|
|
|
ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
// Look up which slot this chunk falls into.
|
|
// If outside all strates (above or below), default to TunnelNetwork —
|
|
// the fallback density path will produce solid rock anyway.
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
|
|
{
|
|
return ECaveGeneratorType::TunnelNetwork;
|
|
}
|
|
|
|
#if WITH_EDITOR
|
|
if (const FVoxelStrateComposerSlotOverride* Override =
|
|
FindComposerOverride(StrateLayout[SlotIdx].StrateIndex))
|
|
{
|
|
return Override->Archetype;
|
|
}
|
|
#endif
|
|
|
|
if (SeasonStrates.IsValidIndex(SlotIdx))
|
|
{
|
|
return SeasonStrates[SlotIdx].Archetype;
|
|
}
|
|
|
|
return StrateLayout[SlotIdx].Definition->GeneratorType;
|
|
}
|
|
|
|
bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; }
|
|
|
|
if (SeasonStrates.IsValidIndex(SlotIdx))
|
|
{
|
|
// A recipe is an explicit stack opt-in. Native fixed entries retain the authored/switch
|
|
// route while still reading their season parameter vector.
|
|
return SeasonStrates[SlotIdx].bUsesRecipe;
|
|
}
|
|
|
|
#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; }
|
|
|
|
// LA LISTE DES ARCHÉTYPES PORTÉS — le seul endroit où elle est écrite. Un archétype non porté
|
|
// ignore le drapeau et retombe sur le `switch`, pour qu'on puisse cocher la case sur n'importe
|
|
// quelle strate sans rien casser en attendant son portage.
|
|
// THE PORTED-ARCHETYPE LIST, written down exactly once. An unported archetype ignores the flag
|
|
// and falls back to the switch, so the box can be ticked anywhere without breaking anything.
|
|
switch (Def->GeneratorType)
|
|
{
|
|
case ECaveGeneratorType::Maze: return true; // Phase 1
|
|
case ECaveGeneratorType::FlatPlain: // Phase 2 — les deux partagent
|
|
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
|
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
// ✅ La garde « pas de biomes » est TOMBÉE (étape 2c) : le combiner `Mask` existe, donc une
|
|
// strate à biomes mélange bien ses hauteurs comme le chemin d'origine. Les trois archétypes
|
|
// du dessus plus celui-ci font 5 des 8 portés.
|
|
// The no-biome guard is GONE: the Mask combiner exists, so a biome strate blends its heights
|
|
// exactly as the original path does.
|
|
return true;
|
|
|
|
case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels
|
|
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
// Phase 2 — la pile qui tourne à l'ENVERS : source de VIDE + fill, au lieu de source de ROC
|
|
// + carve, avec les MÊMES opérateurs au signe près.
|
|
return true;
|
|
|
|
case ECaveGeneratorType::Underwater:
|
|
// ⚠️ AUCUNE PILE À ELLE : `Underwater` EST `TunnelNetwork` plus un drapeau d'eau consommé
|
|
// côté rendu. `GetDensityAt` les met dans le même `case`, et `WaterLevelRelative` n'est lu
|
|
// que par `GetWaterLevel*` de ce manager — jamais par la densité (vérifié, pas supposé).
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
// Phase 2, LE DERNIER, et le plus gros : ~1080 lignes portées en trois étapes (squelette
|
|
// SDF → douze modificateurs de détail → override d'op par salle), 19 opérateurs, dont
|
|
// `FRoomGraphSource` qui **APPELLE** `BuildChunkCache`/`EvaluateSDFCached` au lieu de les
|
|
// transcrire — c'est là que vit la discipline d'invariance de fenêtre d'ARCHITECTURE §8.4,
|
|
// et en forker une copie aurait été le pire résultat possible de ce refactor.
|
|
//
|
|
// **8 SUR 8.** Le `switch` d'archétypes a désormais un jumeau en pile d'opérateurs, opt-in
|
|
// par strate, chacun vérifié par un test d'équivalence bit à bit contre sa fonction
|
|
// d'origine. Ce qui n'est PAS fait : `ClassifyTile` n'utilise toujours pas `ClassifyBox`.
|
|
return true;
|
|
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
if (StrateLayout.Num() == 0) return false;
|
|
|
|
// Above the top strate or below the bottom strate = open air, NOT a gap.
|
|
const int32 StackTop = StrateLayout[0].TopChunkZ;
|
|
const int32 StackBottom = StrateLayout.Last().BottomChunkZ;
|
|
if (ChunkCoord.Z > StackTop || ChunkCoord.Z < StackBottom) return false;
|
|
|
|
// Inside the stack's Z span but not in any strate slot → it's a bedrock gap.
|
|
return FindSlotIndexForChunkZ(ChunkCoord.Z) < 0;
|
|
}
|
|
|
|
FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
// Fallback: empty params with BaseDensity < 0 → all-air outside strate range.
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
|
|
{
|
|
FSlabGenerationParams Empty;
|
|
Empty.BaseDensity = -1.0f;
|
|
return Empty;
|
|
}
|
|
|
|
const FStrateSlot& Slot = StrateLayout[SlotIdx];
|
|
|
|
// Copy the designer-authored slab params from the strate definition, unless an editor
|
|
// composer candidate owns this slot.
|
|
FSlabGenerationParams Result;
|
|
if (SeasonStrates.IsValidIndex(SlotIdx))
|
|
{
|
|
Result = SeasonStrates[SlotIdx].Params.SlabParams;
|
|
}
|
|
#if WITH_EDITOR
|
|
else 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).
|
|
// TopChunkZ+1 because the top chunk's CEILING is at (TopChunkZ+1)*CHUNK_SIZE.
|
|
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
|
|
return Result;
|
|
}
|
|
|
|
//=============================================================================
|
|
// PER-ARCHETYPE PARAM GETTERS
|
|
//=============================================================================
|
|
// Each mirrors GetSlabParamsForChunk: copy designer params, fill runtime Z bounds.
|
|
// No cross-boundary blending — archetypes meet at Hard boundaries.
|
|
|
|
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 = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Params.MazeParams : 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;
|
|
}
|
|
|
|
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 = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Params.SurfaceParams : 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 = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Params.VerticalShaftParams : 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 = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Params.FloatingIslandParams : 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
|
|
{
|
|
FBiomeContext Out;
|
|
|
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) return Out;
|
|
|
|
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
|
|
if (Def->Biomes.Num() == 0) return Out; // biomes disabled for this strate
|
|
|
|
Out.Map = Def->BiomeMapParams;
|
|
Out.Biomes.Reserve(Def->Biomes.Num());
|
|
for (int32 i = 0; i < Def->Biomes.Num(); ++i)
|
|
{
|
|
const UVoxelBiomeDefinition* B = Def->Biomes[i];
|
|
if (!B) continue; // skip null entries (keep original index for content lookup)
|
|
|
|
FBiomeResolved R;
|
|
R.Index = i;
|
|
R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax;
|
|
R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax;
|
|
R.DebugColor = B->DebugColor.ToFColor(true);
|
|
R.MaterialPaletteIndex = B->MaterialPaletteIndex;
|
|
Out.Biomes.Add(R);
|
|
}
|
|
return Out;
|
|
}
|
|
|
|
bool UVoxelStrateManager::GetStrateUnrealZRange(float WorldZ, float& OutTopZ, float& OutBottomZ) const
|
|
{
|
|
const int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE);
|
|
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ);
|
|
if (SlotIdx < 0) return false;
|
|
|
|
const FStrateSlot& Slot = StrateLayout[SlotIdx];
|
|
// Voxel-space Z bounds → Unreal units. Ceiling = top chunk's upper edge.
|
|
OutTopZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE * VOXEL_SIZE;
|
|
OutBottomZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE * VOXEL_SIZE;
|
|
return true;
|
|
}
|
|
|
|
FStrateDisturbanceParams UVoxelStrateManager::GetDisturbanceParamsForChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition)
|
|
{
|
|
return FStrateDisturbanceParams(); // all features disabled
|
|
}
|
|
const FStrateSlot& Slot = StrateLayout[SlotIdx];
|
|
FStrateDisturbanceParams Result = Slot.Definition->Disturbances;
|
|
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
return Result;
|
|
}
|
|
|
|
float UVoxelStrateManager::GetWaterLevelWorldZForChunk(const FIntVector& ChunkCoord) const
|
|
{
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) return -FLT_MAX;
|
|
|
|
const FStrateSlot& Slot = StrateLayout[SlotIdx];
|
|
const UVoxelStrateDefinition* Def = Slot.Definition;
|
|
if (!Def->bHasWater) return -FLT_MAX;
|
|
|
|
// Pull the relative level from whichever archetype owns water.
|
|
float Rel = 0.0f;
|
|
const ECaveGeneratorType Archetype = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Archetype : Def->GeneratorType;
|
|
switch (Archetype)
|
|
{
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
Rel = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Params.SurfaceParams.WaterLevelRelative
|
|
: Def->SurfaceParams.WaterLevelRelative;
|
|
break;
|
|
default:
|
|
Rel = SeasonStrates.IsValidIndex(SlotIdx)
|
|
? SeasonStrates[SlotIdx].Params.TunnelNetworkParams.WaterLevelRelative
|
|
: Def->GenerationParams.WaterLevelRelative;
|
|
break;
|
|
}
|
|
if (Rel <= 0.0f) return -FLT_MAX;
|
|
|
|
const float BottomZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
const float TopZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
return FMath::Lerp(BottomZ, TopZ, FMath::Clamp(Rel, 0.0f, 1.0f));
|
|
}
|
|
|
|
FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVector& ChunkCoord) const
|
|
{
|
|
int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
|
|
|
// If outside all strates, return negative density → guaranteed air.
|
|
// BaseDensity must be < 0 because IsoLevel is 0.0 and density >= IsoLevel = solid.
|
|
if (SlotIdx < 0)
|
|
{
|
|
FStrateGenerationParams Empty;
|
|
Empty.BaseDensity = -1.0f; // Negative → air after negation
|
|
Empty.WormStrength = 0.0f;
|
|
Empty.RoomDensity = 0.0f; // No rooms outside strates
|
|
return Empty;
|
|
}
|
|
|
|
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
|
|
|
|
if (SeasonStrates.IsValidIndex(SlotIdx))
|
|
{
|
|
FStrateGenerationParams Result = SeasonStrates[SlotIdx].Params.TunnelNetworkParams;
|
|
Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE;
|
|
// Season vectors are already final, measured slot records. Vertical structural posts own
|
|
// their boundary; blending them with another selected recipe would describe neither one.
|
|
return Result;
|
|
}
|
|
|
|
FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition);
|
|
|
|
//=========================================================================
|
|
// SET STRATE BOUNDARY Z VALUES
|
|
//=========================================================================
|
|
// The density function needs to know the strate's Z range (in voxel coords)
|
|
// to seal the top and bottom with solid rock. This prevents caves from
|
|
// carving through strate boundaries.
|
|
//
|
|
// TopChunkZ=0, CHUNK_SIZE=32: top of the strate = chunk 0's top edge = voxel Z=32
|
|
// BottomChunkZ=-3: bottom of the strate = chunk -3's bottom edge = voxel Z=-3*32 = -96
|
|
BaseParams.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
BaseParams.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
|
|
//=========================================================================
|
|
// BOUNDARY BLENDING (transition-type aware)
|
|
//=========================================================================
|
|
// If this chunk is near a strate boundary, apply the appropriate transition
|
|
// based on the UPPER strate's TransitionType setting.
|
|
//
|
|
// Three transition styles:
|
|
//
|
|
// GRADIENT (default):
|
|
// Classic linear lerp of all params across BlendChunks. Smooth,
|
|
// invisible boundary. Cave shape morphs gradually from one strate
|
|
// to the next over several chunks.
|
|
//
|
|
// HARD:
|
|
// No blending at all — params switch instantly at the boundary.
|
|
// The abrupt change in density, room size, roughness, etc. creates
|
|
// a natural cliff, ledge, or visible material discontinuity.
|
|
// BlendChunks is ignored (effectively 0).
|
|
//
|
|
// INTERLEAVED:
|
|
// 3D Perlin noise warps the effective boundary Z position per XY column.
|
|
// Some columns transition early (fingers of the lower strate reach UP),
|
|
// others late (fingers of the upper strate reach DOWN). The Z frequency
|
|
// is intentionally low so the fingers are horizontal — wide, flat
|
|
// intrusions rather than vertical spikes.
|
|
//
|
|
// WHICH STRATE'S TRANSITION TYPE IS USED:
|
|
// At the bottom boundary of strate N (between N and N+1), we use
|
|
// strate N's (the upper strate's) TransitionType. This is consistent:
|
|
// each strate definition controls what happens at its lower edge.
|
|
// At the top boundary of strate N (between N-1 and N), we use
|
|
// strate N-1's TransitionType (the strate above controls its lower edge).
|
|
|
|
//---------------------------------------------------------------------
|
|
// CHECK BOTTOM BOUNDARY (transitioning to strate below)
|
|
//---------------------------------------------------------------------
|
|
// DistFromBottom = how many chunks above the bottom edge of this strate.
|
|
// When 0, we're right at the boundary. When == BlendChunks, we're at
|
|
// the outer edge of the transition zone.
|
|
int32 DistFromBottom = ChunkCoord.Z - Slot.BottomChunkZ;
|
|
|
|
if (SlotIdx + 1 < StrateLayout.Num())
|
|
{
|
|
// The upper strate (this one) controls the transition type at its lower edge
|
|
const EVoxelStrateTransition TransType = Slot.Definition->TransitionType;
|
|
|
|
// Per-definition blend distance (overrides the manager's default BlendChunks)
|
|
const int32 EffectiveBlend = Slot.Definition->TransitionBlendChunks;
|
|
|
|
// Prepare the neighbor's params (only used for Gradient and Interleaved)
|
|
const FStrateSlot& BelowSlot = StrateLayout[SlotIdx + 1];
|
|
|
|
switch (TransType)
|
|
{
|
|
case EVoxelStrateTransition::Hard:
|
|
{
|
|
// HARD TRANSITION: No blending. The current strate's params apply
|
|
// all the way to the boundary with zero transition zone.
|
|
// The abrupt param change (different densities, room sizes, etc.)
|
|
// creates a natural cliff or ledge — no special density boost needed.
|
|
// We simply skip blending and fall through to the "return BaseParams" below.
|
|
break;
|
|
}
|
|
|
|
case EVoxelStrateTransition::Gradient:
|
|
{
|
|
// GRADIENT TRANSITION: Classic smooth lerp across the blend zone.
|
|
// Alpha goes from 0 (at the outer edge of the zone) to 1 (right at boundary).
|
|
if (DistFromBottom < EffectiveBlend)
|
|
{
|
|
FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition);
|
|
BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
BelowParams.StrateBottomWorldZ = (float)(BelowSlot.BottomChunkZ) * CHUNK_SIZE;
|
|
|
|
// Linear alpha: 0 at EffectiveBlend chunks away, 1 at the boundary
|
|
float Alpha = 1.0f - ((float)DistFromBottom / (float)EffectiveBlend);
|
|
Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f);
|
|
|
|
return FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case EVoxelStrateTransition::Interleaved:
|
|
{
|
|
// INTERLEAVED TRANSITION: 3D noise warps the effective boundary Z.
|
|
//
|
|
// Instead of a flat boundary plane, the boundary becomes a wavy 3D surface.
|
|
// For each XY position, a Perlin noise sample offsets the boundary Z by
|
|
// up to ±2 chunks. Where the noise pushes the boundary UP, the lower strate's
|
|
// params appear earlier (its "fingers" reach into the upper strate). Where
|
|
// the noise pushes DOWN, the upper strate's params persist longer.
|
|
//
|
|
// The Z frequency is intentionally 3x lower than XY frequency so the fingers
|
|
// are horizontal slabs rather than vertical spikes — this matches how real
|
|
// geological intrusions look (wide, flat, layered).
|
|
//
|
|
// WarpAmplitude of 2.0 means the boundary can shift ±2 chunks from its
|
|
// true position. Combined with EffectiveBlend for the transition width,
|
|
// we need to check a wider zone: EffectiveBlend + WarpAmplitude.
|
|
const float WarpAmplitude = 2.0f; // Max boundary offset in chunks
|
|
const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude);
|
|
|
|
if (DistFromBottom < CheckRange)
|
|
{
|
|
FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition);
|
|
BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
BelowParams.StrateBottomWorldZ = (float)(BelowSlot.BottomChunkZ) * CHUNK_SIZE;
|
|
|
|
// Sample 3D Perlin noise to warp the boundary position.
|
|
// XY frequency 0.15 gives medium-scale variation (~6-7 chunks per cycle).
|
|
// Z frequency 0.05 gives slow vertical change — horizontal finger shapes.
|
|
// CachedSeed offsets ensure each world has unique finger patterns.
|
|
float WarpNoise = FMath::PerlinNoise3D(FVector(
|
|
ChunkCoord.X * 0.15f + CachedSeed * 0.01f,
|
|
ChunkCoord.Y * 0.15f + CachedSeed * 0.017f,
|
|
ChunkCoord.Z * 0.05f // Lower Z frequency for horizontal "fingers"
|
|
)) * VOXEL_NOISE_SCALE;
|
|
|
|
// Offset the distance from boundary by the noise * amplitude.
|
|
// Positive noise → boundary pushed up → lower strate appears earlier.
|
|
// Negative noise → boundary pushed down → upper strate persists longer.
|
|
float WarpedDist = (float)DistFromBottom + WarpNoise * WarpAmplitude;
|
|
|
|
// Compute alpha from the warped distance (same formula as Gradient,
|
|
// but using the noise-displaced distance instead of the true distance)
|
|
float Alpha = 1.0f - FMath::Clamp(WarpedDist / (float)EffectiveBlend, 0.0f, 1.0f);
|
|
|
|
// Only blend if alpha > 0 (we're inside the warped transition zone)
|
|
if (Alpha > 0.0f)
|
|
{
|
|
return FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
//---------------------------------------------------------------------
|
|
// CHECK TOP BOUNDARY (transitioning to strate above)
|
|
//---------------------------------------------------------------------
|
|
// Mirror logic: the ABOVE strate's TransitionType controls its lower edge,
|
|
// which is this strate's upper edge. So we read from StrateLayout[SlotIdx-1].
|
|
int32 DistFromTop = Slot.TopChunkZ - ChunkCoord.Z;
|
|
|
|
if (SlotIdx > 0)
|
|
{
|
|
// The strate ABOVE controls the transition at its lower edge (= our upper edge)
|
|
const FStrateSlot& AboveSlot = StrateLayout[SlotIdx - 1];
|
|
const EVoxelStrateTransition TransType = AboveSlot.Definition->TransitionType;
|
|
const int32 EffectiveBlend = AboveSlot.Definition->TransitionBlendChunks;
|
|
|
|
switch (TransType)
|
|
{
|
|
case EVoxelStrateTransition::Hard:
|
|
{
|
|
// No blending — fall through to return BaseParams
|
|
break;
|
|
}
|
|
|
|
case EVoxelStrateTransition::Gradient:
|
|
{
|
|
if (DistFromTop < EffectiveBlend)
|
|
{
|
|
FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition);
|
|
AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
AboveParams.StrateBottomWorldZ = (float)(AboveSlot.BottomChunkZ) * CHUNK_SIZE;
|
|
|
|
float Alpha = 1.0f - ((float)DistFromTop / (float)EffectiveBlend);
|
|
Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f);
|
|
|
|
return FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case EVoxelStrateTransition::Interleaved:
|
|
{
|
|
const float WarpAmplitude = 2.0f;
|
|
const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude);
|
|
|
|
if (DistFromTop < CheckRange)
|
|
{
|
|
FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition);
|
|
AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
AboveParams.StrateBottomWorldZ = (float)(AboveSlot.BottomChunkZ) * CHUNK_SIZE;
|
|
|
|
// Same noise function but with a different seed offset to avoid
|
|
// symmetry between top and bottom boundaries of adjacent strates
|
|
float WarpNoise = FMath::PerlinNoise3D(FVector(
|
|
ChunkCoord.X * 0.15f + CachedSeed * 0.013f,
|
|
ChunkCoord.Y * 0.15f + CachedSeed * 0.023f,
|
|
ChunkCoord.Z * 0.05f
|
|
)) * VOXEL_NOISE_SCALE;
|
|
|
|
float WarpedDist = (float)DistFromTop + WarpNoise * WarpAmplitude;
|
|
float Alpha = 1.0f - FMath::Clamp(WarpedDist / (float)EffectiveBlend, 0.0f, 1.0f);
|
|
|
|
if (Alpha > 0.0f)
|
|
{
|
|
return FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Not near any boundary (or Hard transition) — use this strate's params directly
|
|
return BaseParams;
|
|
}
|
|
|
|
//=============================================================================
|
|
// BUILD PARAMS FROM DEFINITION
|
|
//=============================================================================
|
|
// Returns the definition's base GenerationParams (cave shape, SDF, roughness, etc.).
|
|
//
|
|
// NOTE: Terrain op fields (TerraceStepHeight, ColumnDensity, etc.) are no longer
|
|
// merged here. They default to 0 (disabled) in the base params, and are applied
|
|
// per-room during BuildChunkCache() via FCachedRoom::RoomOp — each room hash-rolls
|
|
// one op from the strate's probability pool (FStrateTerrainOpEntry::Probability).
|
|
//
|
|
// Assets are still pre-loaded in Initialize() so BuildChunkCache can resolve
|
|
// soft pointers (Entry.Operation.Get()) without a disk read during generation.
|
|
|
|
FStrateGenerationParams UVoxelStrateManager::BuildParamsFromDefinition(const UVoxelStrateDefinition* Definition)
|
|
{
|
|
if (!Definition) return FStrateGenerationParams();
|
|
|
|
// Base params only — terrain op fields stay 0 until per-room assignment.
|
|
return Definition->GenerationParams;
|
|
}
|