Rewrite comments that told the story of past rounds instead of the current code: dated "fixed on / tried on / reverted on" notes, "this used to...", "stale paragraph removed", "Phase 1/2" progress labels, and the "ClassifyTile still does not use ClassifyBox" claim (it does now, via VF_ClassifyBoxWithWarpRetry). Every invariant, unit, sign convention, trap and measurement that justified the design is kept, rephrased in the present tense and in its original language (French or English). Also fix comments that named symbols removed in the dead-code commit (EvaluateSDF wrapper, FTunnelCoreWorldEvaluation support-floor fields, BlendChunks -> TransitionBlendChunks) and the stale function name in the VF_RollStrateParamsDetailed determinism checkf message. Comment and message text only; no code token changed. Tests untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URDGLsu7c4nunqNFPJEFPU
6926 lines
300 KiB
C++
6926 lines
300 KiB
C++
// VoxelStrateManager.cpp
|
|
// Runtime strate layout generation and queries.
|
|
|
|
#include "VoxelStrateManager.h"
|
|
#include "CoreGlobals.h" // GIsAutomationTesting — the opt-in diagnostic stays quiet under tests
|
|
#include "VoxelSettings.h"
|
|
#include "VoxelSeasonAsset.h"
|
|
#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord
|
|
#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash
|
|
#include "VoxelDensityPrimitives.h" // Shared passage carve polarity/strength
|
|
#include "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->bUseOperatorStack = SeasonStrate.bUsesRecipe;
|
|
Slot.Definition->StrateHeightInChunks = SeasonStrate.HeightInChunks;
|
|
Slot.Definition->GenerationParams = SeasonStrate.Params.TunnelNetworkParams;
|
|
Slot.Definition->SlabParams = SeasonStrate.Params.SlabParams;
|
|
Slot.Definition->MazeParams = SeasonStrate.Params.MazeParams;
|
|
Slot.Definition->SurfaceParams = SeasonStrate.Params.SurfaceParams;
|
|
Slot.Definition->VerticalShaftParams = SeasonStrate.Params.VerticalShaftParams;
|
|
Slot.Definition->FloatingIslandParams = SeasonStrate.Params.FloatingIslandParams;
|
|
|
|
Slot.HeightInChunks = SeasonStrate.HeightInChunks;
|
|
Slot.TopChunkZ = SeasonStrate.TopWorldZ / CHUNK_SIZE - 1;
|
|
Slot.BottomChunkZ = SeasonStrate.BottomWorldZ / CHUNK_SIZE;
|
|
if (SeasonStrate.TopWorldZ % CHUNK_SIZE != 0
|
|
|| SeasonStrate.BottomWorldZ % CHUNK_SIZE != 0
|
|
|| Slot.TopChunkZ != CurrentTopZ
|
|
|| Slot.BottomChunkZ != CurrentTopZ - (Slot.HeightInChunks - 1))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i);
|
|
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,\"operator_stack\":%d"),
|
|
Slot.StrateIndex, *DefinitionPath, *ArchetypeName,
|
|
Slot.TopChunkZ, Slot.BottomChunkZ, Slot.HeightInChunks,
|
|
Slot.Definition->bUseOperatorStack ? 1 : 0));
|
|
}
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Strate %d: '%s' | Z chunks [%d to %d] | %d chunks tall"),
|
|
i,
|
|
*Slot.Definition->StrateName.ToString(),
|
|
Slot.TopChunkZ,
|
|
Slot.BottomChunkZ,
|
|
Slot.HeightInChunks);
|
|
}
|
|
|
|
// Diagnostic de configuration, une seule fois par construction de layout. SurfaceWorld est
|
|
// volontairement exclu : son chemin T1.d exact-lattice ne dépend pas de ce drapeau.
|
|
// Configuration diagnostic once per layout build. SurfaceWorld is deliberately excluded:
|
|
// its exact-lattice T1.d path does not depend on this flag.
|
|
int32 NumCaveSlots = 0;
|
|
int32 NumOperatorStackDisabledCaves = 0;
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
const ECaveGeneratorType SlotArchetype = bUseSeason
|
|
? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType;
|
|
if (!Slot.Definition || SlotArchetype == ECaveGeneratorType::SurfaceWorld)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
++NumCaveSlots;
|
|
const bool bUsesStack = bUseSeason
|
|
? SeasonStrates[Slot.StrateIndex].bUsesRecipe : Slot.Definition->bUseOperatorStack;
|
|
if (!bUsesStack)
|
|
{
|
|
++NumOperatorStackDisabledCaves;
|
|
}
|
|
}
|
|
|
|
// ⚠️ WARNING EN ÉDITEUR/JEU, JAMAIS EN TEST. Les tests `Determinism.*` construisent
|
|
// DÉLIBÉRÉMENT un monde non opt-in — c'est leur oracle de comparaison — et le framework
|
|
// d'automatisation compte un Warning comme un échec. Un diagnostic ne doit pas casser la suite
|
|
// qu'il est censé éclairer. Le message reste écrit UNE fois : seule la verbosité change.
|
|
// Warning in editor/game where it is actionable, never in tests: the Determinism.* tests build
|
|
// a non-opted-in world ON PURPOSE as their comparison oracle, and the automation framework
|
|
// treats a Warning as a failure. One message, two verbosities.
|
|
const bool bQuietDiagnostic = GIsAutomationTesting;
|
|
|
|
if (NumOperatorStackDisabledCaves > 0)
|
|
{
|
|
const FString Summary = FString::Printf(
|
|
TEXT("[StrateManager] Operator-stack opt-in: %d/%d cave layout slots have Use Operator Stack disabled. These slots cannot use operator-stack ClassifyBox/T1.d; enable the asset setting on the listed definitions if that is intended."),
|
|
NumOperatorStackDisabledCaves, NumCaveSlots);
|
|
|
|
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Summary); }
|
|
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Summary); }
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogTemp, Log,
|
|
TEXT("[StrateManager] Operator-stack opt-in: all %d cave layout slots have Use Operator Stack enabled."),
|
|
NumCaveSlots);
|
|
}
|
|
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
const bool bSeasonStack = bUseSeason
|
|
&& SeasonStrates[Slot.StrateIndex].bUsesRecipe;
|
|
const ECaveGeneratorType SlotArchetype = bUseSeason
|
|
? SeasonStrates[Slot.StrateIndex].Archetype : Slot.Definition->GeneratorType;
|
|
if (!Slot.Definition
|
|
|| SlotArchetype == ECaveGeneratorType::SurfaceWorld
|
|
|| (bUseSeason ? bSeasonStack : Slot.Definition->bUseOperatorStack))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FString Line = FString::Printf(
|
|
TEXT("[StrateManager] cave slot=%d name='%s' Z chunks=[%d to %d] bUseOperatorStack=false"),
|
|
Slot.StrateIndex,
|
|
*Slot.Definition->StrateName.ToString(),
|
|
Slot.TopChunkZ,
|
|
Slot.BottomChunkZ);
|
|
|
|
if (bQuietDiagnostic) { UE_LOG(LogTemp, Verbose, TEXT("%s"), *Line); }
|
|
else { UE_LOG(LogTemp, Warning, TEXT("%s"), *Line); }
|
|
}
|
|
|
|
CachedSeed = WorldSeed;
|
|
bOpenSurfaceEntry = Settings->bOpenSurfaceEntry;
|
|
OriginSpineRadius = bUseSeason
|
|
? SeasonManifest.OriginSpineRadius : Settings->OriginSpineRadius;
|
|
InterStrateGapChunks = LayoutGapChunks;
|
|
if (bUseSeason)
|
|
{
|
|
ActiveSeasonContentHash = SeasonManifest.ContentHash;
|
|
}
|
|
// Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig).
|
|
|
|
//=========================================================================
|
|
// STEP 3: Load terrain operation assets
|
|
//=========================================================================
|
|
// Each strate definition references terrain ops as soft pointers.
|
|
// We load them synchronously here so they're available during generation.
|
|
// Without this, BuildParamsFromDefinition's Entry.Operation.Get() would
|
|
// return null if the assets haven't been loaded yet.
|
|
for (const FStrateSlot& Slot : StrateLayout)
|
|
{
|
|
if (!Slot.Definition) continue;
|
|
if (bUseSeason && SeasonStrates[Slot.StrateIndex].bUsesRecipe) continue;
|
|
|
|
for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations)
|
|
{
|
|
if (!Entry.Operation.IsNull())
|
|
{
|
|
Entry.Operation.LoadSynchronous();
|
|
}
|
|
}
|
|
}
|
|
|
|
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Initialized %d strates (seed=%d)"),
|
|
StrateLayout.Num(), WorldSeed);
|
|
|
|
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 - 1.0f, 0.0f);
|
|
const float LowerDoorOffset = FMath::Max(
|
|
Passage.LowerLanding.HalfWidth - 1.0f, 0.0f);
|
|
const float SafeMouthRadius = FMath::Max(
|
|
FMath::Abs(Cfg.MouthRadius), 1.0f);
|
|
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, 1.0f);
|
|
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 - 1.0f, 0.0f)
|
|
&& FMath::Abs(Position.Y - Landing.StandingPoint.Y)
|
|
<= FMath::Max(Landing.HalfWidth - 1.0f, 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 = 1.5f;
|
|
const float Height = Landing->CeilingZ - Landing->FloorZ;
|
|
const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, 0.25f);
|
|
const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, 0.25f);
|
|
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 = 1.5f;
|
|
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, 0.25f),
|
|
FMath::Max(Landing.HalfWidth - RoomRounding, 0.25f),
|
|
FMath::Max(Height * 0.5f - RoomRounding, 0.25f));
|
|
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 - 1.0f, 0.0f)
|
|
- ReachScale,
|
|
Landing->StandingPoint.X + FMath::Max(Landing->HalfWidth - 1.0f, 0.0f)
|
|
+ ReachScale,
|
|
Landing->StandingPoint.Y - FMath::Max(Landing->HalfWidth - 1.0f, 0.0f)
|
|
- ReachScale,
|
|
Landing->StandingPoint.Y + FMath::Max(Landing->HalfWidth - 1.0f, 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 = 1.5f;
|
|
const float Height = Landing->CeilingZ - Landing->FloorZ;
|
|
const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, 0.25f);
|
|
const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, 0.25f);
|
|
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 = 1.5f;
|
|
const float Height = Landing->CeilingZ - Landing->FloorZ;
|
|
const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, 0.25f);
|
|
const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, 0.25f);
|
|
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 - 1.0f, 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 - 1.0f, 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; }
|
|
|
|
if (SeasonStrates.IsValidIndex(SlotIdx))
|
|
{
|
|
// A recipe is an explicit stack opt-in. Native fixed entries retain the authored/switch
|
|
// route while still reading their season parameter vector.
|
|
return SeasonStrates[SlotIdx].bUsesRecipe;
|
|
}
|
|
|
|
#if WITH_EDITOR
|
|
if (FindComposerOverride(StrateLayout[SlotIdx].StrateIndex) != nullptr)
|
|
{
|
|
// Temporary composer candidates are measured through the stack path. Do not inherit an
|
|
// unrelated asset flag from the slot being replaced.
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
|
|
if (!Def->bUseOperatorStack) { return false; }
|
|
|
|
// LA LISTE DES ARCHÉTYPES PORTÉS — le seul endroit où elle est écrite. Un archétype non porté
|
|
// ignore le drapeau et retombe sur le `switch`, pour qu'on puisse cocher la case sur n'importe
|
|
// quelle strate sans rien casser en attendant son portage.
|
|
// THE PORTED-ARCHETYPE LIST, written down exactly once. An unported archetype ignores the flag
|
|
// and falls back to the switch, so the box can be ticked anywhere without breaking anything.
|
|
switch (Def->GeneratorType)
|
|
{
|
|
case ECaveGeneratorType::Maze: return true;
|
|
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. `GetDensityAt` les met dans le même `case`, et `WaterLevelRelative` n'est lu
|
|
// que par `GetWaterLevel*` de ce manager — jamais par la densité (vérifié, pas supposé).
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
// 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.
|
|
//
|
|
// **8 SUR 8.** Le `switch` d'archétypes a un jumeau en pile d'opérateurs, opt-in par
|
|
// strate, chacun vérifié par un test d'équivalence bit à bit contre sa fonction d'origine.
|
|
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;
|
|
}
|
|
|