Stopped by the owner's decision (in-game lit captures were eating the round); he judges the look in game instead. 1. Fall-through with the research field: fixed. Deep drop, gate on: 2 holds (0.50 s), min foot clearance +43 cm; gate off: penetrates (-278 cm). Stress fixture offsets converted to metres. 2. VOXEL_SIZE 25 -> 12.5 cm. Physical sizes kept in metres (player, steps, gate, insets, research field sizes, dig radii, test thresholds); class defaults that count chunks/voxels doubled (ClipRadius 6, RenderDistanceChunks 1536, ViewDistance*, StrateViewMargin 6, PawnCollisionRadius 2, SkirtCells 4, EdgeSealThickness 128, decoration spacings, density volume res 256). Asset values override these defaults: the owner must double his own. Ring scale, six workers, idle, step-2f field before the look changes: 64 m cube (519^3) 4.55 s; 32 m cube (259^3) 0.95 s. 3. In game: tiles go through the brick evaluator (proved by a log marker); profile switch rebuilds the loaded world (~5,800 tiles); full-detail fill 12.4 s after spawn in the visible-window test. 4. Look: round winding tunnels, per-column ellipsoid/lattice caches. All step-2f geometry gates pass at 12.5 cm (0 crumbs, 1 solid component per profile, solid share 69-87%). 259^3 ring, six workers: 1.08-2.02 s (worst HyperComplex) -- the 600 ms gate FAILS. Lit captures failed (hidden/off-screen window, then blank frames); not pursued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
6851 lines
297 KiB
C++
6851 lines
297 KiB
C++
// VoxelStrateManager.cpp
|
|
// Runtime strate layout generation and queries.
|
|
|
|
#include "VoxelStrateManager.h"
|
|
#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 "VoxelDensityAblation.h" // Fingerprinted development-only stage overrides
|
|
#include "VoxelDensityProfile.h" // Opt-in targeted per-voxel attribution
|
|
#include "VoxelStartupTrace.h"
|
|
#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo
|
|
#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten)
|
|
#include "HAL/IConsoleManager.h"
|
|
#include "Misc/CommandLine.h"
|
|
#include "Misc/Crc.h"
|
|
#include "Misc/Parse.h"
|
|
#include "UObject/UObjectGlobals.h"
|
|
|
|
#include <atomic>
|
|
|
|
namespace
|
|
{
|
|
std::atomic<uint64> GNextStrateManagerLifetimeId { 0 };
|
|
|
|
// Exact A/B switch for the repeated passage-floor projection item. The other field-
|
|
// preserving rewrites in this round are deliberately unconditional; this switch isolates
|
|
// the projection reuse without changing any density arithmetic.
|
|
int32 GVoxelForgeFloorRound1PassageProjectionCache = 1;
|
|
FAutoConsoleVariableRef CVarVoxelForgeFloorRound1PassageProjectionCache(
|
|
TEXT("voxel.FloorRound1PassageProjectionCache"),
|
|
GVoxelForgeFloorRound1PassageProjectionCache,
|
|
TEXT("Reuse exact passage floor projections within one density sample; 0 disables the A/B cache."));
|
|
|
|
// This is a measurement override only. The asset-owned bEnableWorms field remains the real
|
|
// per-strate contract; this process-wide switch exists so the game harness can measure the
|
|
// cost of enabled versus disabled worms without editing an owner's asset. It is resolved once
|
|
// before generation params are published so workers cannot disagree mid-world.
|
|
int32 GVoxelForgeWormsForceOff = 0;
|
|
std::atomic<int32> GResolvedWormsForceOff(-1);
|
|
FAutoConsoleVariableRef CVarVoxelForgeWormsForceOff(
|
|
TEXT("voxel.WormsForceOff"),
|
|
GVoxelForgeWormsForceOff,
|
|
TEXT("WORLD-CHANGING DEVELOPMENT-ONLY measurement override. 0=asset behavior, 1=force "
|
|
"all worm strengths to zero. Never vary this between multiplayer peers or world "
|
|
"regenerations; it is not the per-strate off switch."),
|
|
ECVF_Default);
|
|
|
|
bool VF_WormsForceOff()
|
|
{
|
|
int32 Existing = GResolvedWormsForceOff.load(std::memory_order_acquire);
|
|
if (Existing >= 0)
|
|
{
|
|
return Existing != 0;
|
|
}
|
|
|
|
int32 CommandLineValue = GVoxelForgeWormsForceOff;
|
|
FParse::Value(
|
|
FCommandLine::Get(), TEXT("voxel.WormsForceOff="), CommandLineValue);
|
|
const int32 Resolved = CommandLineValue != 0 ? 1 : 0;
|
|
int32 Expected = -1;
|
|
if (!GResolvedWormsForceOff.compare_exchange_strong(
|
|
Expected, Resolved, std::memory_order_release, std::memory_order_acquire))
|
|
{
|
|
return Expected != 0;
|
|
}
|
|
GVoxelForgeWormsForceOff = Resolved;
|
|
return Resolved != 0;
|
|
}
|
|
|
|
struct FRuntimeRoughnessOverrides
|
|
{
|
|
bool bSurfaceRoughness = false;
|
|
float SurfaceRoughness = 0.0f;
|
|
bool bFrequency = false;
|
|
float Frequency = 0.0f;
|
|
bool bNoiseType = false;
|
|
EVoxelNoiseType NoiseType = EVoxelNoiseType::FBM;
|
|
};
|
|
|
|
const FRuntimeRoughnessOverrides& VF_GetRuntimeRoughnessOverrides()
|
|
{
|
|
// These are diagnostic command-line inputs, captured once per process. Keeping the
|
|
// override at the params boundary makes every chunk, classifier, and mesher see the same
|
|
// immutable values without putting a command-line parse in the voxel hot loop.
|
|
static const FRuntimeRoughnessOverrides Overrides = []()
|
|
{
|
|
FRuntimeRoughnessOverrides Result;
|
|
const FString CommandLine(FCommandLine::Get());
|
|
|
|
Result.bSurfaceRoughness = FParse::Value(
|
|
*CommandLine, TEXT("voxel.surfaceroughness="), Result.SurfaceRoughness);
|
|
if (Result.bSurfaceRoughness
|
|
&& (!VoxelMath::IsFinite(Result.SurfaceRoughness) || Result.SurfaceRoughness < 0.0f))
|
|
{
|
|
Result.bSurfaceRoughness = false;
|
|
}
|
|
|
|
Result.bFrequency = FParse::Value(
|
|
*CommandLine, TEXT("voxel.roughnessfrequency="), Result.Frequency);
|
|
if (Result.bFrequency
|
|
&& (!VoxelMath::IsFinite(Result.Frequency) || Result.Frequency <= 0.0f))
|
|
{
|
|
Result.bFrequency = false;
|
|
}
|
|
|
|
FString NoiseTypeText;
|
|
if (FParse::Value(
|
|
*CommandLine, TEXT("voxel.roughnesstype="), NoiseTypeText))
|
|
{
|
|
NoiseTypeText.TrimStartAndEndInline();
|
|
NoiseTypeText.ToLowerInline();
|
|
NoiseTypeText.ReplaceInline(TEXT("_"), TEXT(""));
|
|
NoiseTypeText.ReplaceInline(TEXT("-"), TEXT(""));
|
|
if (NoiseTypeText == TEXT("fbm") || NoiseTypeText == TEXT("fractal"))
|
|
{
|
|
Result.NoiseType = EVoxelNoiseType::FBM;
|
|
Result.bNoiseType = true;
|
|
}
|
|
else if (NoiseTypeText == TEXT("ridged") || NoiseTypeText == TEXT("ridge"))
|
|
{
|
|
Result.NoiseType = EVoxelNoiseType::Ridged;
|
|
Result.bNoiseType = true;
|
|
}
|
|
else if (NoiseTypeText == TEXT("mixed"))
|
|
{
|
|
Result.NoiseType = EVoxelNoiseType::Mixed;
|
|
Result.bNoiseType = true;
|
|
}
|
|
else if (NoiseTypeText == TEXT("cellular") || NoiseTypeText == TEXT("worley"))
|
|
{
|
|
Result.NoiseType = EVoxelNoiseType::Cellular;
|
|
Result.bNoiseType = true;
|
|
}
|
|
}
|
|
|
|
if (Result.bSurfaceRoughness || Result.bFrequency || Result.bNoiseType)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[StrateManager] Runtime roughness override: strength=%s%.3f frequency=%s%.6f type=%s"),
|
|
Result.bSurfaceRoughness ? TEXT("") : TEXT("(asset) "),
|
|
Result.SurfaceRoughness,
|
|
Result.bFrequency ? TEXT("") : TEXT("(asset) "),
|
|
Result.Frequency,
|
|
Result.bNoiseType
|
|
? (Result.NoiseType == EVoxelNoiseType::FBM ? TEXT("FBM")
|
|
: Result.NoiseType == EVoxelNoiseType::Ridged ? TEXT("Ridged")
|
|
: Result.NoiseType == EVoxelNoiseType::Mixed ? TEXT("Mixed")
|
|
: TEXT("Cellular"))
|
|
: TEXT("(asset)"));
|
|
}
|
|
return Result;
|
|
}();
|
|
return Overrides;
|
|
}
|
|
|
|
static FStrateGenerationParams VF_ApplyRuntimeRoughnessOverrides(
|
|
FStrateGenerationParams Params)
|
|
{
|
|
const FRuntimeRoughnessOverrides& Overrides = VF_GetRuntimeRoughnessOverrides();
|
|
if (Overrides.bSurfaceRoughness)
|
|
{
|
|
Params.SurfaceRoughness = Overrides.SurfaceRoughness;
|
|
}
|
|
if (Overrides.bFrequency)
|
|
{
|
|
Params.RoughnessFrequency = Overrides.Frequency;
|
|
}
|
|
if (Overrides.bNoiseType)
|
|
{
|
|
Params.RoughnessNoiseType = Overrides.NoiseType;
|
|
}
|
|
return Params;
|
|
}
|
|
}
|
|
|
|
UVoxelStrateManager::UVoxelStrateManager()
|
|
: CacheLifetimeId(GNextStrateManagerLifetimeId.fetch_add(1, std::memory_order_relaxed) + 1)
|
|
{
|
|
}
|
|
|
|
#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 projection result per passage is enough for the current voxel sample. The surrounding
|
|
// passage cache already moves with (manager, layout, chunk); a generation invalidates old flags
|
|
// before any caller can observe them. Keeping the entries indexed by passage avoids a TMap
|
|
// rehash path in this hot loop, while a collision-free hit remains exact.
|
|
struct FPassageFloorProjectionCacheEntry
|
|
{
|
|
// The sample key lives once in FPassageEvaluationCache. A monotonically increasing
|
|
// generation makes an entry's old flags inert without clearing its float payload.
|
|
// The payload is only read after the corresponding valid flag is set.
|
|
uint64 SampleGeneration = 0;
|
|
uint8 ComputedFlags = 0;
|
|
bool bNativeValid = false;
|
|
float NativeFloorZ = 0.0f;
|
|
float NativeSupportRadius = 0.0f;
|
|
bool bGenericValid = false;
|
|
float GenericFloorZ = 0.0f;
|
|
float GenericSupportRadius = 0.0f;
|
|
bool bWalkableAir = false;
|
|
|
|
enum : uint8
|
|
{
|
|
NativeComputed = 1u << 0,
|
|
GenericComputed = 1u << 1,
|
|
WalkableAirComputed = 1u << 2
|
|
};
|
|
|
|
FORCEINLINE bool Has(uint8 Flag) const
|
|
{
|
|
return (ComputedFlags & Flag) != 0;
|
|
}
|
|
|
|
FORCEINLINE void SetComputed(uint8 Flag)
|
|
{
|
|
ComputedFlags |= Flag;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
uint64 OwnerLifetimeId = 0;
|
|
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
|
uint32 Version = 0xFFFFFFFFu;
|
|
TArray<int32> Nearby;
|
|
TArray<FPassageFloorProjectionCacheEntry> FloorProjections;
|
|
bool bFloorProjectionSampleValid = false;
|
|
double FloorProjectionX = 0.0;
|
|
double FloorProjectionY = 0.0;
|
|
double FloorProjectionZ = 0.0;
|
|
uint64 FloorProjectionGeneration = 0;
|
|
};
|
|
|
|
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;
|
|
const uint64 LifetimeId = Manager ? Manager->GetCacheLifetimeId() : 0;
|
|
if (Cache.Owner == Manager && Cache.OwnerLifetimeId == LifetimeId
|
|
&& Cache.Chunk == ChunkCoord && Cache.Version == Version)
|
|
{
|
|
return Cache.Nearby;
|
|
}
|
|
|
|
Cache.Owner = Manager;
|
|
Cache.OwnerLifetimeId = LifetimeId;
|
|
Cache.Chunk = ChunkCoord;
|
|
Cache.Version = Version;
|
|
Cache.Nearby.Reset();
|
|
Cache.FloorProjections.Reset();
|
|
Cache.bFloorProjectionSampleValid = false;
|
|
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();
|
|
Cache.FloorProjections.SetNum(Passages.Num());
|
|
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;
|
|
}
|
|
|
|
FPassageFloorProjectionCacheEntry* VF_GetPassageFloorProjectionCacheEntry(
|
|
const UVoxelStrateManager* Manager,
|
|
const FVector& Position,
|
|
int32 PassageIndex)
|
|
{
|
|
if (GVoxelForgeFloorRound1PassageProjectionCache == 0
|
|
|| Manager == nullptr || PassageIndex < 0)
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
FPassageEvaluationCache& Cache = VF_GetPassageEvaluationCache();
|
|
if (Cache.Owner != Manager
|
|
|| Cache.OwnerLifetimeId != Manager->GetCacheLifetimeId()
|
|
|| Cache.Version != Manager->GetLayoutVersion()
|
|
|| PassageIndex >= Cache.FloorProjections.Num())
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
if (!Cache.bFloorProjectionSampleValid
|
|
|| Cache.FloorProjectionX != Position.X
|
|
|| Cache.FloorProjectionY != Position.Y
|
|
|| Cache.FloorProjectionZ != Position.Z)
|
|
{
|
|
Cache.bFloorProjectionSampleValid = true;
|
|
Cache.FloorProjectionX = Position.X;
|
|
Cache.FloorProjectionY = Position.Y;
|
|
Cache.FloorProjectionZ = Position.Z;
|
|
++Cache.FloorProjectionGeneration;
|
|
if (Cache.FloorProjectionGeneration == 0)
|
|
{
|
|
// Keep generation zero reserved for never-initialized entries. This is
|
|
// unreachable in a practical worker lifetime, but preserves the invariant if a
|
|
// process survives long enough to wrap the counter.
|
|
for (FPassageFloorProjectionCacheEntry& Candidate : Cache.FloorProjections)
|
|
{
|
|
Candidate.SampleGeneration = 0;
|
|
}
|
|
++Cache.FloorProjectionGeneration;
|
|
}
|
|
}
|
|
|
|
FPassageFloorProjectionCacheEntry* Entry =
|
|
Cache.FloorProjections.GetData() + PassageIndex;
|
|
if (Entry->SampleGeneration != Cache.FloorProjectionGeneration)
|
|
{
|
|
Entry->SampleGeneration = Cache.FloorProjectionGeneration;
|
|
Entry->ComputedFlags = 0;
|
|
}
|
|
return Entry;
|
|
}
|
|
|
|
// Passage relief is a function of the immutable passage descriptor and the actual XY column.
|
|
// Keep the cache fixed-size and thread-local, like the morphology floor cache: a long export
|
|
// cannot retain one entry per tile, and collisions only lose a hit. Fractional normal
|
|
// samples intentionally fall through to the exact same two-octave calculation.
|
|
struct FVoxelPassageReliefCacheEntry
|
|
{
|
|
const UVoxelStrateManager* Owner = nullptr;
|
|
uint64 OwnerLifetimeId = 0;
|
|
uint32 LayoutVersion = 0;
|
|
int32 PassageIndex = INDEX_NONE;
|
|
int32 X = 0;
|
|
int32 Y = 0;
|
|
float Noise = 0.0f;
|
|
bool bValid = false;
|
|
};
|
|
|
|
struct FVoxelPassageReliefCache
|
|
{
|
|
static constexpr int32 Capacity = 16384;
|
|
static_assert((Capacity & (Capacity - 1)) == 0,
|
|
"passage relief cache must be a power of two");
|
|
FVoxelPassageReliefCacheEntry Entries[Capacity];
|
|
|
|
static uint32 HashKey(
|
|
const UVoxelStrateManager* Owner, uint64 LifetimeId, uint32 Version,
|
|
int32 PassageIndex, int32 X, int32 Y)
|
|
{
|
|
uint32 Hash = static_cast<uint32>(reinterpret_cast<UPTRINT>(Owner));
|
|
Hash ^= static_cast<uint32>(reinterpret_cast<UPTRINT>(Owner) >> 32);
|
|
const auto Combine = [&Hash](uint32 Value)
|
|
{
|
|
Hash ^= Value + 0x9E3779B9u + (Hash << 6) + (Hash >> 2);
|
|
};
|
|
Combine(static_cast<uint32>(LifetimeId));
|
|
Combine(static_cast<uint32>(LifetimeId >> 32));
|
|
Combine(Version);
|
|
Combine(static_cast<uint32>(PassageIndex));
|
|
Combine(static_cast<uint32>(X));
|
|
Combine(static_cast<uint32>(Y));
|
|
return VoxelHash::Mix(Hash);
|
|
}
|
|
};
|
|
|
|
FVoxelPassageReliefCache& VF_GetPassageReliefCache()
|
|
{
|
|
thread_local FVoxelPassageReliefCache Cache;
|
|
return Cache;
|
|
}
|
|
|
|
FORCEINLINE float VF_ComputePassageReliefNoise(
|
|
double X, double Y, uint32 FloorSeed, float Frequency)
|
|
{
|
|
const float SF = static_cast<float>(FloorSeed) * 0.00001f;
|
|
float Noise = FMath::PerlinNoise2D(
|
|
FVector2D(
|
|
static_cast<float>(X) * Frequency + SF,
|
|
static_cast<float>(Y) * Frequency + SF * 1.7f)) * 0.65f;
|
|
Noise += FMath::PerlinNoise2D(
|
|
FVector2D(
|
|
static_cast<float>(X) * Frequency * 2.3f + SF * 3.1f,
|
|
static_cast<float>(Y) * Frequency * 2.3f + SF * 5.3f)) * 0.35f;
|
|
return Noise;
|
|
}
|
|
|
|
FORCEINLINE float VF_PassageReliefNoise(
|
|
const UVoxelStrateManager* Manager, int32 PassageIndex,
|
|
const FVoxelPassage& Passage, double X, double Y)
|
|
{
|
|
if (!(Passage.NativeFloorReliefStrength > 0.0f)
|
|
|| !VoxelMath::IsFinite(Passage.NativeFloorReliefStrength)
|
|
|| !VoxelMath::IsFinite(Passage.NativeFloorReliefFrequency)
|
|
|| !(FMath::Abs(Passage.NativeFloorReliefFrequency) > KINDA_SMALL_NUMBER))
|
|
{
|
|
return 0.0f;
|
|
}
|
|
|
|
const bool bInteger = VoxelMath::IsFinite(X) && VoxelMath::IsFinite(Y)
|
|
&& FMath::FloorToDouble(X) == X && FMath::FloorToDouble(Y) == Y
|
|
&& X >= static_cast<double>(MIN_int32) && X <= static_cast<double>(MAX_int32)
|
|
&& Y >= static_cast<double>(MIN_int32) && Y <= static_cast<double>(MAX_int32);
|
|
if (!bInteger || Manager == nullptr || PassageIndex == INDEX_NONE)
|
|
{
|
|
return VF_ComputePassageReliefNoise(
|
|
X, Y, Passage.NativeFloorSeed, Passage.NativeFloorReliefFrequency);
|
|
}
|
|
|
|
const int32 IX = FMath::FloorToInt(X);
|
|
const int32 IY = FMath::FloorToInt(Y);
|
|
FVoxelPassageReliefCache& Cache = VF_GetPassageReliefCache();
|
|
const uint64 LifetimeId = Manager->GetCacheLifetimeId();
|
|
const uint32 Version = Manager->GetLayoutVersion();
|
|
FVoxelPassageReliefCacheEntry& Entry = Cache.Entries[
|
|
FVoxelPassageReliefCache::HashKey(
|
|
Manager, LifetimeId, Version, PassageIndex, IX, IY)
|
|
& (FVoxelPassageReliefCache::Capacity - 1)];
|
|
if (Entry.bValid
|
|
&& Entry.Owner == Manager
|
|
&& Entry.OwnerLifetimeId == LifetimeId
|
|
&& Entry.LayoutVersion == Version
|
|
&& Entry.PassageIndex == PassageIndex
|
|
&& Entry.X == IX && Entry.Y == IY)
|
|
{
|
|
return Entry.Noise;
|
|
}
|
|
|
|
const float Noise = VF_ComputePassageReliefNoise(
|
|
X, Y, Passage.NativeFloorSeed, Passage.NativeFloorReliefFrequency);
|
|
Entry.Owner = Manager;
|
|
Entry.OwnerLifetimeId = LifetimeId;
|
|
Entry.LayoutVersion = Version;
|
|
Entry.PassageIndex = PassageIndex;
|
|
Entry.X = IX;
|
|
Entry.Y = IY;
|
|
Entry.Noise = Noise;
|
|
Entry.bValid = true;
|
|
return Noise;
|
|
}
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// The graph-tunnel relief proof is intentionally repeated here instead of sharing a loose
|
|
// "noise amplitude" constant. The passage uses the same weighted two-octave field and the
|
|
// same conservative partial-derivative bound; a zero scale is the fail-closed result.
|
|
constexpr float VF_PassagePerlin2DPartialAbsBound = 8.5f;
|
|
constexpr float VF_PassageMaxReliefScale = 0.20f;
|
|
|
|
static float VF_BuildPassageReliefScale(
|
|
const FVector& A, const FVector& B,
|
|
float BaseGradient, int32 SegmentIndex, int32 NumSegments,
|
|
float Strength, float Frequency)
|
|
{
|
|
if (!(Strength > 0.0f)
|
|
|| !VoxelMath::IsFinite(Strength)
|
|
|| !VoxelMath::IsFinite(Frequency)
|
|
|| !(FMath::Abs(Frequency) > KINDA_SMALL_NUMBER)
|
|
|| !VoxelMath::IsFinite(BaseGradient)
|
|
|| BaseGradient < 0.0f)
|
|
{
|
|
return 0.0f;
|
|
}
|
|
|
|
const float AvailableGradient =
|
|
VoxelPassageGeometry::PlayerWalkableFloorMaxGradient - BaseGradient;
|
|
if (!(AvailableGradient > 0.0f))
|
|
{
|
|
return 0.0f;
|
|
}
|
|
|
|
const float Amplitude = FMath::Abs(Strength) * VOXEL_NOISE_SCALE;
|
|
const float OctaveFrequencyWeight = 0.65f + 0.35f * 2.3f;
|
|
const float NoiseGradientBound =
|
|
1.4142135623730951f * VF_PassagePerlin2DPartialAbsBound
|
|
* OctaveFrequencyWeight * FMath::Abs(Frequency) * Amplitude;
|
|
const int32 LandingEnvelopeCount = (SegmentIndex == 0 ? 1 : 0)
|
|
+ (SegmentIndex + 1 == NumSegments ? 1 : 0);
|
|
const float HorizontalRun = FVector2D(
|
|
static_cast<float>(B.X - A.X),
|
|
static_cast<float>(B.Y - A.Y)).Size();
|
|
const float EnvelopeDerivativeBound = LandingEnvelopeCount > 0
|
|
? (1.5f * static_cast<float>(LandingEnvelopeCount)
|
|
/ VoxelPassageGeometry::WalkableTunnelLandingApronVoxels)
|
|
* Amplitude
|
|
: 0.0f;
|
|
const float ReliefGradientBound =
|
|
NoiseGradientBound + EnvelopeDerivativeBound;
|
|
if (!(ReliefGradientBound > KINDA_SMALL_NUMBER)
|
|
|| !VoxelMath::IsFinite(ReliefGradientBound)
|
|
|| !(HorizontalRun > KINDA_SMALL_NUMBER))
|
|
{
|
|
return 0.0f;
|
|
}
|
|
|
|
return FMath::Clamp(
|
|
FMath::Min(
|
|
VF_PassageMaxReliefScale,
|
|
AvailableGradient / ReliefGradientBound),
|
|
0.0f, VF_PassageMaxReliefScale);
|
|
}
|
|
|
|
static bool VF_ProjectNativePassageFloorSegment(
|
|
const FVoxelPassage& Passage, int32 SegmentIndex,
|
|
const FVector& Position, float& OutFloorZ, float& OutSupportRadius,
|
|
float& OutDistanceSquared, float& OutHorizontalDistanceSquared,
|
|
const UVoxelStrateManager* Manager = nullptr,
|
|
int32 PassageIndex = INDEX_NONE)
|
|
{
|
|
const int32 ControlPointCount = Passage.ControlPoints.Num();
|
|
if (!Passage.bNativeFloorEnabled
|
|
|| SegmentIndex < 0
|
|
|| SegmentIndex + 1 >= ControlPointCount
|
|
|| Passage.ControlRadii.Num() != ControlPointCount
|
|
|| Passage.NativeFloorProfileZ.Num() != ControlPointCount)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
const float StartRadius = FMath::Abs(Passage.ControlRadii[SegmentIndex]);
|
|
const float EndRadius = FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]);
|
|
const double QueryX = static_cast<double>(static_cast<float>(Position.X));
|
|
const double QueryY = static_cast<double>(static_cast<float>(Position.Y));
|
|
const double AXY_X = static_cast<double>(static_cast<float>(A.X));
|
|
const double AXY_Y = static_cast<double>(static_cast<float>(A.Y));
|
|
const double DeltaX = static_cast<double>(static_cast<float>(B.X - A.X));
|
|
const double DeltaY = static_cast<double>(static_cast<float>(B.Y - A.Y));
|
|
const float HorizontalLengthSquared = static_cast<float>(
|
|
DeltaX * DeltaX + DeltaY * DeltaY);
|
|
if (!(HorizontalLengthSquared > KINDA_SMALL_NUMBER))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const double HorizontalDot = (QueryX - AXY_X) * DeltaX
|
|
+ (QueryY - AXY_Y) * DeltaY;
|
|
const float HorizontalT = FMath::Clamp(
|
|
HorizontalDot / HorizontalLengthSquared,
|
|
0.0f, 1.0f);
|
|
const double HorizontalClosestX = AXY_X + DeltaX * HorizontalT;
|
|
const double HorizontalClosestY = AXY_Y + DeltaY * HorizontalT;
|
|
const double HorizontalDistanceX = QueryX - HorizontalClosestX;
|
|
const double HorizontalDistanceY = QueryY - HorizontalClosestY;
|
|
const float HorizontalDistanceSquared = static_cast<float>(
|
|
HorizontalDistanceX * HorizontalDistanceX
|
|
+ HorizontalDistanceY * HorizontalDistanceY);
|
|
OutHorizontalDistanceSquared = HorizontalDistanceSquared;
|
|
const float SegmentSupportRadius =
|
|
VoxelPassageGeometry::NativePassageFloorSupportRadius(
|
|
StartRadius, EndRadius);
|
|
if (HorizontalDistanceSquared > FMath::Square(SegmentSupportRadius))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const float FloorA = Passage.NativeFloorProfileZ[SegmentIndex];
|
|
const float FloorB = Passage.NativeFloorProfileZ[SegmentIndex + 1];
|
|
const float FloorDeltaZ = FloorB - FloorA;
|
|
const float FullLengthSquared = HorizontalLengthSquared
|
|
+ FMath::Square(FloorDeltaZ);
|
|
if (!(FullLengthSquared > KINDA_SMALL_NUMBER))
|
|
{
|
|
return false;
|
|
}
|
|
const double FullDot = HorizontalDot
|
|
+ (static_cast<double>(static_cast<float>(Position.Z)) - FloorA)
|
|
* FloorDeltaZ;
|
|
const float SpatialT = FMath::Clamp(
|
|
FullDot / FullLengthSquared,
|
|
0.0f, 1.0f);
|
|
const double SpatialX = AXY_X + DeltaX * SpatialT;
|
|
const double SpatialY = AXY_Y + DeltaY * SpatialT;
|
|
const double SpatialZ = FloorA + FloorDeltaZ * SpatialT;
|
|
const double SpatialDistanceX = QueryX - SpatialX;
|
|
const double SpatialDistanceY = QueryY - SpatialY;
|
|
const double SpatialDistanceZ =
|
|
static_cast<double>(static_cast<float>(Position.Z)) - SpatialZ;
|
|
OutDistanceSquared = static_cast<float>(
|
|
SpatialDistanceX * SpatialDistanceX
|
|
+ SpatialDistanceY * SpatialDistanceY
|
|
+ SpatialDistanceZ * SpatialDistanceZ);
|
|
|
|
OutFloorZ = FMath::Lerp(FloorA, FloorB, HorizontalT);
|
|
float ReliefScale = 0.0f;
|
|
if (Passage.NativeFloorReliefScales.IsValidIndex(SegmentIndex))
|
|
{
|
|
ReliefScale = Passage.NativeFloorReliefScales[SegmentIndex];
|
|
}
|
|
float Envelope = 1.0f;
|
|
const float HorizontalRun = FMath::Sqrt(HorizontalLengthSquared);
|
|
if (SegmentIndex == 0)
|
|
{
|
|
Envelope *= SmoothStep01(FMath::Clamp(
|
|
(HorizontalT * HorizontalRun)
|
|
/ VoxelPassageGeometry::WalkableTunnelLandingApronVoxels,
|
|
0.0f, 1.0f));
|
|
}
|
|
if (SegmentIndex + 1 == ControlPointCount - 1)
|
|
{
|
|
Envelope *= SmoothStep01(FMath::Clamp(
|
|
((1.0f - HorizontalT) * HorizontalRun)
|
|
/ VoxelPassageGeometry::WalkableTunnelLandingApronVoxels,
|
|
0.0f, 1.0f));
|
|
}
|
|
OutFloorZ += VF_PassageReliefNoise(
|
|
Manager, PassageIndex, Passage,
|
|
static_cast<double>(Position.X), static_cast<double>(Position.Y))
|
|
* VOXEL_NOISE_SCALE * Passage.NativeFloorReliefStrength
|
|
* Envelope * ReliefScale;
|
|
OutSupportRadius = SegmentSupportRadius;
|
|
return VoxelMath::IsFinite(OutFloorZ)
|
|
&& VoxelMath::IsFinite(OutSupportRadius)
|
|
&& VoxelMath::IsFinite(OutDistanceSquared)
|
|
&& OutSupportRadius > 0.0f;
|
|
}
|
|
|
|
// FMath::PerlinNoise2D is in (-1, 1); the two positive octave weights sum to one,
|
|
// SmoothStep01 keeps the apron envelope in [0, 1], and each segment stores the scale
|
|
// actually consumed by VF_ProjectNativePassageFloorSegment. This is the write supremum,
|
|
// including a small float-rounding allowance at the authored floor's world Z.
|
|
static bool VF_GetNativePassageFloorReliefBound(
|
|
const FVoxelPassage& Passage, float& OutReliefBound)
|
|
{
|
|
OutReliefBound = 0.0f;
|
|
if (!Passage.bNativeFloorEnabled)
|
|
{
|
|
return true;
|
|
}
|
|
if (Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num()
|
|
|| Passage.NativeFloorProfileZ.Num() != Passage.ControlPoints.Num()
|
|
|| Passage.ControlPoints.Num() > 4096)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
float MaxAbsFloorZ = 0.0f;
|
|
for (int32 Index = 0; Index < Passage.ControlPoints.Num(); ++Index)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.NativeFloorProfileZ[Index]))
|
|
{
|
|
return false;
|
|
}
|
|
MaxAbsFloorZ = FMath::Max(
|
|
MaxAbsFloorZ, FMath::Abs(Passage.NativeFloorProfileZ[Index]));
|
|
}
|
|
|
|
// The relief helper returns exactly zero for these inputs.
|
|
if (!(Passage.NativeFloorReliefStrength > 0.0f)
|
|
|| !VoxelMath::IsFinite(Passage.NativeFloorReliefStrength)
|
|
|| !VoxelMath::IsFinite(Passage.NativeFloorReliefFrequency)
|
|
|| !(FMath::Abs(Passage.NativeFloorReliefFrequency) > KINDA_SMALL_NUMBER))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
float MaxAmplitude = 0.0f;
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const float ReliefScale =
|
|
Passage.NativeFloorReliefScales.IsValidIndex(SegmentIndex)
|
|
? Passage.NativeFloorReliefScales[SegmentIndex] : 0.0f;
|
|
if (!VoxelMath::IsFinite(ReliefScale))
|
|
{
|
|
return false;
|
|
}
|
|
const float Amplitude =
|
|
FMath::Abs(Passage.NativeFloorReliefStrength)
|
|
* VOXEL_NOISE_SCALE * FMath::Abs(ReliefScale);
|
|
if (!VoxelMath::IsFinite(Amplitude))
|
|
{
|
|
return false;
|
|
}
|
|
MaxAmplitude = FMath::Max(MaxAmplitude, Amplitude);
|
|
}
|
|
|
|
const float RoundingAllowance =
|
|
8.0f * FLT_EPSILON * FMath::Max(1.0f, MaxAbsFloorZ + MaxAmplitude)
|
|
+ KINDA_SMALL_NUMBER;
|
|
OutReliefBound = MaxAmplitude + RoundingAllowance;
|
|
return VoxelMath::IsFinite(OutReliefBound);
|
|
}
|
|
|
|
static bool VF_ProjectNativePassageFloorUncached(
|
|
const FVoxelPassage& Passage, const FVector& Position,
|
|
float& OutFloorZ, float& OutSupportRadius,
|
|
const UVoxelStrateManager* Manager = nullptr,
|
|
int32 PassageIndex = INDEX_NONE)
|
|
{
|
|
const int32 SegmentCount = Passage.ControlPoints.Num() - 1;
|
|
float BestDistanceSquared = FLT_MAX;
|
|
float BestFloorZ = 0.0f;
|
|
float BestSupportRadius = 0.0f;
|
|
bool bFoundSegment = false;
|
|
for (int32 SegmentIndex = 0; SegmentIndex < SegmentCount; ++SegmentIndex)
|
|
{
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
float DistanceSquared = FLT_MAX;
|
|
float HorizontalDistanceSquared = FLT_MAX;
|
|
if (!VF_ProjectNativePassageFloorSegment(
|
|
Passage, SegmentIndex, Position,
|
|
FloorZ, SupportRadius, DistanceSquared,
|
|
HorizontalDistanceSquared,
|
|
Manager, PassageIndex)
|
|
|| DistanceSquared >= BestDistanceSquared)
|
|
{
|
|
continue;
|
|
}
|
|
BestDistanceSquared = DistanceSquared;
|
|
BestFloorZ = FloorZ;
|
|
BestSupportRadius = SupportRadius;
|
|
bFoundSegment = true;
|
|
}
|
|
if (!bFoundSegment)
|
|
{
|
|
return false;
|
|
}
|
|
OutFloorZ = BestFloorZ;
|
|
OutSupportRadius = BestSupportRadius;
|
|
return true;
|
|
}
|
|
|
|
static bool VF_ProjectNativePassageFloor(
|
|
const FVoxelPassage& Passage, const FVector& Position,
|
|
float& OutFloorZ, float& OutSupportRadius,
|
|
const UVoxelStrateManager* Manager = nullptr,
|
|
int32 PassageIndex = INDEX_NONE)
|
|
{
|
|
FPassageFloorProjectionCacheEntry* Entry =
|
|
VF_GetPassageFloorProjectionCacheEntry(Manager, Position, PassageIndex);
|
|
if (Entry == nullptr)
|
|
{
|
|
return VF_ProjectNativePassageFloorUncached(
|
|
Passage, Position, OutFloorZ, OutSupportRadius,
|
|
Manager, PassageIndex);
|
|
}
|
|
if (!Entry->Has(FPassageFloorProjectionCacheEntry::NativeComputed))
|
|
{
|
|
Entry->bNativeValid = VF_ProjectNativePassageFloorUncached(
|
|
Passage, Position, Entry->NativeFloorZ,
|
|
Entry->NativeSupportRadius, Manager, PassageIndex);
|
|
Entry->SetComputed(FPassageFloorProjectionCacheEntry::NativeComputed);
|
|
}
|
|
if (Entry->bNativeValid)
|
|
{
|
|
OutFloorZ = Entry->NativeFloorZ;
|
|
OutSupportRadius = Entry->NativeSupportRadius;
|
|
}
|
|
return Entry->bNativeValid;
|
|
}
|
|
|
|
static bool VF_ProjectPassageFloor(
|
|
const FVoxelPassage& Passage, const FVector& Position,
|
|
float& OutFloorZ, float& OutSupportRadius,
|
|
const UVoxelStrateManager* Manager = nullptr,
|
|
int32 PassageIndex = INDEX_NONE)
|
|
{
|
|
FPassageFloorProjectionCacheEntry* Entry =
|
|
VF_GetPassageFloorProjectionCacheEntry(Manager, Position, PassageIndex);
|
|
if (Entry != nullptr
|
|
&& Entry->Has(FPassageFloorProjectionCacheEntry::GenericComputed))
|
|
{
|
|
if (Entry->bGenericValid)
|
|
{
|
|
OutFloorZ = Entry->GenericFloorZ;
|
|
OutSupportRadius = Entry->GenericSupportRadius;
|
|
}
|
|
return Entry->bGenericValid;
|
|
}
|
|
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
bool bValid = VF_ProjectNativePassageFloor(
|
|
Passage, Position, FloorZ, SupportRadius,
|
|
Manager, PassageIndex);
|
|
if (!bValid && Passage.bWalkableTunnelContract)
|
|
{
|
|
bValid = VoxelPassageGeometry::ProjectWalkableTunnelFloor(
|
|
Passage.ControlPoints, Passage.ControlRadii, Position,
|
|
FloorZ, SupportRadius);
|
|
}
|
|
|
|
if (Entry != nullptr)
|
|
{
|
|
Entry->bGenericValid = bValid;
|
|
if (bValid)
|
|
{
|
|
Entry->GenericFloorZ = FloorZ;
|
|
Entry->GenericSupportRadius = SupportRadius;
|
|
}
|
|
Entry->SetComputed(FPassageFloorProjectionCacheEntry::GenericComputed);
|
|
}
|
|
if (bValid)
|
|
{
|
|
OutFloorZ = FloorZ;
|
|
OutSupportRadius = SupportRadius;
|
|
}
|
|
return bValid;
|
|
}
|
|
}
|
|
|
|
static void VF_AuthorNativePassageFloor(
|
|
FVoxelPassage& Passage,
|
|
const UVoxelStrateDefinition* UpperDefinition,
|
|
const UVoxelStrateDefinition* LowerDefinition,
|
|
uint32 PassageSeed)
|
|
{
|
|
Passage.bNativeFloorEnabled = false;
|
|
Passage.NativeFloorProfileZ.Reset();
|
|
Passage.NativeFloorReliefScales.Reset();
|
|
Passage.NativeFloorReliefStrength = 0.0f;
|
|
Passage.NativeFloorReliefFrequency = 0.015f;
|
|
Passage.NativeFloorSeed = 0;
|
|
|
|
if (!Passage.bWalkableTunnelContract
|
|
|| Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Inter-strate passages can connect to non-cave archetypes. Keep the native D profile for
|
|
// the walkable contract in all cases, but only borrow relief from a cave strate that actually
|
|
// owns the same room-floor field. A malformed/unknown parameter source fails closed to a
|
|
// flat floor rather than inventing a second relief system.
|
|
const FStrateGenerationParams* ReliefParams = nullptr;
|
|
if (UpperDefinition != nullptr
|
|
&& (UpperDefinition->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
|
|| UpperDefinition->GeneratorType == ECaveGeneratorType::Underwater))
|
|
{
|
|
ReliefParams = &UpperDefinition->GenerationParams;
|
|
}
|
|
else if (LowerDefinition != nullptr
|
|
&& (LowerDefinition->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
|
|| LowerDefinition->GeneratorType == ECaveGeneratorType::Underwater))
|
|
{
|
|
ReliefParams = &LowerDefinition->GenerationParams;
|
|
}
|
|
if (ReliefParams != nullptr)
|
|
{
|
|
Passage.NativeFloorReliefStrength = FMath::Max(
|
|
ReliefParams->FloorReliefStrength, 0.0f);
|
|
Passage.NativeFloorReliefFrequency = ReliefParams->FloorReliefFrequency;
|
|
}
|
|
Passage.NativeFloorSeed = VoxelHash::Mix(PassageSeed ^ 0xD00DF10Eu);
|
|
Passage.NativeFloorProfileZ.SetNum(Passage.ControlPoints.Num());
|
|
for (int32 PointIndex = 0;
|
|
PointIndex < Passage.ControlPoints.Num();
|
|
++PointIndex)
|
|
{
|
|
const float Radius = FMath::Abs(Passage.ControlRadii[PointIndex]);
|
|
const float FloorZ = VoxelPassageGeometry::TunnelFloorZ(
|
|
Passage.ControlPoints[PointIndex], Radius);
|
|
if (!VoxelMath::IsFinite(FloorZ))
|
|
{
|
|
Passage.NativeFloorProfileZ.Reset();
|
|
return;
|
|
}
|
|
Passage.NativeFloorProfileZ[PointIndex] = FloorZ;
|
|
}
|
|
|
|
const int32 NumSegments = Passage.ControlPoints.Num() - 1;
|
|
Passage.NativeFloorReliefScales.SetNumZeroed(NumSegments);
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex < NumSegments;
|
|
++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
const float HorizontalRun = FVector2D(
|
|
static_cast<float>(B.X - A.X),
|
|
static_cast<float>(B.Y - A.Y)).Size();
|
|
const float BaseGradient = HorizontalRun > KINDA_SMALL_NUMBER
|
|
? FMath::Abs(
|
|
Passage.NativeFloorProfileZ[SegmentIndex + 1]
|
|
- Passage.NativeFloorProfileZ[SegmentIndex]) / HorizontalRun
|
|
: FLT_MAX;
|
|
Passage.NativeFloorReliefScales[SegmentIndex] =
|
|
VF_BuildPassageReliefScale(
|
|
A, B, BaseGradient, SegmentIndex, NumSegments,
|
|
Passage.NativeFloorReliefStrength,
|
|
Passage.NativeFloorReliefFrequency);
|
|
}
|
|
Passage.bNativeFloorEnabled =
|
|
Passage.NativeFloorProfileZ.Num() == Passage.ControlPoints.Num();
|
|
}
|
|
|
|
static float VF_BoundarySealThicknessForDefinition(
|
|
const UVoxelStrateDefinition& Definition)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The default inter-strate tunnel is a carved tube plus an explicit support slab. The tube's
|
|
* rounded SDF guarantees clearance, but it cannot guarantee a standable surface over a source
|
|
* field or a disturbance that was solid before the passage post. Project the query into the
|
|
* nearest horizontal control segment and return the conservative floor carried by that segment.
|
|
* This is a fixed-size arithmetic loop over the already-built descriptor; it performs no source
|
|
* search, allocation, or topology work.
|
|
*/
|
|
static bool VF_IsWalkableTunnelFloor(
|
|
const FVoxelPassage& Passage,
|
|
const FVector& Position,
|
|
const UVoxelStrateManager* Manager = nullptr,
|
|
int32 PassageIndex = INDEX_NONE)
|
|
{
|
|
if (!Passage.bWalkableTunnelContract
|
|
|| Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
if (!VF_ProjectPassageFloor(
|
|
Passage, Position, FloorZ, SupportRadius, Manager, PassageIndex))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return Position.Z <= FloorZ + KINDA_SMALL_NUMBER
|
|
&& Position.Z > FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels;
|
|
}
|
|
|
|
static bool VF_IsWalkableTunnelAir(
|
|
const FVoxelPassage& Passage,
|
|
const FVector& Position,
|
|
const UVoxelStrateManager* Manager = nullptr,
|
|
int32 PassageIndex = INDEX_NONE,
|
|
float* OutFloorZ = nullptr,
|
|
float* OutSupportRadius = nullptr)
|
|
{
|
|
if (!Passage.bWalkableTunnelContract
|
|
|| Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FPassageFloorProjectionCacheEntry* Entry =
|
|
VF_GetPassageFloorProjectionCacheEntry(Manager, Position, PassageIndex);
|
|
if (Entry != nullptr
|
|
&& Entry->Has(FPassageFloorProjectionCacheEntry::WalkableAirComputed))
|
|
{
|
|
if (Entry->bWalkableAir)
|
|
{
|
|
if (OutFloorZ != nullptr)
|
|
{
|
|
*OutFloorZ = Entry->GenericFloorZ;
|
|
}
|
|
if (OutSupportRadius != nullptr)
|
|
{
|
|
*OutSupportRadius = Entry->GenericSupportRadius;
|
|
}
|
|
}
|
|
return Entry->bWalkableAir;
|
|
}
|
|
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
if (!VF_ProjectPassageFloor(
|
|
Passage, Position, FloorZ, SupportRadius, Manager, PassageIndex))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// The carved tube is wider than this guaranteed core. The core is deliberately expressed as
|
|
// a vertical prism over the projected floor: it gives the final-density player stencil a
|
|
// stable air volume even when a source archetype or a disturbance would otherwise refill the
|
|
// shallow part of the rounded SDF. The floor writer below owns the closed lower face.
|
|
const float AirHeight = 2.0f * SupportRadius;
|
|
const bool bWalkableAir = Position.Z > FloorZ
|
|
+ VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels
|
|
&& Position.Z < FloorZ + AirHeight - KINDA_SMALL_NUMBER;
|
|
if (Entry != nullptr)
|
|
{
|
|
Entry->bWalkableAir = bWalkableAir;
|
|
Entry->SetComputed(FPassageFloorProjectionCacheEntry::WalkableAirComputed);
|
|
}
|
|
if (bWalkableAir)
|
|
{
|
|
if (OutFloorZ != nullptr)
|
|
{
|
|
*OutFloorZ = FloorZ;
|
|
}
|
|
if (OutSupportRadius != nullptr)
|
|
{
|
|
*OutSupportRadius = SupportRadius;
|
|
}
|
|
}
|
|
return bWalkableAir;
|
|
}
|
|
|
|
static bool VF_FindWalkableTunnelAir(
|
|
const UVoxelStrateManager* Manager,
|
|
const FVector& Position,
|
|
int32& OutPassageIndex,
|
|
float& OutFloorZ,
|
|
float& OutSupportRadius)
|
|
{
|
|
OutPassageIndex = INDEX_NONE;
|
|
OutFloorZ = 0.0f;
|
|
OutSupportRadius = 0.0f;
|
|
if (Manager == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FIntVector ChunkCoord(
|
|
FMath::FloorToInt(Position.X / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(Position.Y / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(Position.Z / (float)CHUNK_SIZE));
|
|
const TArray<int32>& Nearby = VF_GetNearbyPassages(Manager, ChunkCoord);
|
|
const TArray<FVoxelPassage>& Passages = Manager->GetPassages();
|
|
const FVoxelPassage* PassageData = Passages.GetData();
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
if (!Passages.IsValidIndex(PassageIndex))
|
|
{
|
|
continue;
|
|
}
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq
|
|
|| !VF_IsWalkableTunnelAir(
|
|
Passage, Position, Manager, PassageIndex,
|
|
&OutFloorZ, &OutSupportRadius))
|
|
{
|
|
continue;
|
|
}
|
|
OutPassageIndex = PassageIndex;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool VF_IsAnyPassageFloorAt(
|
|
const UVoxelStrateManager* Manager,
|
|
const FVector& Position)
|
|
{
|
|
if (Manager == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
const FIntVector ChunkCoord(
|
|
FMath::FloorToInt(Position.X / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(Position.Y / (float)CHUNK_SIZE),
|
|
FMath::FloorToInt(Position.Z / (float)CHUNK_SIZE));
|
|
const TArray<int32>& Nearby = VF_GetNearbyPassages(Manager, ChunkCoord);
|
|
const TArray<FVoxelPassage>& Passages = Manager->GetPassages();
|
|
const FVoxelPassage* PassageData = Passages.GetData();
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
if (!Passages.IsValidIndex(PassageIndex))
|
|
{
|
|
continue;
|
|
}
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()
|
|
&& (VF_IsPassageLandingFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageLandingFloor(Position, Passage.LowerLanding)
|
|
|| (Manager->ArePassageSupportFloorWritesEnabledForDiagnostics()
|
|
&& VF_IsWalkableTunnelFloor(Passage, Position, Manager, PassageIndex))))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
|
|
{
|
|
VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("StrateManager.Initialize"));
|
|
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;
|
|
}
|
|
|
|
// Keep a valid live layout intact until every season/layout validation step below has
|
|
// succeeded. RebuildStrates is called from live edit while old meshes are still visible;
|
|
// returning false after clearing these members would leave those meshes backed by an empty or
|
|
// partial manager. The copies are small (one entry per strate) and this path is not part of
|
|
// worker generation.
|
|
const TArray<FStrateSlot> PreviousLayout = StrateLayout;
|
|
const TArray<FVoxelSeasonStrate> PreviousSeasonStrates = SeasonStrates;
|
|
const FString PreviousSeasonContentHash = ActiveSeasonContentHash;
|
|
#if WITH_EDITOR
|
|
const TMap<int32, TSharedPtr<FVoxelStrateComposerSlotOverride>> PreviousComposerOverrides = ComposerOverrides;
|
|
#endif
|
|
auto RestorePreviousLayout = [&]()
|
|
{
|
|
StrateLayout = PreviousLayout;
|
|
SeasonStrates = PreviousSeasonStrates;
|
|
ActiveSeasonContentHash = PreviousSeasonContentHash;
|
|
#if WITH_EDITOR
|
|
ComposerOverrides = PreviousComposerOverrides;
|
|
#endif
|
|
};
|
|
|
|
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);
|
|
RestorePreviousLayout();
|
|
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->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);
|
|
RestorePreviousLayout();
|
|
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);
|
|
|
|
if (VoxelForgeStartupTrace::IsActive())
|
|
{
|
|
FString DefinitionPath = Slot.Definition ? Slot.Definition->GetPathName() : FString();
|
|
DefinitionPath.ReplaceInline(TEXT("\\"), TEXT("\\\\"));
|
|
DefinitionPath.ReplaceInline(TEXT("\""), TEXT("\\\""));
|
|
const UEnum* ArchetypeEnum = StaticEnum<ECaveGeneratorType>();
|
|
const FString ArchetypeName = ArchetypeEnum
|
|
? ArchetypeEnum->GetNameStringByValue(static_cast<int64>(Slot.Definition->GeneratorType))
|
|
: FString::Printf(TEXT("Value_%d"), static_cast<int32>(Slot.Definition->GeneratorType));
|
|
VoxelForgeStartupTrace::RecordEvent(TEXT("strate_slot"), FString::Printf(
|
|
TEXT("\"index\":%d,\"definition\":\"%s\",\"archetype\":\"%s\",\"top_chunk_z\":%d,"
|
|
"\"bottom_chunk_z\":%d,\"height_chunks\":%d"),
|
|
Slot.StrateIndex, *DefinitionPath, *ArchetypeName,
|
|
Slot.TopChunkZ, Slot.BottomChunkZ, Slot.HeightInChunks));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
|
|
VoxelForgeStartupTrace::RecordEvent(TEXT("strate_layout_ready"), FString::Printf(
|
|
TEXT("\"strates\":%d,\"seed\":%d,\"inter_strate_gap_chunks\":%d,\"season\":%d"),
|
|
StrateLayout.Num(), WorldSeed, InterStrateGapChunks, bUseSeason ? 1 : 0));
|
|
|
|
// 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;
|
|
if ((VF_WormsForceOff() || !TargetSlot->Definition->bEnableWorms)
|
|
&& (Archetype == ECaveGeneratorType::TunnelNetwork
|
|
|| Archetype == ECaveGeneratorType::Underwater))
|
|
{
|
|
// The composer is allowed to roll a complete candidate, but a disabled asset must never
|
|
// retain a worm-enabled candidate in its runtime override, even for code that inspects
|
|
// the override before GetGenerationParams applies the final resolution gate.
|
|
Override->Params.TunnelNetworkParams.WormStrength = 0.0f;
|
|
}
|
|
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()
|
|
{
|
|
VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("GeneratePassages"));
|
|
// Invalidate worker-local passage shortlists even when the new layout is empty. Returning
|
|
// before this bump would leave a nearby-passage TLS list pointing at the cleared array.
|
|
++PassagesVersion;
|
|
Passages.Empty();
|
|
|
|
if (StrateLayout.Num() < 1)
|
|
{
|
|
VoxelForgeStartupTrace::RecordEvent(TEXT("passages_ready"), TEXT("\"passages\":0"));
|
|
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;
|
|
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 est un bug — la requête trouve la salle la plus
|
|
// proche puis atterrit à 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 is a bug: the query finds the nearest room and then lands 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;
|
|
}
|
|
};
|
|
|
|
//=========================================================================
|
|
// 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 progression tunnel to the layer below.
|
|
// Each strate already owns a finite (0,0) landing room; this passage is the connection
|
|
// that opens the next room rather than a second vertical spine.
|
|
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);
|
|
// The origin room owns the future straight shaft. Keep the progression mouth outside its
|
|
// carve/blend envelope, then choose a deterministic point in the authored annulus. If an
|
|
// asset has an invalid inner radius, repair only that radius; the outer authored radius is
|
|
// preserved unless it is too small to contain the required inner edge.
|
|
const float OriginLandingHalfWidth =
|
|
VoxelPassageGeometry::LandingHalfWidthForRadius(OriginSpineRadius);
|
|
const float MinimumDescentDistance = FMath::Max(
|
|
OriginLandingHalfWidth + VoxelPassageGeometry::LandingCarveBlendVoxels,
|
|
VoxelPassageGeometry::MinimumTurnFloorWidthVoxels);
|
|
const float PlacementLo = FMath::Max(DistLo, MinimumDescentDistance);
|
|
const float PlacementHi = FMath::Max(DistHi, PlacementLo);
|
|
|
|
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;
|
|
// The authored style is also reflected in the legacy passage type. In particular,
|
|
// only Straight receives the walkable-tunnel contract; a future style roll cannot
|
|
// silently inherit the default's player-fit promise.
|
|
switch (Cfg.Style)
|
|
{
|
|
case EVoxelPassageStyle::Straight:
|
|
Passage.PassageType = EVoxelPassageType::SlopedTunnel;
|
|
break;
|
|
case EVoxelPassageStyle::Spiral:
|
|
Passage.PassageType = EVoxelPassageType::SpiralDescent;
|
|
break;
|
|
case EVoxelPassageStyle::Cascading:
|
|
Passage.PassageType = EVoxelPassageType::CascadingDrops;
|
|
break;
|
|
case EVoxelPassageStyle::Worm:
|
|
default:
|
|
Passage.PassageType = EVoxelPassageType::CrackCrevice;
|
|
break;
|
|
}
|
|
|
|
// PLACEMENT: random angle, distance from the (0,0) spine within a deterministic
|
|
// annulus. It is deliberately not the spine: that room is reserved for the future
|
|
// surface shaft, while this passage opens a separate room in the strate network.
|
|
const float Angle = PassageRandom01(PassageSaltAngle) * (2.0f * PI);
|
|
const float Distance = PassageRandomRange(PlacementLo, PlacementHi, PassageSaltDistance);
|
|
float SinAngle = 0.0f, CosAngle = 0.0f;
|
|
VoxelMath::DetSinCos(SinAngle, CosAngle, Angle);
|
|
const float PX = CosAngle * Distance;
|
|
const float PY = SinAngle * 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,
|
|
CachedSeed);
|
|
|
|
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,
|
|
CachedSeed);
|
|
|
|
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 (VoxelMath::IsFinite(SuggestedUpperPoint.X)
|
|
&& VoxelMath::IsFinite(SuggestedUpperPoint.Y)
|
|
&& VoxelMath::IsFinite(SuggestedUpperPoint.Z)
|
|
&& VoxelMath::IsFinite(InnerBottomZ)
|
|
&& VoxelMath::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 (VoxelMath::IsFinite(SuggestedLowerPoint.X)
|
|
&& VoxelMath::IsFinite(SuggestedLowerPoint.Y)
|
|
&& VoxelMath::IsFinite(SuggestedLowerPoint.Z)
|
|
&& VoxelMath::IsFinite(InnerBottomZ)
|
|
&& VoxelMath::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, VoxelMath::DetSin(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 = VoxelMath::DetSin(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;
|
|
float SinAng = 0.0f, CosAng = 0.0f;
|
|
VoxelMath::DetSinCos(SinAng, CosAng, Ang);
|
|
OX = CosAng * Cfg.SpiralRadius * Env;
|
|
OY = SinAng * 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
|
|
float SinSA = 0.0f, CosSA = 0.0f;
|
|
VoxelMath::DetSinCos(SinSA, CosSA, SA);
|
|
OX = CosSA * Cfg.CascadeLedge * Env;
|
|
OY = SinSA * 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(VoxelMath::DetSin(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())
|
|
{
|
|
float SinAngle = 0.0f, CosAngle = 0.0f;
|
|
VoxelMath::DetSinCos(SinAngle, CosAngle, Angle);
|
|
Direction = FVector(CosAngle, SinAngle, 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 is the landing itself. Keep it local to the requested room: the
|
|
// (0,0) landing is reserved for the future straight shaft, so no radial connector is
|
|
// synthesized here.
|
|
Passage.UpperLanding = VF_BuildPassageLanding(
|
|
FVector(UpperX, UpperY, TopZ),
|
|
Cfg.MouthRadius,
|
|
UpperDoorDirection,
|
|
UpperTopZ,
|
|
UpperBottomZ,
|
|
UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f,
|
|
bAimedUpperAtPlayerFitPoint);
|
|
Passage.LowerLanding = VF_BuildPassageLanding(
|
|
FVector(LowerX, LowerY, BottomZ),
|
|
Cfg.MouthRadius,
|
|
LowerDoorDirection,
|
|
LowerTopZ,
|
|
(float)(Lower.BottomChunkZ) * CHUNK_SIZE,
|
|
LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f,
|
|
bAimedLowerAtPlayerFitPoint);
|
|
Passage.UpperPoint = Passage.UpperLanding.StandingPoint;
|
|
Passage.LowerPoint = Passage.LowerLanding.StandingPoint;
|
|
|
|
if (Cfg.Style == EVoxelPassageStyle::Straight
|
|
&& Passage.UpperLanding.HalfWidth > 0.0f
|
|
&& Passage.LowerLanding.HalfWidth > 0.0f)
|
|
{
|
|
// BASE CONNECTION: a walkable two-leg switchback. A direct line between the
|
|
// mouths would often be nearly vertical because both requests are placed at the
|
|
// same XY radius. Put the turn on the perpendicular bisector and give each leg
|
|
// at least half of the horizontal run required by the named 15-degree law.
|
|
// This is deliberately a construction rule, not a seed-dependent observation.
|
|
const float SwitchbackAngle =
|
|
PassageRandom01(PassageSaltUpperDoor) * 2.0f * PI;
|
|
float SinSwitchback = 0.0f, CosSwitchback = 0.0f;
|
|
VoxelMath::DetSinCos(SinSwitchback, CosSwitchback, SwitchbackAngle);
|
|
FVector2D SwitchbackDirection(CosSwitchback, SinSwitchback);
|
|
if (!SwitchbackDirection.Normalize())
|
|
{
|
|
SwitchbackDirection = FVector2D(1.0f, 0.0f);
|
|
}
|
|
|
|
const FVector DoorDirection(
|
|
SwitchbackDirection.X, SwitchbackDirection.Y, 0.0f);
|
|
Passage.UpperLanding.DoorDirection = DoorDirection;
|
|
// Keep both door approaches on the same outward side so each sloped leg reaches
|
|
// its own apron before entering the room. Separate their turn-side floor branches
|
|
// below so the two projections do not retrace one XY line at different heights.
|
|
const FVector TunnelLowerDoorDirection = DoorDirection;
|
|
Passage.LowerLanding.DoorDirection = TunnelLowerDoorDirection;
|
|
const float UpperDoorOffset = FMath::Max(
|
|
Passage.UpperLanding.HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f);
|
|
const float LowerDoorOffset = FMath::Max(
|
|
Passage.LowerLanding.HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f);
|
|
const float SafeMouthRadius = FMath::Max(
|
|
FMath::Abs(Cfg.MouthRadius),
|
|
VoxelPassageGeometry::MetersToVoxels(0.25f));
|
|
Passage.UpperLanding.DoorPoint =
|
|
Passage.UpperLanding.StandingPoint + DoorDirection * UpperDoorOffset;
|
|
Passage.UpperLanding.DoorPoint.Z =
|
|
Passage.UpperLanding.FloorZ + SafeMouthRadius;
|
|
Passage.LowerLanding.DoorPoint =
|
|
Passage.LowerLanding.StandingPoint
|
|
+ TunnelLowerDoorDirection * LowerDoorOffset;
|
|
Passage.LowerLanding.DoorPoint.Z =
|
|
Passage.LowerLanding.FloorZ + SafeMouthRadius;
|
|
|
|
const FVector UpperDoor = Passage.UpperLanding.DoorPoint;
|
|
const FVector LowerDoor = Passage.LowerLanding.DoorPoint;
|
|
// Keep a short level apron beyond each room floor. The door anchor is one voxel
|
|
// inside the room's support square; a diagonal door direction can therefore need
|
|
// several voxels before it leaves that square and its four-voxel SDF blend.
|
|
// The level apron must clear the room half-width, the tube radius, and the
|
|
// smooth carve band before the sloped leg begins. The final six-voxel transition
|
|
// is measured from that clear point back toward the turn, so include it too. For
|
|
// the default 8-voxel half-width / 6-voxel mouth this is 17 voxels (4.25 m), not
|
|
// the old 6-voxel minimum that left the sloped capsule inside the landing room.
|
|
const float LandingApron = FMath::Max(
|
|
VoxelPassageGeometry::WalkableTunnelLandingApronVoxels,
|
|
VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels
|
|
+ SafeMouthRadius
|
|
+ VoxelPassageGeometry::LandingCarveBlendVoxels
|
|
+ 1.0f);
|
|
const FVector UpperApronEnd = UpperDoor
|
|
+ DoorDirection * LandingApron;
|
|
const FVector LowerApronBegin = LowerDoor
|
|
+ TunnelLowerDoorDirection * LandingApron;
|
|
const float VerticalDrop = FMath::Abs(UpperDoor.Z - LowerDoor.Z);
|
|
const float UpperFloorZ = VoxelPassageGeometry::TunnelFloorZ(
|
|
UpperDoor, Cfg.MouthRadius);
|
|
const float LowerFloorZ = VoxelPassageGeometry::TunnelFloorZ(
|
|
LowerDoor, Cfg.MouthRadius);
|
|
const float MidFloorZ = 0.5f * (UpperFloorZ + LowerFloorZ);
|
|
// Each ramp must leave its own landing on the outward side. The two doors face
|
|
// that same direction; orient the shared level turn along it, then split the lower
|
|
// floor branch laterally so the ramp projections remain unambiguous.
|
|
FVector2D UpperOutward(
|
|
UpperApronEnd.X - Passage.UpperLanding.StandingPoint.X,
|
|
UpperApronEnd.Y - Passage.UpperLanding.StandingPoint.Y);
|
|
FVector2D LowerOutward(
|
|
LowerApronBegin.X - Passage.LowerLanding.StandingPoint.X,
|
|
LowerApronBegin.Y - Passage.LowerLanding.StandingPoint.Y);
|
|
UpperOutward.Normalize();
|
|
LowerOutward.Normalize();
|
|
const float UpperFloorDrop = UpperFloorZ - MidFloorZ;
|
|
const float LowerFloorDrop = MidFloorZ - LowerFloorZ;
|
|
const float RequiredHorizontalRun =
|
|
VoxelPassageGeometry::RequiredHorizontalRunForFloorDrop(
|
|
UpperFloorDrop, LowerFloorDrop);
|
|
// Move the level turn farther than half the required run. Each slope then loses
|
|
// only the named transition length to its level segment and still retains the
|
|
// full horizontal run required by the 15-degree floor law.
|
|
const float TurnTransition =
|
|
VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels;
|
|
const float TurnOffset =
|
|
0.5f * RequiredHorizontalRun + TurnTransition;
|
|
FVector2D TurnDirection(
|
|
UpperOutward.X + LowerOutward.X,
|
|
UpperOutward.Y + LowerOutward.Y);
|
|
if (!TurnDirection.Normalize())
|
|
{
|
|
TurnDirection = UpperOutward;
|
|
}
|
|
const FVector2D ApronChord(
|
|
LowerApronBegin.X - UpperApronEnd.X,
|
|
LowerApronBegin.Y - UpperApronEnd.Y);
|
|
const float LowerApronAlong = FVector2D::DotProduct(
|
|
ApronChord, TurnDirection);
|
|
const float SharedTurnOffset = TurnOffset
|
|
+ FMath::Max(LowerApronAlong, 0.0f);
|
|
const FVector2D SharedTurnMid(
|
|
UpperApronEnd.X + TurnDirection.X * SharedTurnOffset,
|
|
UpperApronEnd.Y + TurnDirection.Y * SharedTurnOffset);
|
|
const float TurnLegSeparation =
|
|
VoxelPassageGeometry::WalkableTunnelTurnLegSeparation(
|
|
Cfg.MidRadius, Cfg.MouthRadius);
|
|
FVector2D TurnLegSideDirection(-TurnDirection.Y, TurnDirection.X);
|
|
if (FVector2D::DotProduct(ApronChord, TurnLegSideDirection) < 0.0f)
|
|
{
|
|
TurnLegSideDirection *= -1.0f;
|
|
}
|
|
const FVector2D UpperTurnMid = SharedTurnMid;
|
|
const FVector2D LowerTurnMid(
|
|
SharedTurnMid.X + TurnLegSideDirection.X * TurnLegSeparation,
|
|
SharedTurnMid.Y + TurnLegSideDirection.Y * TurnLegSeparation);
|
|
FVector2D UpperSlopeDirection(
|
|
UpperTurnMid.X - UpperApronEnd.X,
|
|
UpperTurnMid.Y - UpperApronEnd.Y);
|
|
if (!UpperSlopeDirection.Normalize())
|
|
{
|
|
UpperSlopeDirection = FVector2D(
|
|
DoorDirection.X, DoorDirection.Y);
|
|
}
|
|
FVector2D LowerSlopeDirection(
|
|
LowerApronBegin.X - LowerTurnMid.X,
|
|
LowerApronBegin.Y - LowerTurnMid.Y);
|
|
if (!LowerSlopeDirection.Normalize())
|
|
{
|
|
LowerSlopeDirection = -UpperSlopeDirection;
|
|
}
|
|
const FVector2D UpperSlopeStartXY(
|
|
UpperApronEnd.X + UpperSlopeDirection.X * TurnTransition,
|
|
UpperApronEnd.Y + UpperSlopeDirection.Y * TurnTransition);
|
|
const FVector2D LowerSlopeEndXY(
|
|
LowerApronBegin.X - LowerSlopeDirection.X * TurnTransition,
|
|
LowerApronBegin.Y - LowerSlopeDirection.Y * TurnTransition);
|
|
const FVector UpperApronEndPoint(
|
|
UpperApronEnd.X, UpperApronEnd.Y,
|
|
UpperFloorZ + FMath::Abs(Cfg.MouthRadius));
|
|
const FVector UpperSlopeStart(
|
|
UpperSlopeStartXY.X, UpperSlopeStartXY.Y,
|
|
UpperFloorZ + FMath::Abs(Cfg.MouthRadius));
|
|
const FVector MidPoint(
|
|
UpperTurnMid.X, UpperTurnMid.Y,
|
|
MidFloorZ + FMath::Abs(Cfg.MidRadius));
|
|
const FVector LowerTurnPoint(
|
|
LowerTurnMid.X, LowerTurnMid.Y,
|
|
MidFloorZ + FMath::Abs(Cfg.MidRadius));
|
|
const FVector LowerSlopeEnd(
|
|
LowerSlopeEndXY.X, LowerSlopeEndXY.Y,
|
|
LowerFloorZ + FMath::Abs(Cfg.MouthRadius));
|
|
const FVector LowerApronBeginPoint(
|
|
LowerApronBegin.X, LowerApronBegin.Y,
|
|
LowerFloorZ + FMath::Abs(Cfg.MouthRadius));
|
|
|
|
Passage.ControlPoints.Reset(8);
|
|
Passage.ControlRadii.Reset(8);
|
|
Passage.ControlPoints.Add(UpperDoor);
|
|
Passage.ControlPoints.Add(UpperApronEndPoint);
|
|
Passage.ControlPoints.Add(UpperSlopeStart);
|
|
Passage.ControlPoints.Add(MidPoint);
|
|
Passage.ControlPoints.Add(LowerTurnPoint);
|
|
Passage.ControlPoints.Add(LowerSlopeEnd);
|
|
Passage.ControlPoints.Add(LowerApronBeginPoint);
|
|
Passage.ControlPoints.Add(LowerDoor);
|
|
Passage.ControlRadii.Add(Cfg.MouthRadius);
|
|
Passage.ControlRadii.Add(Cfg.MouthRadius);
|
|
Passage.ControlRadii.Add(Cfg.MouthRadius);
|
|
Passage.ControlRadii.Add(Cfg.MidRadius);
|
|
Passage.ControlRadii.Add(Cfg.MidRadius);
|
|
Passage.ControlRadii.Add(Cfg.MouthRadius);
|
|
Passage.ControlRadii.Add(Cfg.MouthRadius);
|
|
Passage.ControlRadii.Add(Cfg.MouthRadius);
|
|
|
|
Passage.PassageType = EVoxelPassageType::SlopedTunnel;
|
|
Passage.bWalkableTunnelContract = true;
|
|
Passage.TunnelMaxGradientDegrees =
|
|
VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees;
|
|
Passage.TunnelVerticalDropVoxels = VerticalDrop;
|
|
Passage.TunnelRequiredHorizontalRunVoxels = RequiredHorizontalRun;
|
|
Passage.TunnelHorizontalPathLengthVoxels = 0.0f;
|
|
for (int32 ControlIndex = 0;
|
|
ControlIndex + 1 < Passage.ControlPoints.Num();
|
|
++ControlIndex)
|
|
{
|
|
Passage.TunnelHorizontalPathLengthVoxels += FVector2D(
|
|
Passage.ControlPoints[ControlIndex + 1].X
|
|
- Passage.ControlPoints[ControlIndex].X,
|
|
Passage.ControlPoints[ControlIndex + 1].Y
|
|
- Passage.ControlPoints[ControlIndex].Y).Size();
|
|
}
|
|
Passage.TunnelMinimumClearWidthVoxels = 2.0f * FMath::Max(
|
|
FMath::Min(FMath::Abs(Cfg.MouthRadius), FMath::Abs(Cfg.MidRadius)),
|
|
0.0f);
|
|
Passage.TunnelClearHeightVoxels = Passage.TunnelMinimumClearWidthVoxels;
|
|
}
|
|
else 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));
|
|
|
|
// The passage now owns its D-section at construction time. Both landing floors,
|
|
// the complete control chain, and the room-derived relief source are known here;
|
|
// no voxel query needs to decide whether this particular passage deserves a floor.
|
|
VF_AuthorNativePassageFloor(
|
|
Passage, UpperDef, LowerDef,
|
|
PassageSeed ^ VoxelHash::Cell(i, c, PassageSeed));
|
|
|
|
// Bounding sphere over the tube, both rooms, and both floor slabs (+ 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);
|
|
};
|
|
auto IncludeBounds = [&BoundsMin, &BoundsMax](
|
|
const FVector& Min, const FVector& Max)
|
|
{
|
|
BoundsMin.X = FMath::Min(BoundsMin.X, Min.X);
|
|
BoundsMin.Y = FMath::Min(BoundsMin.Y, Min.Y);
|
|
BoundsMin.Z = FMath::Min(BoundsMin.Z, Min.Z);
|
|
BoundsMax.X = FMath::Max(BoundsMax.X, Max.X);
|
|
BoundsMax.Y = FMath::Max(BoundsMax.Y, Max.Y);
|
|
BoundsMax.Z = FMath::Max(BoundsMax.Z, Max.Z);
|
|
};
|
|
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);
|
|
};
|
|
IncludeLanding(Passage.UpperLanding);
|
|
IncludeLanding(Passage.LowerLanding);
|
|
|
|
// These two MC writers run after the tunnel SDF: the native support slab uses
|
|
// the relieved floor profile, and the final corridor air envelope uses each
|
|
// segment's own relief branch plus the door-to-standing approaches. Include the
|
|
// actual write suprema so the chunk shortlist and all sphere-based proofs remain
|
|
// conservative even when the authored relief exceeds the old one-voxel reserve.
|
|
if (Passage.bWalkableTunnelContract && Passage.bNativeFloorEnabled)
|
|
{
|
|
constexpr float CorridorHalfWidth =
|
|
0.5f * VoxelPassageGeometry::WalkCorridorPlayerWidthVoxels
|
|
+ VoxelPassageGeometry::WalkCorridorLatticeMarginVoxels;
|
|
constexpr float CorridorBottom =
|
|
VoxelPassageGeometry::MaxStepHeightVoxels;
|
|
constexpr float CorridorTop = CorridorBottom
|
|
+ VoxelPassageGeometry::WalkCorridorPlayerHeightVoxels;
|
|
float ReliefBound = 0.0f;
|
|
if (VF_GetNativePassageFloorReliefBound(Passage, ReliefBound)
|
|
&& Passage.ControlPoints.Num() >= 2
|
|
&& Passage.ControlRadii.Num() == Passage.ControlPoints.Num())
|
|
{
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num();
|
|
++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
const float RadiusA =
|
|
FMath::Abs(Passage.ControlRadii[SegmentIndex]);
|
|
const float RadiusB =
|
|
FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]);
|
|
const float FloorA =
|
|
Passage.NativeFloorProfileZ[SegmentIndex];
|
|
const float FloorB =
|
|
Passage.NativeFloorProfileZ[SegmentIndex + 1];
|
|
if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y)
|
|
|| !VoxelMath::IsFinite(B.X) || !VoxelMath::IsFinite(B.Y)
|
|
|| !VoxelMath::IsFinite(FloorA)
|
|
|| !VoxelMath::IsFinite(FloorB))
|
|
{
|
|
continue;
|
|
}
|
|
const float SupportRadius =
|
|
VoxelPassageGeometry::NativePassageFloorSupportRadius(
|
|
RadiusA, RadiusB);
|
|
IncludeBounds(
|
|
FVector(
|
|
FMath::Min(A.X, B.X) - SupportRadius,
|
|
FMath::Min(A.Y, B.Y) - SupportRadius,
|
|
FMath::Min(FloorA, FloorB) - ReliefBound
|
|
- VoxelPassageGeometry::LandingFloorThicknessVoxels),
|
|
FVector(
|
|
FMath::Max(A.X, B.X) + SupportRadius,
|
|
FMath::Max(A.Y, B.Y) + SupportRadius,
|
|
FMath::Max(FloorA, FloorB) + ReliefBound
|
|
+ KINDA_SMALL_NUMBER));
|
|
IncludeBounds(
|
|
FVector(
|
|
FMath::Min(A.X, B.X) - CorridorHalfWidth,
|
|
FMath::Min(A.Y, B.Y) - CorridorHalfWidth,
|
|
FMath::Min(FloorA, FloorB) + CorridorBottom
|
|
- ReliefBound),
|
|
FVector(
|
|
FMath::Max(A.X, B.X) + CorridorHalfWidth,
|
|
FMath::Max(A.Y, B.Y) + CorridorHalfWidth,
|
|
FMath::Max(FloorA, FloorB) + CorridorTop
|
|
+ ReliefBound));
|
|
}
|
|
}
|
|
|
|
for (const FVoxelPassageLanding* Landing : {
|
|
&Passage.UpperLanding, &Passage.LowerLanding })
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->DoorPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->DoorPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ))
|
|
{
|
|
continue;
|
|
}
|
|
const float LengthSquared = FVector2D::DistSquared(
|
|
FVector2D(Landing->DoorPoint.X, Landing->DoorPoint.Y),
|
|
FVector2D(Landing->StandingPoint.X, Landing->StandingPoint.Y));
|
|
if (!(LengthSquared > KINDA_SMALL_NUMBER)
|
|
|| !VoxelMath::IsFinite(LengthSquared))
|
|
{
|
|
continue;
|
|
}
|
|
IncludeBounds(
|
|
FVector(
|
|
FMath::Min(Landing->DoorPoint.X, Landing->StandingPoint.X)
|
|
- CorridorHalfWidth,
|
|
FMath::Min(Landing->DoorPoint.Y, Landing->StandingPoint.Y)
|
|
- CorridorHalfWidth,
|
|
Landing->FloorZ + CorridorBottom),
|
|
FVector(
|
|
FMath::Max(Landing->DoorPoint.X, Landing->StandingPoint.X)
|
|
+ CorridorHalfWidth,
|
|
FMath::Max(Landing->DoorPoint.Y, Landing->StandingPoint.Y)
|
|
+ CorridorHalfWidth,
|
|
Landing->FloorZ + CorridorTop));
|
|
}
|
|
}
|
|
|
|
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; %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."),
|
|
NumAimedAtUpperPlayerFit,
|
|
TotalPassages,
|
|
NumAimedAtLowerPlayerFit,
|
|
TotalPassages,
|
|
(TotalPassages * 2) - NumAimedAtUpperPlayerFit - NumAimedAtLowerPlayerFit,
|
|
TotalPassages * 2,
|
|
*NoQueryList);
|
|
|
|
//=========================================================================
|
|
// SURFACE ENTRY — the one optional above-ground opening.
|
|
// It opens the top seal and stops at the ceiling of strate 0's origin landing room. It does
|
|
// not continue down the room or into lower strates: every lower seal remains closed until the
|
|
// corresponding progression passage is opened.
|
|
//=========================================================================
|
|
if (bOpenSurfaceEntry && OriginSpineRadius > 0.0f && StrateLayout.Num() > 0)
|
|
{
|
|
const FStrateSlot& Top = StrateLayout[0];
|
|
const float TopZ = (float)(Top.TopChunkZ + 1) * CHUNK_SIZE;
|
|
const float BottomZ = (float)Top.BottomChunkZ * CHUNK_SIZE;
|
|
const float TopSeal = Top.Definition
|
|
? BoundarySealThicknessFor(*Top.Definition) : 0.0f;
|
|
const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding =
|
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
|
TopZ, BottomZ, TopSeal, OriginSpineRadius);
|
|
if (!OriginLanding.bValid)
|
|
{
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[StrateManager] Surface entry skipped: top origin landing has no seal-safe room interval."));
|
|
}
|
|
else
|
|
{
|
|
FVoxelPassage Entry;
|
|
Entry.UpperStrateIndex = 0;
|
|
Entry.LowerStrateIndex = 0;
|
|
// Keep the legacy type: this is the explicitly opted-in above-ground vertical opening,
|
|
// not the default inter-strate descent style.
|
|
Entry.PassageType = EVoxelPassageType::VerticalShaft;
|
|
// Match the reserved future shaft envelope exactly. The entry is only opened through
|
|
// the top seal into the finite landing room; lower strate seals remain untouched.
|
|
Entry.Radius = FMath::Max(OriginSpineRadius,
|
|
VoxelPassageGeometry::MetersToVoxels(0.25f));
|
|
Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE);
|
|
// The capsule's lower tangent meets the room ceiling; it never bores through the
|
|
// room's floor or creates an origin column in strate 0.
|
|
Entry.LowerPoint = FVector(
|
|
0.0f, 0.0f, OriginLanding.CeilingZ + Entry.Radius);
|
|
Entry.ControlPoints.Reset(2);
|
|
Entry.ControlPoints.Add(Entry.UpperPoint);
|
|
Entry.ControlPoints.Add(Entry.LowerPoint);
|
|
Entry.ControlRadii.Reset(2);
|
|
Entry.ControlRadii.Add(Entry.Radius);
|
|
Entry.ControlRadii.Add(Entry.Radius);
|
|
Entry.TunnelClearHeightVoxels = 2.0f * Entry.Radius;
|
|
Entry.TunnelMinimumClearWidthVoxels = 2.0f * Entry.Radius;
|
|
{
|
|
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 opening at (0,0) topZ=%.0f roomFloor=%.1f roomCeiling=%.1f R=%.1f"),
|
|
TopZ, OriginLanding.FloorZ, OriginLanding.CeilingZ, Entry.Radius);
|
|
}
|
|
}
|
|
|
|
VoxelForgeStartupTrace::RecordEvent(TEXT("passages_ready"), FString::Printf(
|
|
TEXT("\"passages\":%d,\"passages_version\":%u,\"upper_source_fit\":%d,\"lower_source_fit\":%d"),
|
|
Passages.Num(), PassagesVersion, NumAimedAtUpperPlayerFit, NumAimedAtLowerPlayerFit));
|
|
}
|
|
|
|
//=============================================================================
|
|
// MODIFIER SDF (inter-strate passages)
|
|
//=============================================================================
|
|
|
|
float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const
|
|
{
|
|
VoxelGenLOD::FScopedReachCost ReachCost(
|
|
VoxelGenLOD::ETileReachCostKind::PassageCarving);
|
|
if (VoxelDensityAblation::IsPassageCarvingOff())
|
|
{
|
|
return FLT_MAX;
|
|
}
|
|
// GenerateMesh installs this once per tile/block. Outside the mesher the TLS defaults to
|
|
// the canonical all-reachable state, and debug canonical calls set the bypass bit explicitly.
|
|
// A miss is an identity for VF_ApplyPassageCarving, so do not even enter the spatial shortlist
|
|
// or capsule chain on a proven-far sample.
|
|
if (!VoxelGenLOD::IsTilePostReachBypassActive()
|
|
&& !VoxelGenLOD::IsPassageCarvingReachable())
|
|
{
|
|
return FLT_MAX;
|
|
}
|
|
|
|
// The landing terms are part of the passage modifier, so their reach proof is deliberately
|
|
// wider than the direct post's four-voxel blend: SmoothMin can lower a finite term by K/6 at
|
|
// each later combine. Outside that padded room domain, omitting the two landing evaluations
|
|
// is an identity for VF_ApplyPassageCarving.
|
|
const bool bEvaluateLandingSDF = VoxelGenLOD::IsPassageLandingReachable()
|
|
|| VoxelGenLOD::IsTilePostReachBypassActive();
|
|
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 (VoxelDensityProfile::AreCountersEnabled())
|
|
{
|
|
VoxelDensityProfile::AddCounter(
|
|
VoxelDensityProfile::ECounter::PassageCandidates,
|
|
static_cast<uint64>(Nearby.Num()));
|
|
}
|
|
|
|
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
|
|
const FVoxelPassage* PassageData = Passages.GetData();
|
|
|
|
//=========================================================================
|
|
// 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 = PassageData[PIdx];
|
|
// BOUNDING-SPHERE REJECT: skip passages this voxel can't possibly be inside.
|
|
// EvaluateModifierSDF runs PER VOXEL; evaluating every passage's full capsule chain
|
|
// unconditionally is the dominant lag source with 12-segment worm passages. Far
|
|
// passages cost a single squared-distance compare.
|
|
if (FVector::DistSquared(Pos, P.BoundCenter) > P.BoundRadiusSq) continue;
|
|
if (VoxelDensityProfile::AreCountersEnabled())
|
|
{
|
|
VoxelDensityProfile::AddCounter(
|
|
VoxelDensityProfile::ECounter::PassageEvaluated);
|
|
}
|
|
|
|
const int32 ControlPointCount = P.ControlPoints.Num();
|
|
if (ControlPointCount >= 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());
|
|
const FVector* ControlPointData = P.ControlPoints.GetData();
|
|
const float* ControlRadiusData = P.ControlRadii.GetData();
|
|
float PassageSDF = FLT_MAX;
|
|
for (int32 j = 0; j < ControlPointCount - 1; j++)
|
|
{
|
|
const float rA = bTaper ? ControlRadiusData[j] : P.Radius;
|
|
const float rB = bTaper ? ControlRadiusData[j + 1] : P.Radius;
|
|
float SegmentSDF = VoxelSDF::TaperedCapsule(
|
|
Pos, ControlPointData[j], ControlPointData[j + 1], rA, rB);
|
|
|
|
// Clip each capsule against its own floor profile before the route segments are
|
|
// unioned. A single nearest-floor projection after the union can select the
|
|
// neighboring leg of a switchback and leave a solid post in this segment.
|
|
if (P.bNativeFloorEnabled && !VoxelDensityAblation::IsNativeFloorOff())
|
|
{
|
|
float NativeFloorZ = 0.0f;
|
|
float NativeSupportRadius = 0.0f;
|
|
float FloorDistanceSquared = FLT_MAX;
|
|
float FloorHorizontalDistanceSquared = FLT_MAX;
|
|
if (VF_ProjectNativePassageFloorSegment(
|
|
P, j, Pos, NativeFloorZ, NativeSupportRadius,
|
|
FloorDistanceSquared, FloorHorizontalDistanceSquared,
|
|
this, PIdx))
|
|
{
|
|
SegmentSDF = VoxelSDF::SmoothMax(
|
|
SegmentSDF,
|
|
NativeFloorZ - static_cast<float>(Pos.Z),
|
|
BlendK * 0.35f);
|
|
}
|
|
}
|
|
|
|
PassageSDF = VoxelSDF::SmoothMin(PassageSDF, SegmentSDF, 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. It is the same fixed SDF geometry selected during GeneratePassages; it
|
|
// never performs a source query here and does not synthesize a radial root connector.
|
|
if (bEvaluateLandingSDF)
|
|
{
|
|
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
|
|
{
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageModifier);
|
|
const bool bBypass = VoxelGenLOD::IsTilePostReachBypassActive();
|
|
const bool bCompareLandingModifier = VoxelGenLOD::bTilePostReachDebug
|
|
&& !VoxelGenLOD::IsPassageLandingReachable() && !bBypass;
|
|
float CanonicalModifierDensity = Density;
|
|
if (bCompareLandingModifier)
|
|
{
|
|
const bool PreviousBypass = VoxelGenLOD::bTilePostReachBypass;
|
|
VoxelGenLOD::bTilePostReachBypass = true;
|
|
const float CanonicalSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ);
|
|
VF_ApplyPassageCarving(
|
|
CanonicalModifierDensity, CanonicalSDF, BaseDensity, SealThickness);
|
|
VoxelGenLOD::bTilePostReachBypass = PreviousBypass;
|
|
}
|
|
if (!VoxelDensityAblation::IsPassageCarvingOff())
|
|
{
|
|
const float ModSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ);
|
|
VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness);
|
|
}
|
|
if (bCompareLandingModifier)
|
|
{
|
|
VoxelGenLOD::RecordSkippedPostComparison(
|
|
VoxelGenLOD::ETilePostComparisonKind::PassageLandingSDF,
|
|
!VoxelGenLOD::SameFloatBits(Density, CanonicalModifierDensity));
|
|
}
|
|
ApplyPassageLandingAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness);
|
|
|
|
const bool bLandingReachable = VoxelGenLOD::IsPassageLandingReachable()
|
|
|| VoxelGenLOD::IsTilePostReachBypassActive();
|
|
const bool bStructuralReachable = VoxelGenLOD::IsPassageStructuralReachable()
|
|
|| VoxelGenLOD::IsTilePostReachBypassActive();
|
|
if (!bLandingReachable && !bStructuralReachable)
|
|
{
|
|
return;
|
|
}
|
|
|
|
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 FVoxelPassage* PassageData = Passages.GetData();
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
const bool bLandingFloor = bLandingReachable
|
|
&& !VoxelDensityAblation::IsLandingPostsOff()
|
|
&& (VF_IsPassageLandingFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageLandingFloor(Position, Passage.LowerLanding));
|
|
const bool bLegacySupportFloor = bStructuralReachable
|
|
&& !VoxelDensityAblation::IsPassageStructuralPostsOff()
|
|
&& bPassageSupportFloorWritesEnabled
|
|
&& !Passage.bNativeFloorEnabled
|
|
&& VF_IsWalkableTunnelFloor(Passage, Position, this, PassageIndex);
|
|
if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()
|
|
&& (bLandingFloor || bLegacySupportFloor))
|
|
{
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// A neighbouring passage may own a floor at this XY while this point is in the air core of
|
|
// the current walkable tunnel. Reassert tunnel air after all floor posts so overlap cannot
|
|
// turn a valid route into a solid plug. At the tunnel's own floor the air predicate is false,
|
|
// so the support plane remains solid.
|
|
if (bStructuralReachable)
|
|
{
|
|
ApplyPassageTunnelAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness);
|
|
}
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageCarvingOnly(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
if (VoxelDensityAblation::IsPassageCarvingOff())
|
|
{
|
|
return;
|
|
}
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageModifier);
|
|
const bool bBypass = VoxelGenLOD::IsTilePostReachBypassActive();
|
|
const bool bCompareLandingModifier = VoxelGenLOD::bTilePostReachDebug
|
|
&& !VoxelGenLOD::IsPassageLandingReachable() && !bBypass;
|
|
float CanonicalModifierDensity = Density;
|
|
if (bCompareLandingModifier)
|
|
{
|
|
const bool PreviousBypass = VoxelGenLOD::bTilePostReachBypass;
|
|
VoxelGenLOD::bTilePostReachBypass = true;
|
|
const float CanonicalSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ);
|
|
VF_ApplyPassageCarving(
|
|
CanonicalModifierDensity, CanonicalSDF, BaseDensity, SealThickness);
|
|
VoxelGenLOD::bTilePostReachBypass = PreviousBypass;
|
|
}
|
|
const float ModSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ);
|
|
VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness);
|
|
if (bCompareLandingModifier)
|
|
{
|
|
VoxelGenLOD::RecordSkippedPostComparison(
|
|
VoxelGenLOD::ETilePostComparisonKind::PassageLandingSDF,
|
|
!VoxelGenLOD::SameFloatBits(Density, CanonicalModifierDensity));
|
|
}
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageLandingAir(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
if (VoxelDensityAblation::IsLandingPostsOff())
|
|
{
|
|
return;
|
|
}
|
|
if (!VoxelGenLOD::IsPassageLandingReachable()
|
|
&& !VoxelGenLOD::IsTilePostReachBypassActive())
|
|
{
|
|
VoxelGenLOD::CompareSkippedPost(Density, [&]()
|
|
{
|
|
ApplyPassageLandingAir(
|
|
Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness);
|
|
}, VoxelGenLOD::ETilePostComparisonKind::PassageLandingAir);
|
|
return;
|
|
}
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageLandingAir);
|
|
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 FVoxelPassage* PassageData = Passages.GetData();
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
// A smooth room blend may overlap the end of a ramp. The analytic support plane owns that
|
|
// overlap; otherwise landing air can erase the tunnel's final-density floor after the tunnel
|
|
// air post has deliberately stopped above it.
|
|
if (VF_IsAnyPassageFloorAt(this, Position))
|
|
{
|
|
return;
|
|
}
|
|
float MinLandingSDF = FLT_MAX;
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = PassageData[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::ApplyPassageTunnelAir(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
if (VoxelDensityAblation::IsPassageStructuralPostsOff())
|
|
{
|
|
return;
|
|
}
|
|
if (!VoxelGenLOD::IsPassageStructuralReachable()
|
|
&& !VoxelGenLOD::IsTilePostReachBypassActive())
|
|
{
|
|
VoxelGenLOD::CompareSkippedPost(Density, [&]()
|
|
{
|
|
ApplyPassageTunnelAir(
|
|
Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness);
|
|
}, VoxelGenLOD::ETilePostComparisonKind::PassageTunnelAir);
|
|
return;
|
|
}
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageTunnelAir);
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
int32 PassageIndex = INDEX_NONE;
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
const bool bFoundWalkableAir = VF_FindWalkableTunnelAir(
|
|
this, Position, PassageIndex, FloorZ, SupportRadius);
|
|
if (bFoundWalkableAir)
|
|
{
|
|
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
|
|
Density = FMath::Min(Density, AirTarget);
|
|
}
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageLandingFloorMC(
|
|
float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const
|
|
{
|
|
if (VoxelDensityAblation::IsLandingPostsOff()
|
|
&& VoxelDensityAblation::IsPassageStructuralPostsOff())
|
|
{
|
|
return;
|
|
}
|
|
const bool bBypass = VoxelGenLOD::IsTilePostReachBypassActive();
|
|
const bool bLandingReachable = VoxelGenLOD::IsPassageLandingReachable() || bBypass;
|
|
const bool bStructuralReachable = VoxelGenLOD::IsPassageStructuralReachable() || bBypass;
|
|
const bool bCompareLanding = VoxelGenLOD::bTilePostReachDebug
|
|
&& !bLandingReachable && !bBypass;
|
|
float CanonicalDensity = Density;
|
|
if (bCompareLanding)
|
|
{
|
|
const bool PreviousBypass = VoxelGenLOD::bTilePostReachBypass;
|
|
VoxelGenLOD::bTilePostReachBypass = true;
|
|
ApplyPassageLandingFloorMC(
|
|
CanonicalDensity, WorldX, WorldY, WorldZ, BaseDensity);
|
|
VoxelGenLOD::bTilePostReachBypass = PreviousBypass;
|
|
}
|
|
if (!bLandingReachable && !bStructuralReachable)
|
|
{
|
|
if (bCompareLanding)
|
|
{
|
|
VoxelGenLOD::RecordSkippedPostComparison(
|
|
VoxelGenLOD::ETilePostComparisonKind::PassageLandingFloor,
|
|
!VoxelGenLOD::SameFloatBits(Density, CanonicalDensity));
|
|
}
|
|
return;
|
|
}
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageLandingFloor);
|
|
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 FVoxelPassage* PassageData = Passages.GetData();
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
const bool bLandingFloor = bLandingReachable
|
|
&& !VoxelDensityAblation::IsLandingPostsOff()
|
|
&& (VF_IsPassageLandingFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageLandingFloor(Position, Passage.LowerLanding));
|
|
const bool bLegacySupportFloor = bStructuralReachable
|
|
&& !VoxelDensityAblation::IsPassageStructuralPostsOff()
|
|
&& bPassageSupportFloorWritesEnabled
|
|
&& !Passage.bNativeFloorEnabled
|
|
&& VF_IsWalkableTunnelFloor(Passage, Position, this, PassageIndex);
|
|
if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()
|
|
&& (bLandingFloor || bLegacySupportFloor))
|
|
{
|
|
// 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;
|
|
}
|
|
}
|
|
if (bCompareLanding)
|
|
{
|
|
VoxelGenLOD::RecordSkippedPostComparison(
|
|
VoxelGenLOD::ETilePostComparisonKind::PassageLandingFloor,
|
|
!VoxelGenLOD::SameFloatBits(Density, CanonicalDensity));
|
|
}
|
|
}
|
|
|
|
static bool VF_IsPassageRoomFloor(
|
|
const FVector& Position, const FVoxelPassageLanding& Landing)
|
|
{
|
|
if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y)
|
|
|| !VoxelMath::IsFinite(Position.Z) || !VoxelMath::IsFinite(Landing.FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing.FloorThickness)
|
|
|| Landing.FloorThickness <= 0.0f || Landing.HalfWidth <= 0.0f)
|
|
{
|
|
return false;
|
|
}
|
|
return Position.Z <= Landing.FloorZ + KINDA_SMALL_NUMBER
|
|
&& Position.Z > Landing.FloorZ - Landing.FloorThickness
|
|
&& FMath::Abs(Position.X - Landing.StandingPoint.X)
|
|
<= FMath::Max(Landing.HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f)
|
|
&& FMath::Abs(Position.Y - Landing.StandingPoint.Y)
|
|
<= FMath::Max(Landing.HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f);
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageStructuralPostsMC(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
const bool bDisableStructuralPosts = VoxelDensityAblation::IsPassageStructuralPostsOff();
|
|
const bool bDisableLandingPosts = VoxelDensityAblation::IsLandingPostsOff();
|
|
if (bDisableStructuralPosts && bDisableLandingPosts)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const bool bBypass = VoxelGenLOD::IsTilePostReachBypassActive();
|
|
const bool bLandingReachable = VoxelGenLOD::IsPassageLandingReachable() || bBypass;
|
|
const bool bStructuralReachable = VoxelGenLOD::IsPassageStructuralReachable() || bBypass;
|
|
const bool bCompareLanding = VoxelGenLOD::bTilePostReachDebug
|
|
&& !bLandingReachable && !bBypass;
|
|
float CanonicalDensity = Density;
|
|
if (bCompareLanding)
|
|
{
|
|
const bool PreviousBypass = VoxelGenLOD::bTilePostReachBypass;
|
|
VoxelGenLOD::bTilePostReachBypass = true;
|
|
ApplyPassageStructuralPostsMC(
|
|
CanonicalDensity, WorldX, WorldY, WorldZ,
|
|
BaseDensity, SealThickness);
|
|
VoxelGenLOD::bTilePostReachBypass = PreviousBypass;
|
|
}
|
|
if (!bLandingReachable && !bStructuralReachable)
|
|
{
|
|
if (bCompareLanding)
|
|
{
|
|
VoxelGenLOD::RecordSkippedPostComparison(
|
|
VoxelGenLOD::ETilePostComparisonKind::PassageStructuralPosts,
|
|
!VoxelGenLOD::SameFloatBits(Density, CanonicalDensity));
|
|
}
|
|
return;
|
|
}
|
|
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageStructuralPosts);
|
|
|
|
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 FVoxelPassage* PassageData = Passages.GetData();
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
const bool bSuppressFloor =
|
|
VoxelPassageGeometry::VerticalShaftConnectorAirMarker();
|
|
bool bAnyLandingFloor = false;
|
|
bool bLegacyTunnelSupportFloor = false;
|
|
bool bAnyRoomFloor = false;
|
|
bool bWalkableAir = false;
|
|
float MinLandingSDF = FLT_MAX;
|
|
|
|
// The four old MC backstops all walked the same immutable per-chunk shortlist. Gather their
|
|
// predicates in one pass; the writes below retain the old order (landing air, landing floor,
|
|
// tunnel air, room floor), including the vertical-shaft floor suppression marker.
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
if (!Passages.IsValidIndex(PassageIndex)) continue;
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
|
|
const bool bTunnelSupportFloor =
|
|
bStructuralReachable && !bDisableStructuralPosts
|
|
&& bPassageSupportFloorWritesEnabled
|
|
&& !Passage.bNativeFloorEnabled
|
|
&& VF_IsWalkableTunnelFloor(Passage, Position, this, PassageIndex);
|
|
if (!bSuppressFloor && bLandingReachable && !bDisableLandingPosts
|
|
&& (VF_IsPassageLandingFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageLandingFloor(Position, Passage.LowerLanding)))
|
|
{
|
|
bAnyLandingFloor = true;
|
|
}
|
|
if (!bSuppressFloor && bStructuralReachable
|
|
&& !bDisableStructuralPosts && bTunnelSupportFloor)
|
|
{
|
|
bLegacyTunnelSupportFloor = true;
|
|
}
|
|
|
|
if (!bSuppressFloor && bLandingReachable && !bDisableLandingPosts
|
|
&& (VF_IsPassageRoomFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageRoomFloor(Position, Passage.LowerLanding)))
|
|
{
|
|
bAnyRoomFloor = true;
|
|
}
|
|
|
|
if (bStructuralReachable && !bDisableStructuralPosts && !bWalkableAir
|
|
&& FVector::DistSquared(Position, Passage.BoundCenter) <= Passage.BoundRadiusSq
|
|
&& VF_IsWalkableTunnelAir(Passage, Position, this, PassageIndex))
|
|
{
|
|
bWalkableAir = true;
|
|
}
|
|
|
|
if (bLandingReachable && !bDisableLandingPosts
|
|
&& FVector::DistSquared(Position, Passage.BoundCenter) <= Passage.BoundRadiusSq)
|
|
{
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
MinLandingSDF = FMath::Min(
|
|
MinLandingSDF,
|
|
VF_EvaluatePassageLandingSDF(Position, *Landing));
|
|
}
|
|
}
|
|
}
|
|
|
|
// The graph tunnel does not expose a separate support slab. Inter-strate landing carving
|
|
// is independent and is composed here when its own reach proof allows it.
|
|
if (!bDisableLandingPosts
|
|
&& !bAnyLandingFloor && !bLegacyTunnelSupportFloor && MinLandingSDF < FLT_MAX)
|
|
{
|
|
float InternalDensity = -Density;
|
|
VF_ApplyPassageLandingCarving(
|
|
InternalDensity, MinLandingSDF, BaseDensity, SealThickness);
|
|
Density = -InternalDensity;
|
|
}
|
|
|
|
if (bAnyLandingFloor || bLegacyTunnelSupportFloor)
|
|
{
|
|
Density = FMath::Min(Density, -BaseDensity);
|
|
}
|
|
|
|
if (bWalkableAir)
|
|
{
|
|
Density = FMath::Max(
|
|
Density, BaseDensity * 2.0f + SealThickness + 4.0f);
|
|
}
|
|
|
|
if (bAnyRoomFloor)
|
|
{
|
|
Density = FMath::Min(Density, -BaseDensity);
|
|
}
|
|
|
|
if (bLegacyTunnelSupportFloor && VoxelDensityProfile::AreCountersEnabled())
|
|
{
|
|
VoxelDensityProfile::AddCounter(
|
|
VoxelDensityProfile::ECounter::PassageSupportFloorBackstopFires);
|
|
}
|
|
if (bCompareLanding)
|
|
{
|
|
VoxelGenLOD::RecordSkippedPostComparison(
|
|
VoxelGenLOD::ETilePostComparisonKind::PassageStructuralPosts,
|
|
!VoxelGenLOD::SameFloatBits(Density, CanonicalDensity));
|
|
}
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageNativeFloorMC(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity) const
|
|
{
|
|
if (VoxelDensityAblation::IsNativeFloorOff())
|
|
{
|
|
return;
|
|
}
|
|
if (!VoxelGenLOD::IsPassageStructuralReachable()
|
|
&& !VoxelGenLOD::IsTilePostReachBypassActive())
|
|
{
|
|
VoxelGenLOD::CompareSkippedPost(Density, [&]()
|
|
{
|
|
ApplyPassageNativeFloorMC(Density, WorldX, WorldY, WorldZ, BaseDensity);
|
|
}, VoxelGenLOD::ETilePostComparisonKind::PassageNativeFloor);
|
|
return;
|
|
}
|
|
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
|
|
|| VoxelPassageGeometry::VerticalShaftConnectorAirMarker())
|
|
{
|
|
return;
|
|
}
|
|
|
|
const FVoxelPassage* PassageData = Passages.GetData();
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
bool bRoomOwnershipChecked = false;
|
|
bool bInsidePassageRoom = false;
|
|
const auto IsInsidePassageRoom = [&]()
|
|
{
|
|
if (!bRoomOwnershipChecked)
|
|
{
|
|
bRoomOwnershipChecked = true;
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
if (!Passages.IsValidIndex(PassageIndex))
|
|
{
|
|
continue;
|
|
}
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
if (VF_EvaluatePassageLandingSDF(Position, Passage.UpperLanding) <= 0.0f
|
|
|| VF_EvaluatePassageLandingSDF(Position, Passage.LowerLanding) <= 0.0f)
|
|
{
|
|
bInsidePassageRoom = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return bInsidePassageRoom;
|
|
};
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
if (!Passages.IsValidIndex(PassageIndex))
|
|
{
|
|
continue;
|
|
}
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
if (!Passage.bNativeFloorEnabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
if (VF_ProjectNativePassageFloor(
|
|
Passage, Position, FloorZ, SupportRadius, this, PassageIndex)
|
|
&& Position.Z <= FloorZ + KINDA_SMALL_NUMBER
|
|
&& Position.Z > FloorZ
|
|
- VoxelPassageGeometry::LandingFloorThicknessVoxels)
|
|
{
|
|
// Room geometry is strate A and owns every overlap. A tunnel's native post must not
|
|
// put a floor patch through a landing room; its own room-floor writer owns support.
|
|
if (!IsInsidePassageRoom())
|
|
{
|
|
// Keep the existing narrow floor surface on the segment selected by the passage's
|
|
// full 3D centerline, so a switchback's XY-near neighbor cannot add a second post.
|
|
Density = FMath::Min(Density, -FMath::Max(BaseDensity, 1.0f));
|
|
if (VoxelDensityProfile::AreCountersEnabled())
|
|
{
|
|
VoxelDensityProfile::AddCounter(
|
|
VoxelDensityProfile::ECounter::PassageNativeFloorCompositions);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Eval, terrain operations, and disturbance writers have all run before this MC-facing
|
|
// composition point. Reassert the walkable passage's swept body volume here so their solid
|
|
// features cannot put a rib or a neighboring floor post back through the through-route.
|
|
// The profile is evaluated per route segment: switchback legs keep their own floor branch.
|
|
// Room interiors stay owned by the room SDF, and the floor band remains untouched below the
|
|
// maximum legal step. This is an air carve over the tunnel's own shape, not a support slab.
|
|
const float CorridorHalfWidth =
|
|
0.5f * VoxelPassageGeometry::WalkCorridorPlayerWidthVoxels
|
|
+ VoxelPassageGeometry::WalkCorridorLatticeMarginVoxels;
|
|
const float CorridorBottom = VoxelPassageGeometry::MaxStepHeightVoxels;
|
|
const float CorridorTop = CorridorBottom
|
|
+ VoxelPassageGeometry::WalkCorridorPlayerHeightVoxels;
|
|
bool bInsideWalkCorridor = false;
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
if (!Passages.IsValidIndex(PassageIndex))
|
|
{
|
|
continue;
|
|
}
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
if (!Passage.bWalkableTunnelContract || !Passage.bNativeFloorEnabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const int32 NumSegments = Passage.ControlPoints.Num() - 1;
|
|
for (int32 SegmentIndex = 0; SegmentIndex < NumSegments; ++SegmentIndex)
|
|
{
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
float DistanceSquared = FLT_MAX;
|
|
float HorizontalDistanceSquared = FLT_MAX;
|
|
if (!VF_ProjectNativePassageFloorSegment(
|
|
Passage, SegmentIndex, Position,
|
|
FloorZ, SupportRadius, DistanceSquared,
|
|
HorizontalDistanceSquared, this, PassageIndex)
|
|
|| HorizontalDistanceSquared > FMath::Square(CorridorHalfWidth))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const float HeightAboveFloor = Position.Z - FloorZ;
|
|
if (HeightAboveFloor >= CorridorBottom
|
|
&& HeightAboveFloor <= CorridorTop)
|
|
{
|
|
bInsideWalkCorridor = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The audited A-to-B path continues from each mouth to the landing's standing point.
|
|
// Cover those short approach lines too, while letting the actual landing room retain A
|
|
// ownership wherever its room SDF contains the query.
|
|
const auto IsInsideLandingApproach = [&](const FVoxelPassageLanding& Landing)
|
|
{
|
|
const FVector2D Start(Landing.DoorPoint.X, Landing.DoorPoint.Y);
|
|
const FVector2D End(Landing.StandingPoint.X, Landing.StandingPoint.Y);
|
|
const FVector2D Delta = End - Start;
|
|
const float LengthSquared = Delta.SizeSquared();
|
|
if (!(LengthSquared > KINDA_SMALL_NUMBER))
|
|
{
|
|
return false;
|
|
}
|
|
const FVector2D Query(Position.X, Position.Y);
|
|
const float T = FMath::Clamp(
|
|
FVector2D::DotProduct(Query - Start, Delta) / LengthSquared,
|
|
0.0f, 1.0f);
|
|
const FVector2D Closest = Start + Delta * T;
|
|
if (FVector2D::DistSquared(Query, Closest)
|
|
> FMath::Square(CorridorHalfWidth))
|
|
{
|
|
return false;
|
|
}
|
|
const float HeightAboveFloor = Position.Z - Landing.FloorZ;
|
|
return HeightAboveFloor >= CorridorBottom
|
|
&& HeightAboveFloor <= CorridorTop;
|
|
};
|
|
if (!bInsideWalkCorridor
|
|
&& (IsInsideLandingApproach(Passage.UpperLanding)
|
|
|| IsInsideLandingApproach(Passage.LowerLanding)))
|
|
{
|
|
bInsideWalkCorridor = true;
|
|
}
|
|
if (bInsideWalkCorridor)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
if (bInsideWalkCorridor)
|
|
{
|
|
if (!IsInsidePassageRoom())
|
|
{
|
|
Density = FMath::Max(Density, FMath::Max(BaseDensity, 1.0f));
|
|
}
|
|
}
|
|
}
|
|
|
|
void UVoxelStrateManager::ApplyPassageLandingRoomFloorMC(
|
|
float& Density, float WorldX, float WorldY, float WorldZ,
|
|
float BaseDensity) const
|
|
{
|
|
if (VoxelDensityAblation::IsLandingPostsOff())
|
|
{
|
|
return;
|
|
}
|
|
if (!VoxelGenLOD::IsPassageLandingReachable()
|
|
&& !VoxelGenLOD::IsTilePostReachBypassActive())
|
|
{
|
|
VoxelGenLOD::CompareSkippedPost(Density, [&]()
|
|
{
|
|
ApplyPassageLandingRoomFloorMC(
|
|
Density, WorldX, WorldY, WorldZ, BaseDensity);
|
|
}, VoxelGenLOD::ETilePostComparisonKind::PassageLandingRoomFloor);
|
|
return;
|
|
}
|
|
VoxelDensityProfile::FScopedTimer ProfileTimer(
|
|
VoxelDensityProfile::EBucket::PassageLandingRoomFloor);
|
|
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 FVoxelPassage* PassageData = Passages.GetData();
|
|
const FVector Position(WorldX, WorldY, WorldZ);
|
|
for (const int32 PassageIndex : Nearby)
|
|
{
|
|
const FVoxelPassage& Passage = PassageData[PassageIndex];
|
|
if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()
|
|
&& (VF_IsPassageRoomFloor(Position, Passage.UpperLanding)
|
|
|| VF_IsPassageRoomFloor(Position, Passage.LowerLanding)))
|
|
{
|
|
Density = FMath::Min(Density, -BaseDensity);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const
|
|
{
|
|
if (VoxelDensityAblation::IsPassageCarvingOff())
|
|
{
|
|
return false;
|
|
}
|
|
// 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)
|
|
{
|
|
float ReliefBound = 0.0f;
|
|
if (!VF_GetNativePassageFloorReliefBound(Passage, ReliefBound))
|
|
{
|
|
return true;
|
|
}
|
|
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 default tube also owns a three-voxel support band. Its conservative segment AABB
|
|
// keeps a classifier from proving AllAir over the floor and then dropping that support
|
|
// during meshing. False positives are intentional; false negatives would be a hole.
|
|
if (Passage.bWalkableTunnelContract)
|
|
{
|
|
if (Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return true;
|
|
}
|
|
constexpr float Pad = 1.0f;
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num();
|
|
++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y)
|
|
|| !VoxelMath::IsFinite(A.Z) || !VoxelMath::IsFinite(B.X)
|
|
|| !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z)
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex])
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1]))
|
|
{
|
|
return true;
|
|
}
|
|
const float RadiusA = FMath::Abs(Passage.ControlRadii[SegmentIndex]);
|
|
const float RadiusB = FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]);
|
|
const float SupportRadius =
|
|
VoxelPassageGeometry::NativePassageFloorSupportRadius(RadiusA, RadiusB);
|
|
const float FloorA = Passage.bNativeFloorEnabled
|
|
? Passage.NativeFloorProfileZ[SegmentIndex]
|
|
: VoxelPassageGeometry::TunnelFloorZ(A, RadiusA);
|
|
const float FloorB = Passage.bNativeFloorEnabled
|
|
? Passage.NativeFloorProfileZ[SegmentIndex + 1]
|
|
: VoxelPassageGeometry::TunnelFloorZ(B, RadiusB);
|
|
const float FloorMinZ = FMath::Min(FloorA, FloorB)
|
|
- VoxelPassageGeometry::LandingFloorThicknessVoxels
|
|
- Pad - ReliefBound;
|
|
const float FloorMaxZ = FMath::Max(FloorA, FloorB)
|
|
+ Pad + ReliefBound;
|
|
const float FloorMinX = FMath::Min(A.X, B.X) - SupportRadius - Pad;
|
|
const float FloorMaxX = FMath::Max(A.X, B.X) + SupportRadius + Pad;
|
|
const float FloorMinY = FMath::Min(A.Y, B.Y) - SupportRadius - Pad;
|
|
const float FloorMaxY = FMath::Max(A.Y, B.Y) + SupportRadius + Pad;
|
|
if (FloorMaxX >= MinVoxel.X && FloorMinX <= MaxVoxel.X
|
|
&& FloorMaxY >= MinVoxel.Y && FloorMinY <= MaxVoxel.Y
|
|
&& FloorMaxZ >= MinVoxel.Z && FloorMinZ <= MaxVoxel.Z)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
static bool VF_LatticeAxisRange(
|
|
float Min, float Max, float Origin, int32 Step,
|
|
int32& OutFirst, int32& OutLast)
|
|
{
|
|
if (Step <= 0 || !VoxelMath::IsFinite(Min) || !VoxelMath::IsFinite(Max)
|
|
|| !VoxelMath::IsFinite(Origin) || Min > Max)
|
|
{
|
|
return false;
|
|
}
|
|
const float InvStep = 1.0f / static_cast<float>(Step);
|
|
OutFirst = FMath::CeilToInt((Min - Origin) * InvStep - 1.0e-4f);
|
|
OutLast = FMath::FloorToInt((Max - Origin) * InvStep + 1.0e-4f);
|
|
return OutFirst <= OutLast;
|
|
}
|
|
|
|
struct FVoxelPassageModifierDomain
|
|
{
|
|
FVector Min = FVector(FLT_MAX, FLT_MAX, FLT_MAX);
|
|
FVector Max = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX);
|
|
};
|
|
|
|
static bool VF_LatticeBoxTouchesAABB(
|
|
const FBox& Box, const FIntVector& Origin, int32 Step,
|
|
float MinX, float MaxX, float MinY, float MaxY, float MinZ, float MaxZ);
|
|
|
|
static bool VF_BuildPassageModifierDomains(
|
|
const TArray<FVoxelPassage>& Passages,
|
|
TArray<FVoxelPassageModifierDomain, TInlineAllocator<128>>& Domains)
|
|
{
|
|
constexpr int32 MaxControlPointsForBound = 4096;
|
|
Domains.Reset();
|
|
|
|
auto AddDomain = [&](const FVector& Min, const FVector& Max) -> bool
|
|
{
|
|
if (!VoxelMath::IsFinite(Min.X) || !VoxelMath::IsFinite(Min.Y)
|
|
|| !VoxelMath::IsFinite(Min.Z) || !VoxelMath::IsFinite(Max.X)
|
|
|| !VoxelMath::IsFinite(Max.Y) || !VoxelMath::IsFinite(Max.Z)
|
|
|| Min.X > Max.X || Min.Y > Max.Y || Min.Z > Max.Z)
|
|
{
|
|
return false;
|
|
}
|
|
Domains.Add({Min, Max});
|
|
return true;
|
|
};
|
|
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
// Invalid generated geometry is an unknown, not an empty passage set.
|
|
return false;
|
|
}
|
|
|
|
if (Passage.ControlPoints.Num() >= 2)
|
|
{
|
|
if (Passage.ControlPoints.Num() > MaxControlPointsForBound)
|
|
{
|
|
return false;
|
|
}
|
|
const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
|
if (!bTaper && !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return false;
|
|
}
|
|
for (int32 PointIndex = 0;
|
|
PointIndex < Passage.ControlPoints.Num(); ++PointIndex)
|
|
{
|
|
const FVector& Point = Passage.ControlPoints[PointIndex];
|
|
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
|
|| !VoxelMath::IsFinite(Point.Z))
|
|
{
|
|
return false;
|
|
}
|
|
if (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex]))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
const float RadiusA = bTaper
|
|
? Passage.ControlRadii[SegmentIndex] : Passage.Radius;
|
|
const float RadiusB = bTaper
|
|
? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius;
|
|
const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB));
|
|
if (!VoxelMath::IsFinite(Radius))
|
|
{
|
|
return false;
|
|
}
|
|
if (!AddDomain(
|
|
FVector(FMath::Min(A.X, B.X) - Radius,
|
|
FMath::Min(A.Y, B.Y) - Radius,
|
|
FMath::Min(A.Z, B.Z) - Radius),
|
|
FVector(FMath::Max(A.X, B.X) + Radius,
|
|
FMath::Max(A.Y, B.Y) + Radius,
|
|
FMath::Max(A.Z, B.Z) + Radius)))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.UpperPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return false;
|
|
}
|
|
const float Radius = FMath::Abs(Passage.Radius);
|
|
if (!AddDomain(
|
|
FVector(FMath::Min(Passage.UpperPoint.X, Passage.LowerPoint.X) - Radius,
|
|
FMath::Min(Passage.UpperPoint.Y, Passage.LowerPoint.Y) - Radius,
|
|
FMath::Min(Passage.UpperPoint.Z, Passage.LowerPoint.Z) - Radius),
|
|
FVector(FMath::Max(Passage.UpperPoint.X, Passage.LowerPoint.X) + Radius,
|
|
FMath::Max(Passage.UpperPoint.Y, Passage.LowerPoint.Y) + Radius,
|
|
FMath::Max(Passage.UpperPoint.Z, Passage.LowerPoint.Z) + Radius)))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing->CeilingZ)
|
|
|| !VoxelMath::IsFinite(Landing->HalfWidth))
|
|
{
|
|
// The canonical evaluator returns FLT_MAX for this landing. Treating it as
|
|
// unknown is safer for future evaluator changes and only gives up a skip.
|
|
return false;
|
|
}
|
|
if (Landing->HalfWidth <= 0.0f
|
|
|| Landing->CeilingZ <= Landing->FloorZ)
|
|
{
|
|
continue;
|
|
}
|
|
constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels;
|
|
const float Height = Landing->CeilingZ - Landing->FloorZ;
|
|
const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f));
|
|
const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f));
|
|
const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f;
|
|
if (!AddDomain(
|
|
FVector(Landing->StandingPoint.X - HalfX - RoomRounding,
|
|
Landing->StandingPoint.Y - HalfX - RoomRounding,
|
|
FMath::Min(Landing->FloorZ,
|
|
CenterZ - HalfZ - RoomRounding)),
|
|
FVector(Landing->StandingPoint.X + HalfX + RoomRounding,
|
|
Landing->StandingPoint.Y + HalfX + RoomRounding,
|
|
FMath::Max(Landing->CeilingZ,
|
|
CenterZ + HalfZ + RoomRounding))))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool VF_AnyPassageModifierDomainTouchesLattice(
|
|
const TArray<FVoxelPassage>& Passages,
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step)
|
|
{
|
|
if (Step <= 0 || !VoxelBox.IsValid)
|
|
{
|
|
// An invalid query must never manufacture an identity proof.
|
|
return true;
|
|
}
|
|
|
|
// EvaluateModifierSDF smooth-mins every finite primitive in passage order. The polynomial
|
|
// smooth-min can dip below the smallest raw term by at most K/6 per combine (K=3 here).
|
|
// Build a conservative AABB for each actual tube/landing primitive, then pad the union by
|
|
// the worst finite dip. This is materially tighter than Passage.BoundRadius for a long,
|
|
// thin inter-strate route, while still being a pure reject gate: a miss proves only that
|
|
// the modifier cannot be < 4 at any MC lattice point. Malformed data remains Unknown.
|
|
constexpr float CarveThreshold = 4.0f;
|
|
constexpr float SmoothMinK = 3.0f;
|
|
TArray<FVoxelPassageModifierDomain, TInlineAllocator<128>> Domains;
|
|
if (!VF_BuildPassageModifierDomains(Passages, Domains))
|
|
{
|
|
return true;
|
|
}
|
|
if (Domains.Num() == 0)
|
|
{
|
|
return false;
|
|
}
|
|
const int64 DomainCount = Domains.Num();
|
|
const float SmoothDip = static_cast<float>(DomainCount - 1)
|
|
* (SmoothMinK / 6.0f);
|
|
const float Padding = CarveThreshold + SmoothDip;
|
|
if (!VoxelMath::IsFinite(Padding) || Padding < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
for (const FVoxelPassageModifierDomain& Domain : Domains)
|
|
{
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
Domain.Min.X - Padding, Domain.Max.X + Padding,
|
|
Domain.Min.Y - Padding, Domain.Max.Y + Padding,
|
|
Domain.Min.Z - Padding, Domain.Max.Z + Padding))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static FORCEINLINE float VF_DistanceToSegmentLowerBound(
|
|
const FVector& Position, const FVector& A, const FVector& B, float Radius)
|
|
{
|
|
const FVector AB = B - A;
|
|
const FVector AP = Position - A;
|
|
const float LenSq = FVector::DotProduct(AB, AB);
|
|
const float T = LenSq > KINDA_SMALL_NUMBER
|
|
? FMath::Clamp(FVector::DotProduct(AP, AB) / LenSq, 0.0f, 1.0f)
|
|
: 0.0f;
|
|
const FVector Closest = A + AB * T;
|
|
return FVector::Dist(Position, Closest) - Radius;
|
|
}
|
|
|
|
static bool VF_PointMayHavePassageCarve(
|
|
const TArray<FVoxelPassage>& Passages,
|
|
const FVector& Position)
|
|
{
|
|
if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y)
|
|
|| !VoxelMath::IsFinite(Position.Z))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
constexpr float BlendK = 3.0f;
|
|
constexpr float CarveThreshold = 4.0f;
|
|
constexpr float NumericalSafetyMargin = 1.0e-3f;
|
|
float ModifierLowerBound = FLT_MAX;
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float PassageLowerBound = FLT_MAX;
|
|
if (Passage.ControlPoints.Num() >= 2)
|
|
{
|
|
if (Passage.ControlPoints.Num() > 4096)
|
|
{
|
|
return true;
|
|
}
|
|
const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
|
if (!bTaper && !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return true;
|
|
}
|
|
for (int32 PointIndex = 0;
|
|
PointIndex < Passage.ControlPoints.Num(); ++PointIndex)
|
|
{
|
|
const FVector& Point = Passage.ControlPoints[PointIndex];
|
|
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
|
|| !VoxelMath::IsFinite(Point.Z))
|
|
{
|
|
return true;
|
|
}
|
|
if (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex]))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
const float RadiusA = bTaper
|
|
? Passage.ControlRadii[SegmentIndex] : Passage.Radius;
|
|
const float RadiusB = bTaper
|
|
? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius;
|
|
const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB));
|
|
const float SegmentLowerBound = VF_DistanceToSegmentLowerBound(
|
|
Position, A, B, Radius);
|
|
if (!VoxelMath::IsFinite(SegmentLowerBound))
|
|
{
|
|
return true;
|
|
}
|
|
PassageLowerBound = VoxelSDF::SmoothMin(
|
|
PassageLowerBound, SegmentLowerBound, BlendK);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.UpperPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return true;
|
|
}
|
|
PassageLowerBound = VF_DistanceToSegmentLowerBound(
|
|
Position, Passage.UpperPoint, Passage.LowerPoint,
|
|
FMath::Abs(Passage.Radius));
|
|
if (!VoxelMath::IsFinite(PassageLowerBound))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing->CeilingZ)
|
|
|| !VoxelMath::IsFinite(Landing->HalfWidth))
|
|
{
|
|
return true;
|
|
}
|
|
if (Landing->HalfWidth <= 0.0f
|
|
|| Landing->CeilingZ <= Landing->FloorZ)
|
|
{
|
|
continue;
|
|
}
|
|
const float LandingSDF = VF_EvaluatePassageLandingSDF(Position, *Landing);
|
|
if (!VoxelMath::IsFinite(LandingSDF))
|
|
{
|
|
return true;
|
|
}
|
|
PassageLowerBound = VoxelSDF::SmoothMin(
|
|
PassageLowerBound, LandingSDF, BlendK);
|
|
}
|
|
|
|
ModifierLowerBound = VoxelSDF::SmoothMin(
|
|
ModifierLowerBound, PassageLowerBound, BlendK);
|
|
}
|
|
return !VoxelMath::IsFinite(ModifierLowerBound)
|
|
|| ModifierLowerBound < CarveThreshold + NumericalSafetyMargin;
|
|
}
|
|
|
|
static float VF_DistanceBetweenAABBs(
|
|
const FVector& MinA, const FVector& MaxA,
|
|
const FVector& MinB, const FVector& MaxB)
|
|
{
|
|
const float DX = MaxA.X < MinB.X ? MinB.X - MaxA.X
|
|
: MaxB.X < MinA.X ? MinA.X - MaxB.X : 0.0f;
|
|
const float DY = MaxA.Y < MinB.Y ? MinB.Y - MaxA.Y
|
|
: MaxB.Y < MinA.Y ? MinA.Y - MaxB.Y : 0.0f;
|
|
const float DZ = MaxA.Z < MinB.Z ? MinB.Z - MaxA.Z
|
|
: MaxB.Z < MinA.Z ? MinA.Z - MaxB.Z : 0.0f;
|
|
return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ);
|
|
}
|
|
|
|
// Exact minimum distance between a finite segment and an axis-aligned box. The squared
|
|
// distance to a box is piecewise quadratic along the segment; the only breakpoints are where
|
|
// one segment coordinate crosses one of the six box planes. This is a small, allocation-free
|
|
// lower-bound primitive for the passage proof, not a change to the runtime SDF evaluator.
|
|
static bool VF_SegmentDistanceToBox(
|
|
const FVector& A, const FVector& B, const FBox& Box, float& OutDistance)
|
|
{
|
|
if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y) || !VoxelMath::IsFinite(A.Z)
|
|
|| !VoxelMath::IsFinite(B.X) || !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z)
|
|
|| !Box.IsValid)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVector D = B - A;
|
|
float Breaks[8] = { 0.0f, 1.0f };
|
|
int32 BreakCount = 2;
|
|
const float AValues[3] = { A.X, A.Y, A.Z };
|
|
const float DValues[3] = { D.X, D.Y, D.Z };
|
|
const float BoxMinValues[3] = {
|
|
(float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z };
|
|
const float BoxMaxValues[3] = {
|
|
(float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z };
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
// This is a lower-bound proof. A nonzero direction, however small, can cross a
|
|
// box plane and reduce the segment distance; treating it as stationary would make
|
|
// the bound too high and could incorrectly skip geometry.
|
|
if (DValues[Axis] == 0.0f)
|
|
{
|
|
continue;
|
|
}
|
|
const float TMin = (BoxMinValues[Axis] - AValues[Axis]) / DValues[Axis];
|
|
const float TMax = (BoxMaxValues[Axis] - AValues[Axis]) / DValues[Axis];
|
|
if (TMin > 0.0f && TMin < 1.0f && BreakCount < UE_ARRAY_COUNT(Breaks))
|
|
{
|
|
Breaks[BreakCount++] = TMin;
|
|
}
|
|
if (TMax > 0.0f && TMax < 1.0f && BreakCount < UE_ARRAY_COUNT(Breaks))
|
|
{
|
|
Breaks[BreakCount++] = TMax;
|
|
}
|
|
}
|
|
for (int32 I = 1; I < BreakCount; ++I)
|
|
{
|
|
const float Value = Breaks[I];
|
|
int32 J = I - 1;
|
|
while (J >= 0 && Breaks[J] > Value)
|
|
{
|
|
Breaks[J + 1] = Breaks[J];
|
|
--J;
|
|
}
|
|
Breaks[J + 1] = Value;
|
|
}
|
|
|
|
float MinimumDistanceSquared = FLT_MAX;
|
|
for (int32 Interval = 0; Interval + 1 < BreakCount; ++Interval)
|
|
{
|
|
const float T0 = Breaks[Interval];
|
|
const float T1 = Breaks[Interval + 1];
|
|
const float TM = 0.5f * (T0 + T1);
|
|
float QuadraticA = 0.0f;
|
|
float QuadraticB = 0.0f;
|
|
float QuadraticC = 0.0f;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
const float Mid = AValues[Axis] + DValues[Axis] * TM;
|
|
float Offset = 0.0f;
|
|
if (Mid < BoxMinValues[Axis])
|
|
{
|
|
Offset = AValues[Axis] - BoxMinValues[Axis];
|
|
}
|
|
else if (Mid > BoxMaxValues[Axis])
|
|
{
|
|
Offset = AValues[Axis] - BoxMaxValues[Axis];
|
|
}
|
|
else
|
|
{
|
|
continue;
|
|
}
|
|
QuadraticA += DValues[Axis] * DValues[Axis];
|
|
QuadraticB += 2.0f * Offset * DValues[Axis];
|
|
QuadraticC += Offset * Offset;
|
|
}
|
|
float T = T0;
|
|
if (QuadraticA > 0.0f)
|
|
{
|
|
T = FMath::Clamp(
|
|
-QuadraticB / (2.0f * QuadraticA), T0, T1);
|
|
}
|
|
const float DistanceSquared = FMath::Max(
|
|
0.0f,
|
|
(QuadraticA * T + QuadraticB) * T + QuadraticC);
|
|
MinimumDistanceSquared = FMath::Min(
|
|
MinimumDistanceSquared, DistanceSquared);
|
|
}
|
|
if (!VoxelMath::IsFinite(MinimumDistanceSquared))
|
|
{
|
|
return false;
|
|
}
|
|
OutDistance = FMath::Sqrt(MinimumDistanceSquared);
|
|
return VoxelMath::IsFinite(OutDistance);
|
|
}
|
|
|
|
static bool VF_LandingLowerBoundOverBox(
|
|
const FVoxelPassageLanding& Landing, const FBox& Box, float& OutLowerBound)
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing.StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing.StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing.FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing.CeilingZ)
|
|
|| !VoxelMath::IsFinite(Landing.HalfWidth)
|
|
|| !Box.IsValid)
|
|
{
|
|
return false;
|
|
}
|
|
if (Landing.HalfWidth <= 0.0f || Landing.CeilingZ <= Landing.FloorZ)
|
|
{
|
|
OutLowerBound = FLT_MAX;
|
|
return true;
|
|
}
|
|
|
|
constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels;
|
|
const float Height = Landing.CeilingZ - Landing.FloorZ;
|
|
const FVector Center(
|
|
Landing.StandingPoint.X, Landing.StandingPoint.Y,
|
|
(Landing.FloorZ + Landing.CeilingZ) * 0.5f);
|
|
const FVector HalfExtent(
|
|
FMath::Max(Landing.HalfWidth - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f)),
|
|
FMath::Max(Landing.HalfWidth - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f)),
|
|
FMath::Max(Height * 0.5f - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f)));
|
|
const FVector OuterMin = Center - HalfExtent - FVector(RoomRounding);
|
|
const FVector OuterMax = Center + HalfExtent + FVector(RoomRounding);
|
|
const FVector BoxMin((float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z);
|
|
const FVector BoxMax((float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z);
|
|
const float OuterDistance = VF_DistanceBetweenAABBs(
|
|
BoxMin, BoxMax, OuterMin, OuterMax);
|
|
const float RoundedBoxLower = OuterDistance > 0.0f
|
|
? OuterDistance
|
|
: -FMath::Max(HalfExtent.X,
|
|
FMath::Max(HalfExtent.Y, HalfExtent.Z)) - RoomRounding;
|
|
OutLowerBound = FMath::Max(
|
|
RoundedBoxLower, Landing.FloorZ - (float)Box.Max.Z);
|
|
return VoxelMath::IsFinite(OutLowerBound);
|
|
}
|
|
|
|
static bool VF_PassageModifierLowerBoundOverBox(
|
|
const TArray<FVoxelPassage>& Passages,
|
|
const FBox& Box, float& OutLowerBound)
|
|
{
|
|
if (!Box.IsValid)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
constexpr float BlendK = 3.0f;
|
|
float ModifierLowerBound = FLT_MAX;
|
|
const FVector BoxMin((float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z);
|
|
const FVector BoxMax((float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z);
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
return false;
|
|
}
|
|
const FVector ClosestPoint(
|
|
FMath::Clamp(Passage.BoundCenter.X, BoxMin.X, BoxMax.X),
|
|
FMath::Clamp(Passage.BoundCenter.Y, BoxMin.Y, BoxMax.Y),
|
|
FMath::Clamp(Passage.BoundCenter.Z, BoxMin.Z, BoxMax.Z));
|
|
if (FVector::DistSquared(ClosestPoint, Passage.BoundCenter)
|
|
> Passage.BoundRadiusSq)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float PassageLowerBound = FLT_MAX;
|
|
if (Passage.ControlPoints.Num() >= 2)
|
|
{
|
|
if (Passage.ControlPoints.Num() > 4096)
|
|
{
|
|
return false;
|
|
}
|
|
const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
|
if (!bTaper && !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return false;
|
|
}
|
|
for (int32 PointIndex = 0;
|
|
PointIndex < Passage.ControlPoints.Num(); ++PointIndex)
|
|
{
|
|
const FVector& Point = Passage.ControlPoints[PointIndex];
|
|
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
|
|| !VoxelMath::IsFinite(Point.Z)
|
|
|| (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex])))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const float RadiusA = bTaper
|
|
? Passage.ControlRadii[SegmentIndex] : Passage.Radius;
|
|
const float RadiusB = bTaper
|
|
? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius;
|
|
const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB));
|
|
float SegmentDistance = 0.0f;
|
|
if (!VoxelMath::IsFinite(Radius)
|
|
|| !VF_SegmentDistanceToBox(
|
|
Passage.ControlPoints[SegmentIndex],
|
|
Passage.ControlPoints[SegmentIndex + 1], Box, SegmentDistance))
|
|
{
|
|
return false;
|
|
}
|
|
PassageLowerBound = VoxelSDF::SmoothMin(
|
|
PassageLowerBound, SegmentDistance - Radius, BlendK);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.UpperPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return false;
|
|
}
|
|
float SegmentDistance = 0.0f;
|
|
if (!VF_SegmentDistanceToBox(
|
|
Passage.UpperPoint, Passage.LowerPoint, Box, SegmentDistance))
|
|
{
|
|
return false;
|
|
}
|
|
PassageLowerBound = SegmentDistance - FMath::Abs(Passage.Radius);
|
|
}
|
|
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
float LandingLowerBound = FLT_MAX;
|
|
if (!VF_LandingLowerBoundOverBox(
|
|
*Landing, Box, LandingLowerBound))
|
|
{
|
|
return false;
|
|
}
|
|
PassageLowerBound = VoxelSDF::SmoothMin(
|
|
PassageLowerBound, LandingLowerBound, BlendK);
|
|
}
|
|
ModifierLowerBound = VoxelSDF::SmoothMin(
|
|
ModifierLowerBound, PassageLowerBound, BlendK);
|
|
}
|
|
OutLowerBound = ModifierLowerBound;
|
|
return VoxelMath::IsFinite(OutLowerBound);
|
|
}
|
|
|
|
// MaxPassageCarveFactorNearLattice is queried once for the root and once for every refined
|
|
// child. The child lattice is a strict subset of the root lattice, so retaining the exact
|
|
// carve factor at each root point is a sound memo: a child reads the same values the old
|
|
// implementation would have recomputed, in the same deterministic order. The cache is
|
|
// worker-local because classifier calls can run concurrently and no mutable state may be
|
|
// published through the immutable strate manager.
|
|
struct FPassageLatticeBoundCache
|
|
{
|
|
const UVoxelStrateManager* Owner = nullptr;
|
|
uint64 OwnerLifetimeId = 0;
|
|
uint32 Version = 0xFFFFFFFFu;
|
|
FIntVector Origin = FIntVector::ZeroValue;
|
|
int32 Step = 0;
|
|
int32 FirstX = 0, LastX = -1;
|
|
int32 FirstY = 0, LastY = -1;
|
|
int32 FirstZ = 0, LastZ = -1;
|
|
bool bValid = false;
|
|
bool bNoCandidate = false;
|
|
TArray<float> Factors;
|
|
|
|
void Reset()
|
|
{
|
|
Owner = nullptr;
|
|
OwnerLifetimeId = 0;
|
|
Version = 0xFFFFFFFFu;
|
|
Origin = FIntVector::ZeroValue;
|
|
Step = 0;
|
|
FirstX = 0; LastX = -1;
|
|
FirstY = 0; LastY = -1;
|
|
FirstZ = 0; LastZ = -1;
|
|
bValid = false;
|
|
bNoCandidate = false;
|
|
Factors.Reset();
|
|
}
|
|
|
|
void Begin(const UVoxelStrateManager* InOwner, uint64 InLifetimeId,
|
|
uint32 InVersion, const FIntVector& InOrigin, int32 InStep,
|
|
int32 InFirstX, int32 InLastX,
|
|
int32 InFirstY, int32 InLastY,
|
|
int32 InFirstZ, int32 InLastZ,
|
|
bool bInNoCandidate)
|
|
{
|
|
Owner = InOwner;
|
|
OwnerLifetimeId = InLifetimeId;
|
|
Version = InVersion;
|
|
Origin = InOrigin;
|
|
Step = InStep;
|
|
FirstX = InFirstX; LastX = InLastX;
|
|
FirstY = InFirstY; LastY = InLastY;
|
|
FirstZ = InFirstZ; LastZ = InLastZ;
|
|
bValid = true;
|
|
bNoCandidate = bInNoCandidate;
|
|
Factors.Reset();
|
|
}
|
|
|
|
bool Contains(const UVoxelStrateManager* InOwner, uint64 InLifetimeId,
|
|
uint32 InVersion, const FIntVector& InOrigin, int32 InStep,
|
|
int32 InFirstX, int32 InLastX,
|
|
int32 InFirstY, int32 InLastY,
|
|
int32 InFirstZ, int32 InLastZ) const
|
|
{
|
|
return bValid && Owner == InOwner && OwnerLifetimeId == InLifetimeId
|
|
&& Version == InVersion && Origin == InOrigin && Step == InStep
|
|
&& InFirstX >= FirstX && InLastX <= LastX
|
|
&& InFirstY >= FirstY && InLastY <= LastY
|
|
&& InFirstZ >= FirstZ && InLastZ <= LastZ;
|
|
}
|
|
|
|
int32 Index(int32 IX, int32 IY, int32 IZ) const
|
|
{
|
|
const int32 DimX = LastX - FirstX + 1;
|
|
const int32 DimY = LastY - FirstY + 1;
|
|
return ((IZ - FirstZ) * DimY + (IY - FirstY)) * DimX + (IX - FirstX);
|
|
}
|
|
|
|
float MaxIn(const int32 InFirstX, const int32 InLastX,
|
|
const int32 InFirstY, const int32 InLastY,
|
|
const int32 InFirstZ, const int32 InLastZ) const
|
|
{
|
|
if (bNoCandidate) { return 0.0f; }
|
|
|
|
float Result = 0.0f;
|
|
for (int32 IZ = InFirstZ; IZ <= InLastZ; ++IZ)
|
|
{
|
|
for (int32 IY = InFirstY; IY <= InLastY; ++IY)
|
|
{
|
|
for (int32 IX = InFirstX; IX <= InLastX; ++IX)
|
|
{
|
|
Result = FMath::Max(Result, Factors[Index(IX, IY, IZ)]);
|
|
if (Result >= 1.0f)
|
|
{
|
|
return Result;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Result;
|
|
}
|
|
};
|
|
|
|
FPassageLatticeBoundCache& VF_GetPassageLatticeBoundCache()
|
|
{
|
|
thread_local FPassageLatticeBoundCache Cache;
|
|
return Cache;
|
|
}
|
|
|
|
static bool VF_LatticeBoxTouchesAABB(
|
|
const FBox& Box, const FIntVector& Origin, int32 Step,
|
|
float MinX, float MaxX, float MinY, float MaxY, float MinZ, float MaxZ)
|
|
{
|
|
return VoxelPassageGeometry::LatticeAxisHasSampleInInterval(
|
|
FMath::Max((float)Box.Min.X, MinX),
|
|
FMath::Min((float)Box.Max.X, MaxX),
|
|
(float)Origin.X, Step)
|
|
&& VoxelPassageGeometry::LatticeAxisHasSampleInInterval(
|
|
FMath::Max((float)Box.Min.Y, MinY),
|
|
FMath::Min((float)Box.Max.Y, MaxY),
|
|
(float)Origin.Y, Step)
|
|
&& VoxelPassageGeometry::LatticeAxisHasSampleInInterval(
|
|
FMath::Max((float)Box.Min.Z, MinZ),
|
|
FMath::Min((float)Box.Max.Z, MaxZ),
|
|
(float)Origin.Z, Step);
|
|
}
|
|
|
|
static bool VF_LatticeBoxTouchesSphere(
|
|
const FBox& Box, const FVector& Center, float Radius)
|
|
{
|
|
if (!Box.IsValid || !VoxelMath::IsFinite(Center.X)
|
|
|| !VoxelMath::IsFinite(Center.Y) || !VoxelMath::IsFinite(Center.Z)
|
|
|| !VoxelMath::IsFinite(Radius) || Radius < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
const FVector Closest(
|
|
FMath::Clamp(Center.X, (float)Box.Min.X, (float)Box.Max.X),
|
|
FMath::Clamp(Center.Y, (float)Box.Min.Y, (float)Box.Max.Y),
|
|
FMath::Clamp(Center.Z, (float)Box.Min.Z, (float)Box.Max.Z));
|
|
return FVector::DistSquared(Closest, Center) <= Radius * Radius;
|
|
}
|
|
|
|
static bool VF_WalkCorridorBodyCarveTouchesLattice(
|
|
const TArray<FVoxelPassage>& Passages,
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step)
|
|
{
|
|
if (Step <= 0 || !VoxelBox.IsValid)
|
|
{
|
|
return true;
|
|
}
|
|
constexpr float CorridorHalfWidth =
|
|
0.5f * VoxelPassageGeometry::WalkCorridorPlayerWidthVoxels
|
|
+ VoxelPassageGeometry::WalkCorridorLatticeMarginVoxels;
|
|
constexpr float CorridorBottom = VoxelPassageGeometry::MaxStepHeightVoxels;
|
|
constexpr float CorridorTop = CorridorBottom
|
|
+ VoxelPassageGeometry::WalkCorridorPlayerHeightVoxels;
|
|
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!Passage.bWalkableTunnelContract || !Passage.bNativeFloorEnabled)
|
|
{
|
|
continue;
|
|
}
|
|
float ReliefBound = 0.0f;
|
|
if (!VF_GetNativePassageFloorReliefBound(Passage, ReliefBound))
|
|
{
|
|
return true;
|
|
}
|
|
if (Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
const float FloorA = Passage.NativeFloorProfileZ[SegmentIndex];
|
|
const float FloorB = Passage.NativeFloorProfileZ[SegmentIndex + 1];
|
|
if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y)
|
|
|| !VoxelMath::IsFinite(B.X) || !VoxelMath::IsFinite(B.Y)
|
|
|| !VoxelMath::IsFinite(FloorA) || !VoxelMath::IsFinite(FloorB))
|
|
{
|
|
return true;
|
|
}
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
FMath::Min(A.X, B.X) - CorridorHalfWidth,
|
|
FMath::Max(A.X, B.X) + CorridorHalfWidth,
|
|
FMath::Min(A.Y, B.Y) - CorridorHalfWidth,
|
|
FMath::Max(A.Y, B.Y) + CorridorHalfWidth,
|
|
FMath::Min(FloorA, FloorB) + CorridorBottom - ReliefBound,
|
|
FMath::Max(FloorA, FloorB) + CorridorTop + ReliefBound))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
for (const FVoxelPassageLanding* Landing : {
|
|
&Passage.UpperLanding, &Passage.LowerLanding })
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->DoorPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->DoorPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ))
|
|
{
|
|
return true;
|
|
}
|
|
const float LengthSquared = FVector2D::DistSquared(
|
|
FVector2D(Landing->DoorPoint.X, Landing->DoorPoint.Y),
|
|
FVector2D(Landing->StandingPoint.X, Landing->StandingPoint.Y));
|
|
if (!VoxelMath::IsFinite(LengthSquared))
|
|
{
|
|
return true;
|
|
}
|
|
if (LengthSquared <= KINDA_SMALL_NUMBER)
|
|
{
|
|
continue;
|
|
}
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
FMath::Min(Landing->DoorPoint.X, Landing->StandingPoint.X)
|
|
- CorridorHalfWidth,
|
|
FMath::Max(Landing->DoorPoint.X, Landing->StandingPoint.X)
|
|
+ CorridorHalfWidth,
|
|
FMath::Min(Landing->DoorPoint.Y, Landing->StandingPoint.Y)
|
|
- CorridorHalfWidth,
|
|
FMath::Max(Landing->DoorPoint.Y, Landing->StandingPoint.Y)
|
|
+ CorridorHalfWidth,
|
|
Landing->FloorZ + CorridorBottom,
|
|
Landing->FloorZ + CorridorTop))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const
|
|
{
|
|
// This query feeds FPassageCarveOp::EffectOverBox, so a spatial candidate is not enough:
|
|
// returning true kills the AllSolid hypothesis even when the exact MC samples all have a
|
|
// zero carve factor. That was the LOD0 false-Mixed path. Keep the cheap domain test as the
|
|
// common reject, then use the same exact lattice factor that MaxCarveOverBox consumes. The
|
|
// manager cache makes the second call in the fold free for the same box.
|
|
const bool bBodyCarveCandidate = VF_WalkCorridorBodyCarveTouchesLattice(
|
|
Passages, VoxelBox, LatticeOrigin, Step);
|
|
if (!VF_AnyPassageModifierDomainTouchesLattice(
|
|
Passages, VoxelBox, LatticeOrigin, Step))
|
|
{
|
|
return bBodyCarveCandidate;
|
|
}
|
|
|
|
const float MaxFactor = MaxPassageCarveFactorNearLattice(
|
|
VoxelBox, LatticeOrigin, Step);
|
|
// Invalid input deliberately returns 1.0 from the exact helper. A non-finite result is also
|
|
// retained as a candidate rather than becoming an identity proof.
|
|
return !VoxelMath::IsFinite(MaxFactor) || MaxFactor > 0.0f
|
|
|| bBodyCarveCandidate;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageAirPostNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
// FPassageCarveOp already evaluates the complete passage modifier in the stack. The only
|
|
// passage work still outside that proof is the post-disturbance landing/tunnel-air backstop.
|
|
// Do not use the enclosing passage sphere as its candidate: that sphere is deliberately much
|
|
// wider than a walkable core and was the false-positive that made LOD0 all-solid tiles recurse.
|
|
if (Step <= 0 || !VoxelBox.IsValid
|
|
|| VoxelPassageGeometry::VerticalShaftConnectorAirMarker())
|
|
{
|
|
return true;
|
|
}
|
|
if (!VoxelMath::IsFinite(BaseDensity) || !(BaseDensity > 0.0f)
|
|
|| !VoxelMath::IsFinite(SealThickness) || SealThickness < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
if (VF_WalkCorridorBodyCarveTouchesLattice(
|
|
Passages, VoxelBox, LatticeOrigin, Step))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
int32 FirstX = 0, LastX = -1;
|
|
int32 FirstY = 0, LastY = -1;
|
|
int32 FirstZ = 0, LastZ = -1;
|
|
if (!VF_LatticeAxisRange(
|
|
VoxelBox.Min.X, VoxelBox.Max.X, (float)LatticeOrigin.X, Step, FirstX, LastX)
|
|
|| !VF_LatticeAxisRange(
|
|
VoxelBox.Min.Y, VoxelBox.Max.Y, (float)LatticeOrigin.Y, Step, FirstY, LastY)
|
|
|| !VF_LatticeAxisRange(
|
|
VoxelBox.Min.Z, VoxelBox.Max.Z, (float)LatticeOrigin.Z, Step, FirstZ, LastZ))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const int64 DimX = (int64)LastX - FirstX + 1;
|
|
const int64 DimY = (int64)LastY - FirstY + 1;
|
|
const int64 DimZ = (int64)LastZ - FirstZ + 1;
|
|
const int64 SampleCount = DimX * DimY * DimZ;
|
|
// This query is a proof helper, not a second unbounded density grid. An unusual external
|
|
// caller gets Unknown/candidate and therefore keeps the normal mesher path.
|
|
if (SampleCount <= 0 || SampleCount > 65536)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// The tunnel-air post is a vertical prism over the nearest horizontal control segment. Its
|
|
// existence on a lattice does not require a 3-D walk: project each XY lattice point once,
|
|
// then ask whether the resulting floor interval contains a Z lattice sample. The interval is
|
|
// deliberately closed at both ends here, so strict predicate boundaries can only create a
|
|
// false candidate; they can never hide a real air sample.
|
|
constexpr int32 MaxTunnelControlPointsForQuery = 4096;
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!Passage.bWalkableTunnelContract)
|
|
{
|
|
continue;
|
|
}
|
|
if (Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num()
|
|
|| Passage.ControlPoints.Num() > MaxTunnelControlPointsForQuery)
|
|
{
|
|
return true;
|
|
}
|
|
for (int32 PointIndex = 0;
|
|
PointIndex < Passage.ControlPoints.Num(); ++PointIndex)
|
|
{
|
|
const FVector& Point = Passage.ControlPoints[PointIndex];
|
|
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
|
|| !VoxelMath::IsFinite(Point.Z)
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex]))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
for (int32 IY = FirstY; IY <= LastY; ++IY)
|
|
{
|
|
const float Y = (float)LatticeOrigin.Y + (float)(IY * Step);
|
|
for (int32 IX = FirstX; IX <= LastX; ++IX)
|
|
{
|
|
const FVector Position(
|
|
(float)LatticeOrigin.X + (float)(IX * Step), Y, 0.0f);
|
|
float FloorZ = 0.0f;
|
|
float SupportRadius = 0.0f;
|
|
if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor(
|
|
Passage.ControlPoints, Passage.ControlRadii, Position,
|
|
FloorZ, SupportRadius))
|
|
{
|
|
continue;
|
|
}
|
|
const float AirMinZ = FMath::Max(
|
|
(float)VoxelBox.Min.Z,
|
|
FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels);
|
|
const float AirMaxZ = FMath::Min(
|
|
(float)VoxelBox.Max.Z,
|
|
FloorZ + 2.0f * SupportRadius);
|
|
if (VoxelPassageGeometry::LatticeAxisHasSampleInInterval(
|
|
AirMinZ, AirMaxZ,
|
|
(float)LatticeOrigin.Z, Step))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
constexpr float LandingBlendRadius = 4.0f;
|
|
TArray<int32, TInlineAllocator<32>> Candidates;
|
|
struct FPostPotentialDomain
|
|
{
|
|
int32 PassageIndex = INDEX_NONE;
|
|
FVector Min = FVector(FLT_MAX, FLT_MAX, FLT_MAX);
|
|
FVector Max = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX);
|
|
};
|
|
TArray<FPostPotentialDomain, TInlineAllocator<64>> PotentialDomains;
|
|
float PotentialMinX = FLT_MAX;
|
|
float PotentialMinY = FLT_MAX;
|
|
float PotentialMinZ = FLT_MAX;
|
|
float PotentialMaxX = -FLT_MAX;
|
|
float PotentialMaxY = -FLT_MAX;
|
|
float PotentialMaxZ = -FLT_MAX;
|
|
|
|
// BoundCenter/BoundRadius encloses the whole passage, which is intentionally much larger
|
|
// than the post writers. Using that sphere here made every LOD0 child along a long route
|
|
// walk its complete 17^3 lattice even when the route's landing/tunnel-air AABBs were many
|
|
// chunks away. Build a conservative union of the actual post domains instead. A point
|
|
// outside these AABBs cannot satisfy either VF_EvaluatePassageLandingSDF or
|
|
// VF_IsWalkableTunnelAir; the exact predicates below still decide every point inside.
|
|
auto AddPotentialAABB = [&](int32 PassageIndex,
|
|
float MinX, float MaxX, float MinY, float MaxY,
|
|
float MinZ, float MaxZ, bool& bTouches) -> bool
|
|
{
|
|
if (!VoxelMath::IsFinite(MinX) || !VoxelMath::IsFinite(MaxX)
|
|
|| !VoxelMath::IsFinite(MinY) || !VoxelMath::IsFinite(MaxY)
|
|
|| !VoxelMath::IsFinite(MinZ) || !VoxelMath::IsFinite(MaxZ)
|
|
|| MinX > MaxX || MinY > MaxY || MinZ > MaxZ)
|
|
{
|
|
return false;
|
|
}
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
MinX, MaxX, MinY, MaxY, MinZ, MaxZ))
|
|
{
|
|
bTouches = true;
|
|
PotentialMinX = FMath::Min(PotentialMinX, MinX);
|
|
PotentialMinY = FMath::Min(PotentialMinY, MinY);
|
|
PotentialMinZ = FMath::Min(PotentialMinZ, MinZ);
|
|
PotentialMaxX = FMath::Max(PotentialMaxX, MaxX);
|
|
PotentialMaxY = FMath::Max(PotentialMaxY, MaxY);
|
|
PotentialMaxZ = FMath::Max(PotentialMaxZ, MaxZ);
|
|
PotentialDomains.Add({
|
|
PassageIndex,
|
|
FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ) });
|
|
}
|
|
return true;
|
|
};
|
|
|
|
for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex)
|
|
{
|
|
const FVoxelPassage& Passage = Passages[PassageIndex];
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
bool bPassageTouches = false;
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
// A passage such as the optional surface entry has no landing post and therefore
|
|
// leaves this descriptor at its zero-valued default. VF_EvaluatePassageLandingSDF
|
|
// treats that descriptor as FLT_MAX (the writer is a known no-op), so it must not
|
|
// turn the whole tile into an Unknown/candidate. FloorThickness is intentionally
|
|
// not part of this validity check: the landing-air writer reads the SDF fields even
|
|
// when a separate floor descriptor is degenerate.
|
|
const bool bLandingSdfValid =
|
|
VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
&& VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
&& VoxelMath::IsFinite(Landing->FloorZ)
|
|
&& VoxelMath::IsFinite(Landing->CeilingZ)
|
|
&& VoxelMath::IsFinite(Landing->HalfWidth)
|
|
&& Landing->HalfWidth > 0.0f
|
|
&& Landing->CeilingZ > Landing->FloorZ;
|
|
if (bLandingSdfValid)
|
|
{
|
|
const float HalfExtent = Landing->HalfWidth + LandingBlendRadius;
|
|
if (!AddPotentialAABB(
|
|
PassageIndex,
|
|
Landing->StandingPoint.X - HalfExtent,
|
|
Landing->StandingPoint.X + HalfExtent,
|
|
Landing->StandingPoint.Y - HalfExtent,
|
|
Landing->StandingPoint.Y + HalfExtent,
|
|
Landing->FloorZ - LandingBlendRadius,
|
|
Landing->CeilingZ + LandingBlendRadius,
|
|
bPassageTouches))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bPassageTouches)
|
|
{
|
|
Candidates.Add(PassageIndex);
|
|
}
|
|
}
|
|
if (Candidates.Num() == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// The union above is conservative but can be much smaller than the queried box. Restrict the
|
|
// exact walk to its lattice intersection; a false positive still pays the exact predicates,
|
|
// while a far-away long-passage sphere does not.
|
|
if (!VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.X, PotentialMinX),
|
|
FMath::Min((float)VoxelBox.Max.X, PotentialMaxX),
|
|
(float)LatticeOrigin.X, Step, FirstX, LastX)
|
|
|| !VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Y, PotentialMinY),
|
|
FMath::Min((float)VoxelBox.Max.Y, PotentialMaxY),
|
|
(float)LatticeOrigin.Y, Step, FirstY, LastY)
|
|
|| !VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Z, PotentialMinZ),
|
|
FMath::Min((float)VoxelBox.Max.Z, PotentialMaxZ),
|
|
(float)LatticeOrigin.Z, Step, FirstZ, LastZ))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const int64 PotentialSampleCount = ((int64)LastX - FirstX + 1)
|
|
* ((int64)LastY - FirstY + 1)
|
|
* ((int64)LastZ - FirstZ + 1);
|
|
if (PotentialSampleCount <= 0 || PotentialSampleCount > 65536)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
|
|
if (!VoxelMath::IsFinite(AirTarget))
|
|
{
|
|
return true;
|
|
}
|
|
for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ)
|
|
{
|
|
const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step);
|
|
for (int32 IY = FirstY; IY <= LastY; ++IY)
|
|
{
|
|
const float Y = (float)LatticeOrigin.Y + (float)(IY * Step);
|
|
for (int32 IX = FirstX; IX <= LastX; ++IX)
|
|
{
|
|
const FVector Position(
|
|
(float)LatticeOrigin.X + (float)(IX * Step), Y, Z);
|
|
float MinLandingSDF = FLT_MAX;
|
|
|
|
for (const int32 PassageIndex : Candidates)
|
|
{
|
|
const FVoxelPassage& Passage = Passages[PassageIndex];
|
|
bool bInPotentialDomain = false;
|
|
for (const FPostPotentialDomain& Domain : PotentialDomains)
|
|
{
|
|
if (Domain.PassageIndex == PassageIndex
|
|
&& Position.X >= Domain.Min.X && Position.X <= Domain.Max.X
|
|
&& Position.Y >= Domain.Min.Y && Position.Y <= Domain.Max.Y
|
|
&& Position.Z >= Domain.Min.Z && Position.Z <= Domain.Max.Z)
|
|
{
|
|
bInPotentialDomain = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!bInPotentialDomain)
|
|
{
|
|
continue;
|
|
}
|
|
const float DistanceSquared =
|
|
FVector::DistSquared(Position, Passage.BoundCenter);
|
|
if (DistanceSquared > Passage.BoundRadiusSq)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const float UpperSDF = VF_EvaluatePassageLandingSDF(
|
|
Position, Passage.UpperLanding);
|
|
const float LowerSDF = VF_EvaluatePassageLandingSDF(
|
|
Position, Passage.LowerLanding);
|
|
if (!VoxelMath::IsFinite(UpperSDF) || !VoxelMath::IsFinite(LowerSDF))
|
|
{
|
|
// A malformed landing is an unknown post, never an identity proof.
|
|
return true;
|
|
}
|
|
MinLandingSDF = FMath::Min(
|
|
MinLandingSDF, FMath::Min(UpperSDF, LowerSDF));
|
|
}
|
|
|
|
// The floor writer can only suppress a landing-air post, so omitting that
|
|
// exclusion here is deliberately conservative: it can retain a final-field
|
|
// candidate, but can never prove an empty tile that owns a support floor.
|
|
// The exact same SDF threshold as the post is sufficient for the current-density
|
|
// interpolation: SDF < 4 is exactly its activation domain.
|
|
if (MinLandingSDF < LandingBlendRadius)
|
|
{
|
|
float CarveFactor = FMath::Clamp(
|
|
(LandingBlendRadius - MinLandingSDF)
|
|
/ (LandingBlendRadius * 2.0f),
|
|
0.0f, 1.0f);
|
|
CarveFactor = SmoothStep01(CarveFactor);
|
|
const float LandingThreshold = FMath::Lerp(
|
|
BaseDensity, AirTarget, CarveFactor);
|
|
if (!VoxelMath::IsFinite(LandingThreshold)
|
|
|| LandingThreshold <= 0.0f)
|
|
{
|
|
// Zero is not solid under the mesher's sign convention, so it is part of
|
|
// the final-field risk just like a strictly air threshold.
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
float UVoxelStrateManager::MaxPassageCarveFactorNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const
|
|
{
|
|
// The passage bound is only a cheap candidate test. It encloses an entire multi-segment
|
|
// passage, so using it as the final lattice proof turns a long tunnel into a false positive
|
|
// for every tile inside its enclosing sphere. That is particularly damaging at LOD0: the
|
|
// classifier then falls through to a full density grid even when every sampled point is rock.
|
|
//
|
|
// Once a bound reaches this lattice, ask the same exact modifier SDF used by
|
|
// ApplyPassageCarvingOnly at the actual MC samples. This is still a proof, not a sampling
|
|
// shortcut: the operator is evaluated at precisely the points the mesher will evaluate, and
|
|
// a non-finite result remains conservative. The common no-candidate path stays O(P), while a
|
|
// false-positive bound pays one exact lattice walk and can then be classified as Identity.
|
|
if (Step <= 0 || !VoxelBox.IsValid)
|
|
{
|
|
return 1.0f;
|
|
}
|
|
|
|
int32 FirstX = 0, LastX = -1;
|
|
int32 FirstY = 0, LastY = -1;
|
|
int32 FirstZ = 0, LastZ = -1;
|
|
if (!VF_LatticeAxisRange(
|
|
VoxelBox.Min.X, VoxelBox.Max.X, (float)LatticeOrigin.X, Step, FirstX, LastX)
|
|
|| !VF_LatticeAxisRange(
|
|
VoxelBox.Min.Y, VoxelBox.Max.Y, (float)LatticeOrigin.Y, Step, FirstY, LastY)
|
|
|| !VF_LatticeAxisRange(
|
|
VoxelBox.Min.Z, VoxelBox.Max.Z, (float)LatticeOrigin.Z, Step, FirstZ, LastZ))
|
|
{
|
|
return 1.0f;
|
|
}
|
|
|
|
FPassageLatticeBoundCache& Cache = VF_GetPassageLatticeBoundCache();
|
|
const uint64 LifetimeId = GetCacheLifetimeId();
|
|
const uint32 Version = GetLayoutVersion();
|
|
if (Cache.Contains(
|
|
this, LifetimeId, Version, LatticeOrigin, Step,
|
|
FirstX, LastX, FirstY, LastY, FirstZ, LastZ))
|
|
{
|
|
return Cache.MaxIn(FirstX, LastX, FirstY, LastY, FirstZ, LastZ);
|
|
}
|
|
|
|
constexpr float PassageCarveThreshold = 4.0f;
|
|
constexpr float SmoothMinK = 3.0f;
|
|
float ModifierBoxLowerBound = 0.0f;
|
|
if (VF_PassageModifierLowerBoundOverBox(
|
|
Passages, VoxelBox, ModifierBoxLowerBound)
|
|
&& ModifierBoxLowerBound >= PassageCarveThreshold + 1.0e-3f)
|
|
{
|
|
Cache.Begin(
|
|
this, LifetimeId, Version, LatticeOrigin, Step,
|
|
FirstX, LastX, FirstY, LastY, FirstZ, LastZ,
|
|
/*bInNoCandidate*/ true);
|
|
return 0.0f;
|
|
}
|
|
TArray<FVoxelPassageModifierDomain, TInlineAllocator<128>> ModifierDomains;
|
|
bool bKnownModifierDomains = VF_BuildPassageModifierDomains(
|
|
Passages, ModifierDomains);
|
|
float ModifierDomainPadding = FLT_MAX;
|
|
bool bModifierDomainTouchesLattice = true;
|
|
if (bKnownModifierDomains)
|
|
{
|
|
if (ModifierDomains.Num() == 0)
|
|
{
|
|
bModifierDomainTouchesLattice = false;
|
|
}
|
|
else
|
|
{
|
|
const float SmoothDip = static_cast<float>(ModifierDomains.Num() - 1)
|
|
* (SmoothMinK / 6.0f);
|
|
const float Padding = PassageCarveThreshold + SmoothDip;
|
|
ModifierDomainPadding = Padding;
|
|
if (!VoxelMath::IsFinite(Padding) || Padding < 0.0f)
|
|
{
|
|
bKnownModifierDomains = false;
|
|
}
|
|
else
|
|
{
|
|
bModifierDomainTouchesLattice = false;
|
|
for (const FVoxelPassageModifierDomain& Domain : ModifierDomains)
|
|
{
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
Domain.Min.X - Padding, Domain.Max.X + Padding,
|
|
Domain.Min.Y - Padding, Domain.Max.Y + Padding,
|
|
Domain.Min.Z - Padding, Domain.Max.Z + Padding))
|
|
{
|
|
bModifierDomainTouchesLattice = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bKnownModifierDomains && !bModifierDomainTouchesLattice)
|
|
{
|
|
Cache.Begin(
|
|
this, LifetimeId, Version, LatticeOrigin, Step,
|
|
FirstX, LastX, FirstY, LastY, FirstZ, LastZ,
|
|
/*bInNoCandidate*/ true);
|
|
return 0.0f;
|
|
}
|
|
|
|
auto CarveFactorFromSDF = [](float ModifierSDF)
|
|
{
|
|
if (!(ModifierSDF < PassageCarveThreshold))
|
|
{
|
|
return 0.0f;
|
|
}
|
|
float CarveFactor = FMath::Clamp(
|
|
(PassageCarveThreshold - ModifierSDF)
|
|
/ (PassageCarveThreshold * 2.0f),
|
|
0.0f, 1.0f);
|
|
return SmoothStep01(CarveFactor);
|
|
};
|
|
|
|
// A domain hit is still much wider than the tapered capsule itself. Before paying for the
|
|
// full modifier evaluator, use a point-level lower-bound fold over the actual primitive
|
|
// domains. It preserves the saturating SmoothMin behavior, so a far segment contributes no
|
|
// artificial global K/6 penalty. This keeps the proof exact while avoiding a 33^3 walk
|
|
// through every segment for a tile that merely grazes a passage's old sphere.
|
|
const auto MayEvaluatePoint = [&](const FVector& Position) -> bool
|
|
{
|
|
if (!bKnownModifierDomains)
|
|
{
|
|
return true;
|
|
}
|
|
bool bInsideModifierDomain = false;
|
|
for (const FVoxelPassageModifierDomain& Domain : ModifierDomains)
|
|
{
|
|
if (Position.X >= Domain.Min.X - ModifierDomainPadding
|
|
&& Position.X <= Domain.Max.X + ModifierDomainPadding
|
|
&& Position.Y >= Domain.Min.Y - ModifierDomainPadding
|
|
&& Position.Y <= Domain.Max.Y + ModifierDomainPadding
|
|
&& Position.Z >= Domain.Min.Z - ModifierDomainPadding
|
|
&& Position.Z <= Domain.Max.Z + ModifierDomainPadding)
|
|
{
|
|
bInsideModifierDomain = true;
|
|
break;
|
|
}
|
|
}
|
|
return bInsideModifierDomain
|
|
&& VF_PointMayHavePassageCarve(Passages, Position);
|
|
};
|
|
|
|
// Do not sweep the complete 33^3 lattice merely because a continuous passage domain enters
|
|
// the tile. Recursively split the sample index box and use the exact segment/box lower bound
|
|
// above to discard whole groups. A leaf still evaluates only the original MC vertices; this is
|
|
// a proof acceleration, not a lower-resolution sample.
|
|
const int64 FullSampleCount =
|
|
(static_cast<int64>(LastX) - FirstX + 1)
|
|
* (static_cast<int64>(LastY) - FirstY + 1)
|
|
* (static_cast<int64>(LastZ) - FirstZ + 1);
|
|
constexpr int64 MaxRecursiveLatticeSamples = 65536;
|
|
if (bKnownModifierDomains && FullSampleCount > 0
|
|
&& FullSampleCount <= MaxRecursiveLatticeSamples)
|
|
{
|
|
Cache.Begin(
|
|
this, LifetimeId, Version, LatticeOrigin, Step,
|
|
FirstX, LastX, FirstY, LastY, FirstZ, LastZ,
|
|
/*bInNoCandidate*/ false);
|
|
Cache.Factors.SetNumZeroed(static_cast<int32>(FullSampleCount));
|
|
|
|
float MaxCarveFactor = 0.0f;
|
|
auto EvaluatePoint = [&](int32 IX, int32 IY, int32 IZ)
|
|
{
|
|
const float X = (float)LatticeOrigin.X + (float)(IX * Step);
|
|
const float Y = (float)LatticeOrigin.Y + (float)(IY * Step);
|
|
const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step);
|
|
const FVector Position(X, Y, Z);
|
|
if (!MayEvaluatePoint(Position))
|
|
{
|
|
return;
|
|
}
|
|
const float ModifierSDF = EvaluateModifierSDF(X, Y, Z);
|
|
const float CarveFactor = VoxelMath::IsFinite(ModifierSDF)
|
|
? CarveFactorFromSDF(ModifierSDF) : 1.0f;
|
|
Cache.Factors[Cache.Index(IX, IY, IZ)] = CarveFactor;
|
|
MaxCarveFactor = FMath::Max(MaxCarveFactor, CarveFactor);
|
|
};
|
|
auto Visit = [&](auto&& Self,
|
|
int32 IX0, int32 IX1,
|
|
int32 IY0, int32 IY1,
|
|
int32 IZ0, int32 IZ1) -> void
|
|
{
|
|
const FVector NodeMin(
|
|
(float)LatticeOrigin.X + (float)(IX0 * Step),
|
|
(float)LatticeOrigin.Y + (float)(IY0 * Step),
|
|
(float)LatticeOrigin.Z + (float)(IZ0 * Step));
|
|
const FVector NodeMax(
|
|
(float)LatticeOrigin.X + (float)(IX1 * Step),
|
|
(float)LatticeOrigin.Y + (float)(IY1 * Step),
|
|
(float)LatticeOrigin.Z + (float)(IZ1 * Step));
|
|
const FBox NodeBox(NodeMin, NodeMax);
|
|
float NodeLowerBound = 0.0f;
|
|
if (VF_PassageModifierLowerBoundOverBox(
|
|
Passages, NodeBox, NodeLowerBound)
|
|
&& NodeLowerBound >= PassageCarveThreshold + 1.0e-3f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const int64 NodeSampleCount =
|
|
(static_cast<int64>(IX1) - IX0 + 1)
|
|
* (static_cast<int64>(IY1) - IY0 + 1)
|
|
* (static_cast<int64>(IZ1) - IZ0 + 1);
|
|
if (NodeSampleCount <= 64)
|
|
{
|
|
for (int32 IZ = IZ0; IZ <= IZ1; ++IZ)
|
|
for (int32 IY = IY0; IY <= IY1; ++IY)
|
|
for (int32 IX = IX0; IX <= IX1; ++IX)
|
|
{
|
|
EvaluatePoint(IX, IY, IZ);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const int32 SpanX = IX1 - IX0;
|
|
const int32 SpanY = IY1 - IY0;
|
|
const int32 SpanZ = IZ1 - IZ0;
|
|
if (SpanX >= SpanY && SpanX >= SpanZ && SpanX > 0)
|
|
{
|
|
const int32 Mid = IX0 + SpanX / 2;
|
|
Self(Self, IX0, Mid, IY0, IY1, IZ0, IZ1);
|
|
Self(Self, Mid + 1, IX1, IY0, IY1, IZ0, IZ1);
|
|
}
|
|
else if (SpanY >= SpanZ && SpanY > 0)
|
|
{
|
|
const int32 Mid = IY0 + SpanY / 2;
|
|
Self(Self, IX0, IX1, IY0, Mid, IZ0, IZ1);
|
|
Self(Self, IX0, IX1, Mid + 1, IY1, IZ0, IZ1);
|
|
}
|
|
else if (SpanZ > 0)
|
|
{
|
|
const int32 Mid = IZ0 + SpanZ / 2;
|
|
Self(Self, IX0, IX1, IY0, IY1, IZ0, Mid);
|
|
Self(Self, IX0, IX1, IY0, IY1, Mid + 1, IZ1);
|
|
}
|
|
else
|
|
{
|
|
EvaluatePoint(IX0, IY0, IZ0);
|
|
}
|
|
};
|
|
Visit(Visit, FirstX, LastX, FirstY, LastY, FirstZ, LastZ);
|
|
return MaxCarveFactor;
|
|
}
|
|
|
|
int32 EvaluationFirstX = FirstX, EvaluationLastX = LastX;
|
|
int32 EvaluationFirstY = FirstY, EvaluationLastY = LastY;
|
|
int32 EvaluationFirstZ = FirstZ, EvaluationLastZ = LastZ;
|
|
if (bKnownModifierDomains)
|
|
{
|
|
float CandidateMinX = FLT_MAX;
|
|
float CandidateMinY = FLT_MAX;
|
|
float CandidateMinZ = FLT_MAX;
|
|
float CandidateMaxX = -FLT_MAX;
|
|
float CandidateMaxY = -FLT_MAX;
|
|
float CandidateMaxZ = -FLT_MAX;
|
|
for (const FVoxelPassageModifierDomain& Domain : ModifierDomains)
|
|
{
|
|
CandidateMinX = FMath::Min(CandidateMinX,
|
|
Domain.Min.X - ModifierDomainPadding);
|
|
CandidateMinY = FMath::Min(CandidateMinY,
|
|
Domain.Min.Y - ModifierDomainPadding);
|
|
CandidateMinZ = FMath::Min(CandidateMinZ,
|
|
Domain.Min.Z - ModifierDomainPadding);
|
|
CandidateMaxX = FMath::Max(CandidateMaxX,
|
|
Domain.Max.X + ModifierDomainPadding);
|
|
CandidateMaxY = FMath::Max(CandidateMaxY,
|
|
Domain.Max.Y + ModifierDomainPadding);
|
|
CandidateMaxZ = FMath::Max(CandidateMaxZ,
|
|
Domain.Max.Z + ModifierDomainPadding);
|
|
}
|
|
if (!VoxelMath::IsFinite(CandidateMinX) || !VoxelMath::IsFinite(CandidateMinY)
|
|
|| !VoxelMath::IsFinite(CandidateMinZ) || !VoxelMath::IsFinite(CandidateMaxX)
|
|
|| !VoxelMath::IsFinite(CandidateMaxY) || !VoxelMath::IsFinite(CandidateMaxZ))
|
|
{
|
|
bKnownModifierDomains = false;
|
|
}
|
|
else if (!VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.X, CandidateMinX),
|
|
FMath::Min((float)VoxelBox.Max.X, CandidateMaxX),
|
|
(float)LatticeOrigin.X, Step,
|
|
EvaluationFirstX, EvaluationLastX)
|
|
|| !VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Y, CandidateMinY),
|
|
FMath::Min((float)VoxelBox.Max.Y, CandidateMaxY),
|
|
(float)LatticeOrigin.Y, Step,
|
|
EvaluationFirstY, EvaluationLastY)
|
|
|| !VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Z, CandidateMinZ),
|
|
FMath::Min((float)VoxelBox.Max.Z, CandidateMaxZ),
|
|
(float)LatticeOrigin.Z, Step,
|
|
EvaluationFirstZ, EvaluationLastZ))
|
|
{
|
|
Cache.Begin(
|
|
this, LifetimeId, Version, LatticeOrigin, Step,
|
|
FirstX, LastX, FirstY, LastY, FirstZ, LastZ,
|
|
/*bInNoCandidate*/ true);
|
|
return 0.0f;
|
|
}
|
|
}
|
|
|
|
const int64 DimX = static_cast<int64>(LastX) - FirstX + 1;
|
|
const int64 DimY = static_cast<int64>(LastY) - FirstY + 1;
|
|
const int64 DimZ = static_cast<int64>(LastZ) - FirstZ + 1;
|
|
const int64 SampleCount = DimX * DimY * DimZ;
|
|
constexpr int64 MaxCachedLatticeSamples = 65536;
|
|
if (SampleCount > 0 && SampleCount <= MaxCachedLatticeSamples)
|
|
{
|
|
Cache.Begin(
|
|
this, LifetimeId, Version, LatticeOrigin, Step,
|
|
FirstX, LastX, FirstY, LastY, FirstZ, LastZ,
|
|
/*bInNoCandidate*/ false);
|
|
if (bKnownModifierDomains)
|
|
{
|
|
Cache.Factors.SetNumZeroed(static_cast<int32>(SampleCount));
|
|
}
|
|
else
|
|
{
|
|
Cache.Factors.SetNumUninitialized(static_cast<int32>(SampleCount));
|
|
}
|
|
|
|
float MaxCarveFactor = 0.0f;
|
|
for (int32 IZ = EvaluationFirstZ; IZ <= EvaluationLastZ; ++IZ)
|
|
{
|
|
const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step);
|
|
for (int32 IY = EvaluationFirstY; IY <= EvaluationLastY; ++IY)
|
|
{
|
|
const float Y = (float)LatticeOrigin.Y + (float)(IY * Step);
|
|
for (int32 IX = EvaluationFirstX; IX <= EvaluationLastX; ++IX)
|
|
{
|
|
const float X = (float)LatticeOrigin.X + (float)(IX * Step);
|
|
const FVector Position(X, Y, Z);
|
|
float CarveFactor = 0.0f;
|
|
if (MayEvaluatePoint(Position))
|
|
{
|
|
const float ModifierSDF = EvaluateModifierSDF(X, Y, Z);
|
|
CarveFactor = VoxelMath::IsFinite(ModifierSDF)
|
|
? CarveFactorFromSDF(ModifierSDF) : 1.0f;
|
|
}
|
|
Cache.Factors[Cache.Index(IX, IY, IZ)] = CarveFactor;
|
|
MaxCarveFactor = FMath::Max(MaxCarveFactor, CarveFactor);
|
|
if (MaxCarveFactor >= 1.0f)
|
|
{
|
|
// The remaining values are still filled below. Descendant queries need
|
|
// the complete exact lattice, not merely this query's maximum.
|
|
for (int32 RemainingZ = IZ; RemainingZ <= LastZ; ++RemainingZ)
|
|
{
|
|
const float RemainingWorldZ =
|
|
(float)LatticeOrigin.Z + (float)(RemainingZ * Step);
|
|
const int32 StartY = RemainingZ == IZ ? IY : FirstY;
|
|
for (int32 RemainingY = StartY; RemainingY <= LastY; ++RemainingY)
|
|
{
|
|
const float RemainingWorldY =
|
|
(float)LatticeOrigin.Y + (float)(RemainingY * Step);
|
|
const int32 StartX =
|
|
(RemainingZ == IZ && RemainingY == IY) ? IX + 1 : FirstX;
|
|
for (int32 RemainingX = StartX; RemainingX <= LastX; ++RemainingX)
|
|
{
|
|
const float RemainingWorldX =
|
|
(float)LatticeOrigin.X + (float)(RemainingX * Step);
|
|
const FVector RemainingPosition(
|
|
RemainingWorldX, RemainingWorldY, RemainingWorldZ);
|
|
float RemainingFactor = 0.0f;
|
|
if (MayEvaluatePoint(RemainingPosition))
|
|
{
|
|
const float RemainingSDF = EvaluateModifierSDF(
|
|
RemainingWorldX, RemainingWorldY, RemainingWorldZ);
|
|
RemainingFactor = VoxelMath::IsFinite(RemainingSDF)
|
|
? CarveFactorFromSDF(RemainingSDF) : 1.0f;
|
|
}
|
|
Cache.Factors[Cache.Index(RemainingX, RemainingY, RemainingZ)] =
|
|
RemainingFactor;
|
|
}
|
|
}
|
|
}
|
|
return MaxCarveFactor;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return MaxCarveFactor;
|
|
}
|
|
|
|
// Very large or unusual external queries do not enter the bounded worker memo. Keep the
|
|
// original exact walk for them; refusing the cache must never change the proof.
|
|
Cache.Reset();
|
|
float MaxCarveFactor = 0.0f;
|
|
for (int32 IZ = EvaluationFirstZ; IZ <= EvaluationLastZ; ++IZ)
|
|
{
|
|
const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step);
|
|
for (int32 IY = EvaluationFirstY; IY <= EvaluationLastY; ++IY)
|
|
{
|
|
const float Y = (float)LatticeOrigin.Y + (float)(IY * Step);
|
|
for (int32 IX = EvaluationFirstX; IX <= EvaluationLastX; ++IX)
|
|
{
|
|
const float X = (float)LatticeOrigin.X + (float)(IX * Step);
|
|
const FVector Position(X, Y, Z);
|
|
if (!MayEvaluatePoint(Position))
|
|
{
|
|
continue;
|
|
}
|
|
const float ModifierSDF = EvaluateModifierSDF(X, Y, Z);
|
|
if (!VoxelMath::IsFinite(ModifierSDF))
|
|
{
|
|
return 1.0f;
|
|
}
|
|
MaxCarveFactor = FMath::Max(
|
|
MaxCarveFactor, CarveFactorFromSDF(ModifierSDF));
|
|
if (MaxCarveFactor >= 1.0f)
|
|
{
|
|
return MaxCarveFactor;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return MaxCarveFactor;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageLandingFloorNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const
|
|
{
|
|
if (Step <= 0 || !VoxelBox.IsValid)
|
|
{
|
|
return true;
|
|
}
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
float ReliefBound = 0.0f;
|
|
if (!VF_GetNativePassageFloorReliefBound(Passage, ReliefBound))
|
|
{
|
|
return true;
|
|
}
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing->HalfWidth)
|
|
|| !VoxelMath::IsFinite(Landing->FloorThickness))
|
|
{
|
|
return true;
|
|
}
|
|
if (Landing->HalfWidth <= 0.0f || Landing->FloorThickness <= 0.0f)
|
|
{
|
|
continue;
|
|
}
|
|
constexpr float Pad = 1.0f;
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
Landing->StandingPoint.X - Landing->HalfWidth - Pad,
|
|
Landing->StandingPoint.X + Landing->HalfWidth + Pad,
|
|
Landing->StandingPoint.Y - Landing->HalfWidth - Pad,
|
|
Landing->StandingPoint.Y + Landing->HalfWidth + Pad,
|
|
Landing->FloorZ - Landing->FloorThickness - Pad,
|
|
Landing->FloorZ + Pad))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (Passage.bWalkableTunnelContract)
|
|
{
|
|
if (Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return true;
|
|
}
|
|
constexpr float Pad = 1.0f;
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y)
|
|
|| !VoxelMath::IsFinite(A.Z) || !VoxelMath::IsFinite(B.X)
|
|
|| !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z)
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex])
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1]))
|
|
{
|
|
return true;
|
|
}
|
|
const float RadiusA = FMath::Abs(Passage.ControlRadii[SegmentIndex]);
|
|
const float RadiusB = FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]);
|
|
const float SupportRadius =
|
|
VoxelPassageGeometry::NativePassageFloorSupportRadius(RadiusA, RadiusB);
|
|
const float FloorA = Passage.bNativeFloorEnabled
|
|
? Passage.NativeFloorProfileZ[SegmentIndex]
|
|
: VoxelPassageGeometry::TunnelFloorZ(A, RadiusA);
|
|
const float FloorB = Passage.bNativeFloorEnabled
|
|
? Passage.NativeFloorProfileZ[SegmentIndex + 1]
|
|
: VoxelPassageGeometry::TunnelFloorZ(B, RadiusB);
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
FMath::Min(A.X, B.X) - SupportRadius - Pad,
|
|
FMath::Max(A.X, B.X) + SupportRadius + Pad,
|
|
FMath::Min(A.Y, B.Y) - SupportRadius - Pad,
|
|
FMath::Max(A.Y, B.Y) + SupportRadius + Pad,
|
|
FMath::Min(FloorA, FloorB)
|
|
- VoxelPassageGeometry::LandingFloorThicknessVoxels
|
|
- Pad - ReliefBound,
|
|
FMath::Max(FloorA, FloorB) + Pad + ReliefBound))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageLandingNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
|
float ReachScale) const
|
|
{
|
|
if (Step <= 0 || !VoxelBox.IsValid
|
|
|| !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// VF_ApplyPassageCarving has a hard no-op band at ModSDF >= 4. A landing term outside the
|
|
// rounded-room AABB by 4 + K (K=3) is either saturated out of SmoothMin when the tube is
|
|
// inside the carve band, or leaves the folded result >= 4 when the tube is outside it. That
|
|
// proves the landing term cannot change the field; the direct landing/floor posts use their
|
|
// smaller, operation-specific pads below.
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (Passage.ControlPoints.Num() >= 2)
|
|
{
|
|
if (Passage.ControlPoints.Num() > 4096)
|
|
{
|
|
return true;
|
|
}
|
|
const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
|
if (!bTaper && !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return true;
|
|
}
|
|
for (int32 PointIndex = 0;
|
|
PointIndex < Passage.ControlPoints.Num(); ++PointIndex)
|
|
{
|
|
const FVector& Point = Passage.ControlPoints[PointIndex];
|
|
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
|
|| !VoxelMath::IsFinite(Point.Z)
|
|
|| (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex])))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.UpperPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing->CeilingZ)
|
|
|| !VoxelMath::IsFinite(Landing->HalfWidth)
|
|
|| !VoxelMath::IsFinite(Landing->FloorThickness))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (Landing->HalfWidth > 0.0f
|
|
&& Landing->FloorThickness > 0.0f
|
|
&& VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
Landing->StandingPoint.X - FMath::Max(Landing->HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f)
|
|
- ReachScale,
|
|
Landing->StandingPoint.X + FMath::Max(Landing->HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f)
|
|
+ ReachScale,
|
|
Landing->StandingPoint.Y - FMath::Max(Landing->HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f)
|
|
- ReachScale,
|
|
Landing->StandingPoint.Y + FMath::Max(Landing->HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f)
|
|
+ ReachScale,
|
|
Landing->FloorZ - Landing->FloorThickness - ReachScale,
|
|
Landing->FloorZ + ReachScale))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (Landing->HalfWidth <= 0.0f || Landing->CeilingZ <= Landing->FloorZ)
|
|
{
|
|
// VF_EvaluatePassageLandingSDF returns FLT_MAX for this descriptor. The floor
|
|
// test above has already handled the only remaining possible writer.
|
|
continue;
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
constexpr float SmoothMinK = 3.0f;
|
|
const float RoomPad = (VoxelPassageGeometry::LandingCarveBlendVoxels + SmoothMinK)
|
|
* ReachScale;
|
|
if (!VoxelMath::IsFinite(RoomPad) || RoomPad < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (Landing->HalfWidth <= 0.0f || Landing->CeilingZ <= Landing->FloorZ)
|
|
{
|
|
continue;
|
|
}
|
|
constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels;
|
|
const float Height = Landing->CeilingZ - Landing->FloorZ;
|
|
const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f));
|
|
const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f));
|
|
const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f;
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
Landing->StandingPoint.X - HalfX - RoomRounding - RoomPad,
|
|
Landing->StandingPoint.X + HalfX + RoomRounding + RoomPad,
|
|
Landing->StandingPoint.Y - HalfX - RoomRounding - RoomPad,
|
|
Landing->StandingPoint.Y + HalfX + RoomRounding + RoomPad,
|
|
FMath::Min(Landing->FloorZ,
|
|
CenterZ - HalfZ - RoomRounding) - RoomPad,
|
|
FMath::Max(Landing->CeilingZ,
|
|
CenterZ + HalfZ + RoomRounding) + RoomPad))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageCarvingNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
|
float ReachScale) const
|
|
{
|
|
if (VoxelDensityAblation::IsPassageCarvingOff())
|
|
{
|
|
return false;
|
|
}
|
|
if (Step <= 0 || !VoxelBox.IsValid
|
|
|| !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// BoundRadius is built around every capsule control point and both landing rooms. The
|
|
// construction already adds the ModSDF<4 carve band. SmoothMin can lower a chain of
|
|
// otherwise-far finite terms by at most K/6 per combine, so include that finite morphology
|
|
// loss as well. A miss of this sphere proves every capsule/landing term folds to the hard
|
|
// no-op band before VF_ApplyPassageCarving; malformed descriptors remain candidates.
|
|
int32 SmoothMinCombines = 0;
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
if (Passage.ControlPoints.Num() >= 2)
|
|
{
|
|
if (Passage.ControlPoints.Num() > 4096)
|
|
{
|
|
return true;
|
|
}
|
|
const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
|
if (!bTaper && !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return true;
|
|
}
|
|
for (int32 Index = 0; Index < Passage.ControlPoints.Num(); ++Index)
|
|
{
|
|
const FVector& Point = Passage.ControlPoints[Index];
|
|
if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y)
|
|
|| !VoxelMath::IsFinite(Point.Z)
|
|
|| (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[Index])))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
SmoothMinCombines += FMath::Max(Passage.ControlPoints.Num() - 2, 0);
|
|
}
|
|
else
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.UpperPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.UpperPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.X)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Y)
|
|
|| !VoxelMath::IsFinite(Passage.LowerPoint.Z)
|
|
|| !VoxelMath::IsFinite(Passage.Radius))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (Passage.bNativeFloorEnabled)
|
|
{
|
|
++SmoothMinCombines; // SmoothMax is the same K/6 polynomial bound.
|
|
}
|
|
SmoothMinCombines += 3; // passage union plus its two landing terms
|
|
const FVoxelPassageLanding* Landings[] = {
|
|
&Passage.UpperLanding, &Passage.LowerLanding };
|
|
for (const FVoxelPassageLanding* Landing : Landings)
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing->StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing->StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing->FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing->CeilingZ)
|
|
|| !VoxelMath::IsFinite(Landing->HalfWidth)
|
|
|| !VoxelMath::IsFinite(Landing->FloorThickness))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
constexpr float CarveThreshold = 4.0f;
|
|
constexpr float BlendK = 3.0f;
|
|
const float MorphologyPad = CarveThreshold
|
|
+ BlendK * static_cast<float>(FMath::Max(SmoothMinCombines, 0)) / 6.0f;
|
|
if (!VoxelMath::IsFinite(MorphologyPad) || MorphologyPad < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
bool bHasTightGeometry = false;
|
|
bool bTightTouch = false;
|
|
const float PassagePad = MorphologyPad * ReachScale;
|
|
if (!VoxelMath::IsFinite(PassagePad) || PassagePad < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
if (Passage.ControlPoints.Num() >= 2)
|
|
{
|
|
bHasTightGeometry = Passage.ControlPoints.Num() <= 4096;
|
|
const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num();
|
|
const float MaxRadius = bTaper
|
|
? [&Passage]()
|
|
{
|
|
float Result = 0.0f;
|
|
for (const float Radius : Passage.ControlRadii)
|
|
{
|
|
Result = FMath::Max(Result, FMath::Abs(Radius));
|
|
}
|
|
return Result;
|
|
}()
|
|
: FMath::Abs(Passage.Radius);
|
|
if (!VoxelMath::IsFinite(MaxRadius))
|
|
{
|
|
return true;
|
|
}
|
|
const float SegmentPad = (MaxRadius
|
|
+ (Passage.bNativeFloorEnabled
|
|
? VoxelPassageGeometry::LandingFloorThicknessVoxels : 0.0f)
|
|
+ PassagePad);
|
|
if (!VoxelMath::IsFinite(SegmentPad))
|
|
{
|
|
return true;
|
|
}
|
|
for (int32 SegmentIndex = 0;
|
|
bHasTightGeometry && SegmentIndex + 1 < Passage.ControlPoints.Num();
|
|
++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
FMath::Min(A.X, B.X) - SegmentPad,
|
|
FMath::Max(A.X, B.X) + SegmentPad,
|
|
FMath::Min(A.Y, B.Y) - SegmentPad,
|
|
FMath::Max(A.Y, B.Y) + SegmentPad,
|
|
FMath::Min(A.Z, B.Z) - SegmentPad,
|
|
FMath::Max(A.Z, B.Z) + SegmentPad))
|
|
{
|
|
bTightTouch = true;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
bHasTightGeometry = true;
|
|
const float SegmentPad = FMath::Abs(Passage.Radius)
|
|
+ (Passage.bNativeFloorEnabled
|
|
? VoxelPassageGeometry::LandingFloorThicknessVoxels : 0.0f)
|
|
+ PassagePad;
|
|
if (!VoxelMath::IsFinite(SegmentPad))
|
|
{
|
|
return true;
|
|
}
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
FMath::Min(Passage.UpperPoint.X, Passage.LowerPoint.X) - SegmentPad,
|
|
FMath::Max(Passage.UpperPoint.X, Passage.LowerPoint.X) + SegmentPad,
|
|
FMath::Min(Passage.UpperPoint.Y, Passage.LowerPoint.Y) - SegmentPad,
|
|
FMath::Max(Passage.UpperPoint.Y, Passage.LowerPoint.Y) + SegmentPad,
|
|
FMath::Min(Passage.UpperPoint.Z, Passage.LowerPoint.Z) - SegmentPad,
|
|
FMath::Max(Passage.UpperPoint.Z, Passage.LowerPoint.Z) + SegmentPad))
|
|
{
|
|
bTightTouch = true;
|
|
}
|
|
}
|
|
|
|
const float RoomPad = PassagePad;
|
|
for (const FVoxelPassageLanding* Landing : {
|
|
&Passage.UpperLanding, &Passage.LowerLanding })
|
|
{
|
|
if (Landing->HalfWidth <= 0.0f || Landing->CeilingZ <= Landing->FloorZ)
|
|
{
|
|
continue;
|
|
}
|
|
constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels;
|
|
const float Height = Landing->CeilingZ - Landing->FloorZ;
|
|
const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f));
|
|
const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding,
|
|
VoxelPassageGeometry::MetersToVoxels(0.0625f));
|
|
const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f;
|
|
if (VF_LatticeBoxTouchesAABB(
|
|
VoxelBox, LatticeOrigin, Step,
|
|
Landing->StandingPoint.X - HalfX - RoomRounding - RoomPad,
|
|
Landing->StandingPoint.X + HalfX + RoomRounding + RoomPad,
|
|
Landing->StandingPoint.Y - HalfX - RoomRounding - RoomPad,
|
|
Landing->StandingPoint.Y + HalfX + RoomRounding + RoomPad,
|
|
FMath::Min(Landing->FloorZ,
|
|
CenterZ - HalfZ - RoomRounding) - RoomPad,
|
|
FMath::Max(Landing->CeilingZ,
|
|
CenterZ + HalfZ + RoomRounding) + RoomPad))
|
|
{
|
|
bTightTouch = true;
|
|
}
|
|
}
|
|
|
|
if (bHasTightGeometry && !bTightTouch)
|
|
{
|
|
continue;
|
|
}
|
|
const float Reach = (Passage.BoundRadius + MorphologyPad) * ReachScale;
|
|
if (!VoxelMath::IsFinite(Reach)
|
|
|| VF_LatticeBoxTouchesSphere(VoxelBox, Passage.BoundCenter, Reach))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyPassageStructuralPostNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
|
float ReachScale) const
|
|
{
|
|
(void)LatticeOrigin;
|
|
(void)Step;
|
|
if (Step <= 0 || !VoxelBox.IsValid
|
|
|| !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// The construction-time BoundRadius encloses every tube, landing room, floor band, and its
|
|
// four-voxel carve margin. All structural writers below are subsets of that sphere, so a
|
|
// sphere/AABB miss proves identity without doing a per-sample chunk-shortlist lookup.
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (!VoxelMath::IsFinite(Passage.BoundCenter.X)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Y)
|
|
|| !VoxelMath::IsFinite(Passage.BoundCenter.Z)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadius)
|
|
|| !VoxelMath::IsFinite(Passage.BoundRadiusSq)
|
|
|| Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
const float Reach = (Passage.BoundRadius + 1.0f) * ReachScale;
|
|
if (!VoxelMath::IsFinite(Reach)
|
|
|| VF_LatticeBoxTouchesSphere(VoxelBox, Passage.BoundCenter, Reach))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyLandingFloorAtLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const
|
|
{
|
|
// This query is used only after the stack has proved AllAir. The broad floor guards above
|
|
// are intentionally padded for sound interval folding, but that padding turns a long tunnel
|
|
// or landing AABB into a false final-field candidate. Check the actual writer predicates on
|
|
// the same MC lattice before asking GetDensityAt for a certificate. Unknown inputs remain
|
|
// candidates; this function is never allowed to manufacture an empty proof.
|
|
if (Step <= 0 || !VoxelBox.IsValid
|
|
|| VoxelPassageGeometry::VerticalShaftConnectorAirMarker())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const bool bBroadPassageFloor = AnyPassageLandingFloorNearLattice(
|
|
VoxelBox, LatticeOrigin, Step);
|
|
const bool bBroadOriginFloor = AnyOriginLandingFloorNearLattice(
|
|
VoxelBox, LatticeOrigin, Step);
|
|
if (!bBroadPassageFloor && !bBroadOriginFloor)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
auto GetRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool
|
|
{
|
|
return VF_LatticeAxisRange(
|
|
Min, Max, static_cast<float>(LatticeOrigin.X), Step, OutFirst, OutLast);
|
|
};
|
|
auto GetYRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool
|
|
{
|
|
return VF_LatticeAxisRange(
|
|
Min, Max, static_cast<float>(LatticeOrigin.Y), Step, OutFirst, OutLast);
|
|
};
|
|
auto GetZRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool
|
|
{
|
|
return VF_LatticeAxisRange(
|
|
Min, Max, static_cast<float>(LatticeOrigin.Z), Step, OutFirst, OutLast);
|
|
};
|
|
|
|
auto HasAnyLandingSample = [&](const FVoxelPassageLanding& Landing) -> bool
|
|
{
|
|
if (!VoxelMath::IsFinite(Landing.StandingPoint.X)
|
|
|| !VoxelMath::IsFinite(Landing.StandingPoint.Y)
|
|
|| !VoxelMath::IsFinite(Landing.FloorZ)
|
|
|| !VoxelMath::IsFinite(Landing.HalfWidth)
|
|
|| !VoxelMath::IsFinite(Landing.FloorThickness)
|
|
|| Landing.HalfWidth <= 0.0f || Landing.FloorThickness <= 0.0f)
|
|
{
|
|
// This function gates an AllAir proof. Invalid authored floor data is an unknown
|
|
// structural writer, not evidence that the writer is absent.
|
|
return true;
|
|
}
|
|
const float HalfWidth = FMath::Max(Landing.HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f);
|
|
int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1;
|
|
return VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.X,
|
|
Landing.StandingPoint.X - HalfWidth),
|
|
FMath::Min((float)VoxelBox.Max.X,
|
|
Landing.StandingPoint.X + HalfWidth),
|
|
(float)LatticeOrigin.X, Step, IX0, IX1)
|
|
&& VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Y,
|
|
Landing.StandingPoint.Y - HalfWidth),
|
|
FMath::Min((float)VoxelBox.Max.Y,
|
|
Landing.StandingPoint.Y + HalfWidth),
|
|
(float)LatticeOrigin.Y, Step, IY0, IY1)
|
|
&& VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Z,
|
|
Landing.FloorZ - Landing.FloorThickness),
|
|
FMath::Min((float)VoxelBox.Max.Z,
|
|
Landing.FloorZ + KINDA_SMALL_NUMBER),
|
|
(float)LatticeOrigin.Z, Step, IZ0, IZ1);
|
|
};
|
|
|
|
if (bBroadPassageFloor)
|
|
{
|
|
for (const FVoxelPassage& Passage : Passages)
|
|
{
|
|
if (HasAnyLandingSample(Passage.UpperLanding)
|
|
|| HasAnyLandingSample(Passage.LowerLanding))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!Passage.bWalkableTunnelContract)
|
|
{
|
|
continue;
|
|
}
|
|
if (Passage.ControlPoints.Num() < 2
|
|
|| Passage.ControlRadii.Num() != Passage.ControlPoints.Num())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
constexpr float Pad = 1.0f;
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex)
|
|
{
|
|
const FVector& A = Passage.ControlPoints[SegmentIndex];
|
|
const FVector& B = Passage.ControlPoints[SegmentIndex + 1];
|
|
if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y)
|
|
|| !VoxelMath::IsFinite(A.Z) || !VoxelMath::IsFinite(B.X)
|
|
|| !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z)
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex])
|
|
|| !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1]))
|
|
{
|
|
return true;
|
|
}
|
|
const float SupportRadius = FMath::Max(
|
|
FMath::Min(
|
|
FMath::Abs(Passage.ControlRadii[SegmentIndex]),
|
|
FMath::Abs(Passage.ControlRadii[SegmentIndex + 1])) - 0.5f,
|
|
VoxelPassageGeometry::PlayerRadiusVoxels);
|
|
const float FloorA = VoxelPassageGeometry::TunnelFloorZ(
|
|
A, Passage.ControlRadii[SegmentIndex]);
|
|
const float FloorB = VoxelPassageGeometry::TunnelFloorZ(
|
|
B, Passage.ControlRadii[SegmentIndex + 1]);
|
|
const float MinX = FMath::Min(A.X, B.X) - SupportRadius - Pad;
|
|
const float MaxX = FMath::Max(A.X, B.X) + SupportRadius + Pad;
|
|
const float MinY = FMath::Min(A.Y, B.Y) - SupportRadius - Pad;
|
|
const float MaxY = FMath::Max(A.Y, B.Y) + SupportRadius + Pad;
|
|
const float MinZ = FMath::Min(FloorA, FloorB)
|
|
- VoxelPassageGeometry::LandingFloorThicknessVoxels - Pad;
|
|
const float MaxZ = FMath::Max(FloorA, FloorB) + Pad;
|
|
if (!VoxelMath::IsFinite(MinX) || !VoxelMath::IsFinite(MaxX)
|
|
|| !VoxelMath::IsFinite(MinY) || !VoxelMath::IsFinite(MaxY)
|
|
|| !VoxelMath::IsFinite(MinZ) || !VoxelMath::IsFinite(MaxZ))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1;
|
|
if (!GetRange(FMath::Max((float)VoxelBox.Min.X, MinX),
|
|
FMath::Min((float)VoxelBox.Max.X, MaxX), IX0, IX1)
|
|
|| !GetYRange(FMath::Max((float)VoxelBox.Min.Y, MinY),
|
|
FMath::Min((float)VoxelBox.Max.Y, MaxY), IY0, IY1)
|
|
|| !GetZRange(FMath::Max((float)VoxelBox.Min.Z, MinZ),
|
|
FMath::Min((float)VoxelBox.Max.Z, MaxZ), IZ0, IZ1))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
for (int32 IZ = IZ0; IZ <= IZ1; ++IZ)
|
|
{
|
|
const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step);
|
|
for (int32 IY = IY0; IY <= IY1; ++IY)
|
|
{
|
|
const float Y = (float)LatticeOrigin.Y + (float)(IY * Step);
|
|
for (int32 IX = IX0; IX <= IX1; ++IX)
|
|
{
|
|
const FVector Position(
|
|
(float)LatticeOrigin.X + (float)(IX * Step), Y, Z);
|
|
if (VF_IsWalkableTunnelFloor(Passage, Position))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bBroadOriginFloor)
|
|
{
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const float TopZ = (static_cast<float>(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition);
|
|
const VoxelPassageGeometry::FOriginLandingGeometry Geometry =
|
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
|
TopZ, BottomZ, Seal, OriginSpineRadius);
|
|
if (!Geometry.bValid)
|
|
{
|
|
continue;
|
|
}
|
|
const float HalfWidth = FMath::Max(Geometry.HalfWidth
|
|
- VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f);
|
|
int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1;
|
|
if (VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.X, -HalfWidth - 0.0f),
|
|
FMath::Min((float)VoxelBox.Max.X, HalfWidth + 0.0f),
|
|
(float)LatticeOrigin.X, Step, IX0, IX1)
|
|
&& VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Y, -HalfWidth - 0.0f),
|
|
FMath::Min((float)VoxelBox.Max.Y, HalfWidth + 0.0f),
|
|
(float)LatticeOrigin.Y, Step, IY0, IY1)
|
|
&& VF_LatticeAxisRange(
|
|
FMath::Max((float)VoxelBox.Min.Z,
|
|
Geometry.FloorZ - Geometry.FloorThickness),
|
|
FMath::Min((float)VoxelBox.Max.Z,
|
|
Geometry.FloorZ + KINDA_SMALL_NUMBER),
|
|
(float)LatticeOrigin.Z, Step, IZ0, IZ1))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyOriginLandingNearBox(
|
|
const FVector& MinVoxel, const FVector& MaxVoxel) const
|
|
{
|
|
if (OriginSpineRadius <= 0.0f) return false;
|
|
const FBox VoxelBox(MinVoxel, MaxVoxel);
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const float TopZ = (static_cast<float>(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
if (VoxelPassageGeometry::OriginLandingRoomTouchesBox(
|
|
VoxelBox, TopZ, BottomZ,
|
|
VF_BoundarySealThicknessForDefinition(*Slot.Definition),
|
|
OriginSpineRadius))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyOriginLandingFloorNearBox(
|
|
const FVector& MinVoxel, const FVector& MaxVoxel) const
|
|
{
|
|
if (OriginSpineRadius <= 0.0f) return false;
|
|
const FBox VoxelBox(MinVoxel, MaxVoxel);
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const float TopZ = (static_cast<float>(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
if (VoxelPassageGeometry::OriginLandingFloorTouchesBox(
|
|
VoxelBox, TopZ, BottomZ,
|
|
VF_BoundarySealThicknessForDefinition(*Slot.Definition),
|
|
OriginSpineRadius))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyOriginLandingNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
|
float ReachScale) const
|
|
{
|
|
if (OriginSpineRadius <= 0.0f) return false;
|
|
if (!VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const float TopZ = (static_cast<float>(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition);
|
|
if (VoxelPassageGeometry::OriginLandingRoomTouchesLattice(
|
|
VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius,
|
|
ReachScale))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyOriginLandingAirNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step,
|
|
float BaseDensity, float SealThickness) const
|
|
{
|
|
if (Step <= 0 || !VoxelBox.IsValid || OriginSpineRadius <= 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
if (!VoxelMath::IsFinite(BaseDensity) || !(BaseDensity > 0.0f)
|
|
|| !VoxelMath::IsFinite(SealThickness) || SealThickness < 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
|
|
if (!VoxelMath::IsFinite(AirTarget))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (Slot.Definition == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
const float TopZ = (static_cast<float>(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition);
|
|
const VoxelPassageGeometry::FOriginLandingGeometry Geometry =
|
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
|
TopZ, BottomZ, Seal, OriginSpineRadius);
|
|
if (!Geometry.bValid)
|
|
{
|
|
continue;
|
|
}
|
|
if (!VoxelPassageGeometry::OriginLandingRoomTouchesLattice(
|
|
VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
int32 FirstX = 0, LastX = -1;
|
|
int32 FirstY = 0, LastY = -1;
|
|
int32 FirstZ = 0, LastZ = -1;
|
|
if (!VF_LatticeAxisRange(
|
|
VoxelBox.Min.X, VoxelBox.Max.X, (float)LatticeOrigin.X, Step, FirstX, LastX)
|
|
|| !VF_LatticeAxisRange(
|
|
VoxelBox.Min.Y, VoxelBox.Max.Y, (float)LatticeOrigin.Y, Step, FirstY, LastY)
|
|
|| !VF_LatticeAxisRange(
|
|
VoxelBox.Min.Z, VoxelBox.Max.Z, (float)LatticeOrigin.Z, Step, FirstZ, LastZ))
|
|
{
|
|
return true;
|
|
}
|
|
const int64 SampleCount = ((int64)LastX - FirstX + 1)
|
|
* ((int64)LastY - FirstY + 1)
|
|
* ((int64)LastZ - FirstZ + 1);
|
|
if (SampleCount <= 0 || SampleCount > 65536)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ)
|
|
for (int32 IY = FirstY; IY <= LastY; ++IY)
|
|
for (int32 IX = FirstX; IX <= LastX; ++IX)
|
|
{
|
|
const FVector Position(
|
|
(float)LatticeOrigin.X + (float)(IX * Step),
|
|
(float)LatticeOrigin.Y + (float)(IY * Step),
|
|
(float)LatticeOrigin.Z + (float)(IZ * Step));
|
|
const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF(
|
|
Position, Geometry);
|
|
if (!VoxelMath::IsFinite(RoomSDF))
|
|
{
|
|
return true;
|
|
}
|
|
if (RoomSDF < VoxelPassageGeometry::LandingCarveBlendVoxels)
|
|
{
|
|
float CarveFactor = FMath::Clamp(
|
|
(VoxelPassageGeometry::LandingCarveBlendVoxels - RoomSDF)
|
|
/ (VoxelPassageGeometry::LandingCarveBlendVoxels * 2.0f),
|
|
0.0f, 1.0f);
|
|
CarveFactor = SmoothStep01(CarveFactor);
|
|
const float LandingThreshold = FMath::Lerp(
|
|
BaseDensity, AirTarget, CarveFactor);
|
|
if (!VoxelMath::IsFinite(LandingThreshold)
|
|
|| LandingThreshold <= 0.0f)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UVoxelStrateManager::AnyOriginLandingFloorNearLattice(
|
|
const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const
|
|
{
|
|
if (OriginSpineRadius <= 0.0f) return false;
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const float TopZ = (static_cast<float>(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(Slot.BottomChunkZ) * CHUNK_SIZE;
|
|
const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition);
|
|
if (VoxelPassageGeometry::OriginLandingFloorTouchesLattice(
|
|
VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius))
|
|
{
|
|
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; }
|
|
|
|
// Every strate is generated by the operator stack: authored definitions, cooked-season entries
|
|
// (recipe or native) and editor composer candidates alike. The slot definition carries the
|
|
// archetype in every case.
|
|
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
|
|
|
|
// LA LISTE DES ARCHÉTYPES — le seul endroit où elle est écrite.
|
|
// THE ARCHETYPE LIST, written down exactly once.
|
|
switch (Def->GeneratorType)
|
|
{
|
|
case ECaveGeneratorType::Maze: return true;
|
|
case ECaveGeneratorType::FlatPlain: // les deux partagent
|
|
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
|
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
// Pas de garde « pas de biomes » : le combiner `Mask` existe, donc une strate à biomes
|
|
// mélange bien ses hauteurs comme le chemin d'origine.
|
|
// No no-biome guard: the Mask combiner exists, so a biome strate blends its heights
|
|
// exactly as the original path does.
|
|
return true;
|
|
|
|
case ECaveGeneratorType::VerticalShafts: return true; // 3 ops repris de Maze tels quels
|
|
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
// 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. `VF_BuildOpStackForChunk` 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:
|
|
// Le plus gros : 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 ; en forker une copie la casserait.
|
|
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];
|
|
|
|
// This is the one final resolution gate for the asset-owned worm switch. It is deliberately
|
|
// applied after composer/season replacements and after boundary blending: a disabled current
|
|
// strate can never be re-enabled by a rolled candidate or by an enabled neighbour.
|
|
const auto ResolveFinalParams = [&Slot](FStrateGenerationParams Result)
|
|
{
|
|
if (VF_WormsForceOff()
|
|
|| (Slot.Definition != nullptr && !Slot.Definition->bEnableWorms))
|
|
{
|
|
Result.WormStrength = 0.0f;
|
|
}
|
|
Result = VF_ApplyRuntimeRoughnessOverrides(Result);
|
|
if (VoxelDensityAblation::IsCaveWarpOff())
|
|
{
|
|
Result.CaveWarpStrength = 0.0f;
|
|
}
|
|
return Result;
|
|
};
|
|
|
|
#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 ResolveFinalParams(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 ResolveFinalParams(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 TransitionBlendChunks. 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.
|
|
// TransitionBlendChunks 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 == TransitionBlendChunks, 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.
|
|
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 (EffectiveBlend > 0 && 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 ResolveFinalParams(
|
|
FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case EVoxelStrateTransition::Interleaved:
|
|
{
|
|
if (EffectiveBlend <= 0) { break; }
|
|
|
|
// 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 ResolveFinalParams(
|
|
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 (EffectiveBlend > 0 && 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 ResolveFinalParams(
|
|
FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case EVoxelStrateTransition::Interleaved:
|
|
{
|
|
if (EffectiveBlend <= 0) { break; }
|
|
|
|
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 ResolveFinalParams(
|
|
FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Not near any boundary (or Hard transition) — use this strate's params directly
|
|
return ResolveFinalParams(BaseParams);
|
|
}
|
|
|
|
//=============================================================================
|
|
// BUILD PARAMS FROM DEFINITION
|
|
//=============================================================================
|
|
// Returns the definition's base GenerationParams (cave shape, SDF, roughness, etc.).
|
|
//
|
|
// NOTE: Terrain op fields (TerraceStepHeight, ColumnDensity, etc.) are not
|
|
// 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. Resolve the asset
|
|
// switch here as well as at the final return so a disabled neighbour cannot leak worm strength
|
|
// back through a boundary blend.
|
|
FStrateGenerationParams Result = Definition->GenerationParams;
|
|
if (VF_WormsForceOff() || !Definition->bEnableWorms)
|
|
{
|
|
Result.WormStrength = 0.0f;
|
|
}
|
|
return Result;
|
|
}
|
|
|