From 05f62cff3669463d2e145ef56fe979d48d770ade Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 2 Oct 2026 12:14:04 +0200 Subject: [PATCH] standalone: the owner's world outside Unreal -- bit-identical density, D3D11 fly-through viewer Standalone/ (C++20, MSVC /O2 /arch:AVX2 /fp:precise, Build.ps1): copied density pipeline, op stack, cave morphology, passages, strate manager/composer, noise, DetSinCos, mesher, clipmap selection, with a UE compatibility layer (Compat/). Owner data exported once to Standalone/data/owner_world.json by a default-off explore-commandlet exporter (the only plugin-source change). 25 cm voxels. Parity: seed 0, 3,436 samples (strate edges, negative/far coords, rooms, tunnels, passages, landings): bit-identical. Viewer: full 7-level clipmap, per-level toggles 1-7, T = round winding tunnels (standalone option), N/P seed, F wireframe. Timing NOT yet a baseline: 515 s for 12,054 tiles (L0 70 s, L6 296 s); the compat TMap/TSet are vector-backed with linear lookup, which the plugin's caches do not expect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- .gitignore | 6 + .../Private/VoxelForgeExploreCommandlet.cpp | 545 + Standalone/Build.ps1 | 59 + Standalone/Compat/AssetRegistry/AssetData.h | 1 + .../AssetRegistry/AssetRegistryModule.h | 1 + Standalone/Compat/Async/ParallelFor.h | 1 + Standalone/Compat/CoreMinimal.h | 362 + Standalone/Compat/Dom/JsonObject.h | 1 + Standalone/Compat/Dom/JsonValue.h | 1 + Standalone/Compat/Engine/DataAsset.h | 1 + Standalone/Compat/GameplayTagContainer.h | 1 + Standalone/Compat/HAL/CriticalSection.h | 1 + Standalone/Compat/HAL/Event.h | 1 + Standalone/Compat/HAL/FileManager.h | 1 + Standalone/Compat/HAL/IConsoleManager.h | 1 + Standalone/Compat/HAL/PlatformProcess.h | 1 + Standalone/Compat/HAL/PlatformTime.h | 1 + Standalone/Compat/Json.h | 154 + Standalone/Compat/Math/RandomStream.h | 1 + Standalone/Compat/Misc/CommandLine.h | 1 + Standalone/Compat/Misc/Crc.h | 1 + Standalone/Compat/Misc/FileHelper.h | 1 + Standalone/Compat/Misc/Parse.h | 1 + Standalone/Compat/Misc/Paths.h | 1 + Standalone/Compat/Misc/ScopeLock.h | 1 + Standalone/Compat/Misc/ScopeRWLock.h | 1 + Standalone/Compat/Misc/SecureHash.h | 1 + Standalone/Compat/Modules/ModuleManager.h | 1 + .../ProfilingDebugging/CpuProfilerTrace.h | 1 + .../Compat/Serialization/CustomVersion.h | 1 + .../Compat/Serialization/JsonSerializer.h | 1 + Standalone/Compat/Serialization/JsonWriter.h | 1 + Standalone/Compat/StandaloneDataAssetGlue.cpp | 32 + Standalone/Compat/Stats/Stats.h | 10 + Standalone/Compat/Templates/SubclassOf.h | 1 + Standalone/Compat/Templates/UniquePtr.h | 1 + Standalone/Compat/UObject/EnumProperty.h | 1 + Standalone/Compat/UObject/FieldIterator.h | 1 + Standalone/Compat/UObject/Object.h | 1 + Standalone/Compat/UObject/UObjectGlobals.h | 1 + Standalone/Compat/UObject/UnrealType.h | 1 + Standalone/Compat/UnrealPerlin.cpp | 64 + Standalone/Compat/UnrealPerlinHelpers.inl | 82 + .../Compat/VoxelBiomeDefinition.generated.h | 1 + Standalone/Compat/VoxelBiomeTypes.generated.h | 1 + Standalone/Compat/VoxelDiffLayer.generated.h | 1 + Standalone/Compat/VoxelGenerator.generated.h | 1 + .../VoxelMarchingCubesMesher.generated.h | 1 + .../Compat/VoxelSeasonAsset.generated.h | 1 + Standalone/Compat/VoxelSettings.generated.h | 1 + .../Compat/VoxelStrateComposer.generated.h | 1 + .../Compat/VoxelStrateDefinition.generated.h | 1 + .../Compat/VoxelStrateManager.generated.h | 1 + .../Compat/VoxelStrateTypes.generated.h | 1 + .../VoxelTerrainOpDefinition.generated.h | 1 + Standalone/README.md | 100 + Standalone/Source/OwnerWorldLoader.h | 323 + Standalone/Source/StandaloneResearchTunnels.h | 128 + Standalone/Source/StandaloneViewer.cpp | 492 + .../Private/VoxelBiomeDefinition.cpp | 5 + .../Private/VoxelCaveMorphology.cpp | 9779 +++++++++++++++ .../Private/VoxelClipmapDesiredTiles.h | 152 + .../Private/VoxelDensityAblation.cpp | 131 + .../Private/VoxelDensityOpStack.cpp | 10430 ++++++++++++++++ .../Private/VoxelDensityProfile.cpp | 994 ++ .../VoxelForge/Private/VoxelDiffLayer.cpp | 469 + .../VoxelForge/Private/VoxelForgeModule.cpp | 54 + .../VoxelForge/Private/VoxelGenerator.cpp | 7659 ++++++++++++ .../VoxelForge/Private/VoxelHeightOpStack.cpp | 497 + .../Private/VoxelMarchingCubesMesher.cpp | 1315 ++ .../VoxelForge/Private/VoxelSeasonAsset.cpp | 135 + .../Private/VoxelSeasonManifest.cpp | 3177 +++++ .../VoxelForge/Private/VoxelSettings.cpp | 51 + .../Source/VoxelForge/Private/VoxelStats.cpp | 23 + .../Private/VoxelStrateComposer.cpp | 5787 +++++++++ .../Private/VoxelStrateDefinition.cpp | 79 + .../VoxelForge/Private/VoxelStrateManager.cpp | 6850 ++++++++++ .../VoxelForge/Private/VoxelStrateMeasure.cpp | 3842 ++++++ .../Private/VoxelTerrainOpDefinition.cpp | 87 + .../VoxelForge/Private/VoxelWormField.cpp | 88 + .../VoxelForge/Public/MarchingCubesTables.h | 344 + .../VoxelForge/Public/VoxelBiomeDefinition.h | 159 + .../VoxelForge/Public/VoxelBiomeTypes.h | 264 + .../VoxelForge/Public/VoxelCaveMorphology.h | 1196 ++ .../VoxelForge/Public/VoxelDensityAblation.h | 60 + .../Source/VoxelForge/Public/VoxelDensityOp.h | 934 ++ .../VoxelForge/Public/VoxelDensityOpStack.h | 730 ++ .../Public/VoxelDensityPrimitives.h | 382 + .../VoxelForge/Public/VoxelDensityProfile.h | 459 + .../Source/VoxelForge/Public/VoxelDiffLayer.h | 315 + .../Source/VoxelForge/Public/VoxelGenerator.h | 479 + .../Source/VoxelForge/Public/VoxelHeightOp.h | 273 + .../Public/VoxelMarchingCubesMesher.h | 144 + .../Source/VoxelForge/Public/VoxelNoise.h | 438 + .../VoxelForge/Public/VoxelPassageGeometry.h | 711 ++ .../Public/VoxelRedefinedDensityField.h | 195 + .../VoxelForge/Public/VoxelSeasonAsset.h | 71 + .../VoxelForge/Public/VoxelSeasonManifest.h | 299 + .../Source/VoxelForge/Public/VoxelSettings.h | 436 + .../VoxelForge/Public/VoxelStartupTrace.h | 65 + .../Source/VoxelForge/Public/VoxelStats.h | 27 + .../VoxelForge/Public/VoxelStrateComposer.h | 821 ++ .../VoxelForge/Public/VoxelStrateDefinition.h | 394 + .../VoxelForge/Public/VoxelStrateManager.h | 556 + .../VoxelForge/Public/VoxelStrateMeasure.h | 540 + .../VoxelForge/Public/VoxelStrateTypes.h | 2442 ++++ .../Public/VoxelTerrainOpDefinition.h | 350 + .../VoxelForge/Public/VoxelTilePostReach.h | 414 + .../Source/VoxelForge/Public/VoxelTypes.h | 401 + .../Source/VoxelForge/Public/VoxelWormField.h | 26 + Standalone/Source/WorldMesher.h | 179 + Standalone/Source/parity_main.cpp | 56 + Standalone/data/owner_world.json | 1 + Tools/VoxelForgeTest.ps1 | 41 +- 114 files changed, 68206 insertions(+), 3 deletions(-) create mode 100644 Standalone/Build.ps1 create mode 100644 Standalone/Compat/AssetRegistry/AssetData.h create mode 100644 Standalone/Compat/AssetRegistry/AssetRegistryModule.h create mode 100644 Standalone/Compat/Async/ParallelFor.h create mode 100644 Standalone/Compat/CoreMinimal.h create mode 100644 Standalone/Compat/Dom/JsonObject.h create mode 100644 Standalone/Compat/Dom/JsonValue.h create mode 100644 Standalone/Compat/Engine/DataAsset.h create mode 100644 Standalone/Compat/GameplayTagContainer.h create mode 100644 Standalone/Compat/HAL/CriticalSection.h create mode 100644 Standalone/Compat/HAL/Event.h create mode 100644 Standalone/Compat/HAL/FileManager.h create mode 100644 Standalone/Compat/HAL/IConsoleManager.h create mode 100644 Standalone/Compat/HAL/PlatformProcess.h create mode 100644 Standalone/Compat/HAL/PlatformTime.h create mode 100644 Standalone/Compat/Json.h create mode 100644 Standalone/Compat/Math/RandomStream.h create mode 100644 Standalone/Compat/Misc/CommandLine.h create mode 100644 Standalone/Compat/Misc/Crc.h create mode 100644 Standalone/Compat/Misc/FileHelper.h create mode 100644 Standalone/Compat/Misc/Parse.h create mode 100644 Standalone/Compat/Misc/Paths.h create mode 100644 Standalone/Compat/Misc/ScopeLock.h create mode 100644 Standalone/Compat/Misc/ScopeRWLock.h create mode 100644 Standalone/Compat/Misc/SecureHash.h create mode 100644 Standalone/Compat/Modules/ModuleManager.h create mode 100644 Standalone/Compat/ProfilingDebugging/CpuProfilerTrace.h create mode 100644 Standalone/Compat/Serialization/CustomVersion.h create mode 100644 Standalone/Compat/Serialization/JsonSerializer.h create mode 100644 Standalone/Compat/Serialization/JsonWriter.h create mode 100644 Standalone/Compat/StandaloneDataAssetGlue.cpp create mode 100644 Standalone/Compat/Stats/Stats.h create mode 100644 Standalone/Compat/Templates/SubclassOf.h create mode 100644 Standalone/Compat/Templates/UniquePtr.h create mode 100644 Standalone/Compat/UObject/EnumProperty.h create mode 100644 Standalone/Compat/UObject/FieldIterator.h create mode 100644 Standalone/Compat/UObject/Object.h create mode 100644 Standalone/Compat/UObject/UObjectGlobals.h create mode 100644 Standalone/Compat/UObject/UnrealType.h create mode 100644 Standalone/Compat/UnrealPerlin.cpp create mode 100644 Standalone/Compat/UnrealPerlinHelpers.inl create mode 100644 Standalone/Compat/VoxelBiomeDefinition.generated.h create mode 100644 Standalone/Compat/VoxelBiomeTypes.generated.h create mode 100644 Standalone/Compat/VoxelDiffLayer.generated.h create mode 100644 Standalone/Compat/VoxelGenerator.generated.h create mode 100644 Standalone/Compat/VoxelMarchingCubesMesher.generated.h create mode 100644 Standalone/Compat/VoxelSeasonAsset.generated.h create mode 100644 Standalone/Compat/VoxelSettings.generated.h create mode 100644 Standalone/Compat/VoxelStrateComposer.generated.h create mode 100644 Standalone/Compat/VoxelStrateDefinition.generated.h create mode 100644 Standalone/Compat/VoxelStrateManager.generated.h create mode 100644 Standalone/Compat/VoxelStrateTypes.generated.h create mode 100644 Standalone/Compat/VoxelTerrainOpDefinition.generated.h create mode 100644 Standalone/README.md create mode 100644 Standalone/Source/OwnerWorldLoader.h create mode 100644 Standalone/Source/StandaloneResearchTunnels.h create mode 100644 Standalone/Source/StandaloneViewer.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelBiomeDefinition.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h create mode 100644 Standalone/Source/VoxelForge/Private/VoxelDensityAblation.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelDensityProfile.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelDiffLayer.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelForgeModule.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelHeightOpStack.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelSeasonAsset.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelSeasonManifest.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelSettings.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelStats.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelStrateComposer.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelStrateDefinition.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelStrateMeasure.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp create mode 100644 Standalone/Source/VoxelForge/Private/VoxelWormField.cpp create mode 100644 Standalone/Source/VoxelForge/Public/MarchingCubesTables.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelBiomeDefinition.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelBiomeTypes.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelCaveMorphology.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelDensityAblation.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelDensityOp.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelDensityOpStack.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelDensityPrimitives.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelDensityProfile.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelDiffLayer.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelGenerator.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelHeightOp.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelNoise.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelPassageGeometry.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelRedefinedDensityField.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelSeasonAsset.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelSeasonManifest.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelSettings.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStartupTrace.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStats.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStrateComposer.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStrateDefinition.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStrateManager.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStrateMeasure.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelStrateTypes.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelTilePostReach.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelTypes.h create mode 100644 Standalone/Source/VoxelForge/Public/VoxelWormField.h create mode 100644 Standalone/Source/WorldMesher.h create mode 100644 Standalone/Source/parity_main.cpp create mode 100644 Standalone/data/owner_world.json diff --git a/.gitignore b/.gitignore index 4c5a6e0..6def839 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ !/Source/ !/Source/** +# Portable CPU generator and D3D11 viewer. +!/Standalone/ +!/Standalone/** +/Standalone/Build/ +/Standalone/bin/ + # The guarded single-entry test harness is source, not generated output. !/Tools/ !/Tools/** diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 90f7258..b70fcc7 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -25,6 +25,8 @@ #include "Serialization/JsonWriter.h" #include "UObject/Package.h" #include "UObject/StrongObjectPtr.h" +#include "UObject/UnrealType.h" +#include "UObject/TextProperty.h" #include "UObject/UObjectGlobals.h" #include "VoxelCaveMorphology.h" #include "VoxelDiffLayer.h" @@ -38,11 +40,13 @@ #include "VoxelNoise.h" #include "VoxelPassageGeometry.h" #include "VoxelSettings.h" +#include "VoxelSeasonAsset.h" #include "VoxelStackSampler.h" #include "VoxelStrateDefinition.h" #include "VoxelStrateManager.h" #include "VoxelStrateMeasure.h" #include "VoxelStrateComposer.h" +#include "VoxelTerrainOpDefinition.h" #include "VoxelTypes.h" #include @@ -379,6 +383,7 @@ struct FExploreArguments float RoomMouthRiseBlend = 0.0f; bool bPassageFloorWritesOverride = false; int32 PassageFloorWrites = 1; + bool bStandaloneWorldExport = false; bool bRenderCameraOverride = false; FVector RenderCamera = FVector::ZeroVector; bool bRenderTargetOverride = false; @@ -418,6 +423,9 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr FString OutText; OutArguments.bSeedOverride = FParse::Value(*Params, TEXT("seed="), OutArguments.Seed); + int32 StandaloneWorldExport = 0; + FParse::Value(*Params, TEXT("standaloneworldexport="), StandaloneWorldExport); + OutArguments.bStandaloneWorldExport = StandaloneWorldExport != 0; FParse::Value(*Params, TEXT("archetype="), ArchetypeText); FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); FParse::Value(*Params, TEXT("strateref="), OutArguments.StrateReference); @@ -939,6 +947,13 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr "densityslice must be origin x,y,z, direction x,y,z, sample count, and positive step."); return false; } + if (OutArguments.bStandaloneWorldExport + && (!OutArguments.bExport || OutArguments.SettingsReference.IsEmpty() + || !OutArguments.StrateReference.IsEmpty())) + { + OutError = TEXT("standaloneworldexport requires modes=export and settingsref, and must omit strateref."); + return false; + } if (OutText.IsEmpty()) { @@ -1572,6 +1587,7 @@ bool PrepareRegionEvalWorld( { return false; } + if (!World.Definitions.IsValidIndex(Arguments.Slot)) { OutError = TEXT("Region evaluator target definition is missing."); @@ -12196,6 +12212,525 @@ bool RunOpBounds( return true; } +TSharedPtr StandaloneSerializeStruct( + const UStruct* Type, const void* Data, TSet& ActiveObjects, + const UObject* Object = nullptr); + +TSharedPtr StandaloneSerializeProperty( + FProperty* Property, const void* Data, TSet& ActiveObjects) +{ + if (Property == nullptr || Data == nullptr) return MakeShared(); + if (const FBoolProperty* Bool = CastField(Property)) + { + return MakeShared(Bool->GetPropertyValue(Data)); + } + if (const FEnumProperty* Enum = CastField(Property)) + { + return MakeShared(static_cast( + Enum->GetUnderlyingProperty()->GetSignedIntPropertyValue(Data))); + } + if (const FNumericProperty* Numeric = CastField(Property)) + { + const double Value = Numeric->IsFloatingPoint() + ? Numeric->GetFloatingPointPropertyValue(Data) + : static_cast(Numeric->GetSignedIntPropertyValue(Data)); + return MakeShared(Value); + } + if (const FStrProperty* String = CastField(Property)) + { + return MakeShared(String->GetPropertyValue(Data)); + } + if (const FNameProperty* Name = CastField(Property)) + { + return MakeShared(Name->GetPropertyValue(Data).ToString()); + } + if (const FTextProperty* Text = CastField(Property)) + { + return MakeShared(Text->GetPropertyValue(Data).ToString()); + } + if (const FSoftObjectProperty* Soft = CastField(Property)) + { + return MakeShared( + Soft->GetPropertyValue(Data).ToSoftObjectPath().ToString()); + } + if (const FArrayProperty* Array = CastField(Property)) + { + TArray> Values; + FScriptArrayHelper Helper(Array, Data); + Values.Reserve(Helper.Num()); + for (int32 Index = 0; Index < Helper.Num(); ++Index) + { + Values.Add(StandaloneSerializeProperty( + Array->Inner, Helper.GetRawPtr(Index), ActiveObjects)); + } + return MakeShared(MoveTemp(Values)); + } + if (const FSetProperty* Set = CastField(Property)) + { + TArray> Values; + FScriptSetHelper Helper(Set, Data); + for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index) + { + if (Helper.IsValidIndex(Index)) + { + Values.Add(StandaloneSerializeProperty( + Set->ElementProp, Helper.GetElementPtr(Index), ActiveObjects)); + } + } + return MakeShared(MoveTemp(Values)); + } + if (const FMapProperty* Map = CastField(Property)) + { + TArray> Values; + FScriptMapHelper Helper(Map, Data); + for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index) + { + if (!Helper.IsValidIndex(Index)) continue; + TSharedRef Pair = MakeShared(); + Pair->SetField(TEXT("key"), StandaloneSerializeProperty( + Map->KeyProp, Helper.GetKeyPtr(Index), ActiveObjects)); + Pair->SetField(TEXT("value"), StandaloneSerializeProperty( + Map->ValueProp, Helper.GetValuePtr(Index), ActiveObjects)); + Values.Add(MakeShared(Pair)); + } + return MakeShared(MoveTemp(Values)); + } + if (const FStructProperty* Struct = CastField(Property)) + { + return StandaloneSerializeStruct(Struct->Struct, Data, ActiveObjects); + } + if (const FObjectPropertyBase* ObjectProperty = CastField(Property)) + { + const UObject* Object = ObjectProperty->GetObjectPropertyValue(Data); + if (!Object) return MakeShared(); + if (Object->GetClass()->GetName().StartsWith(TEXT("Voxel"), ESearchCase::CaseSensitive)) + { + return StandaloneSerializeStruct(Object->GetClass(), Object, ActiveObjects, Object); + } + return MakeShared(Object->GetPathName()); + } + return MakeShared(); +} + +TSharedPtr StandaloneSerializeStruct( + const UStruct* Type, const void* Data, TSet& ActiveObjects, + const UObject* Object) +{ + if (Type == nullptr || Data == nullptr) return MakeShared(); + if (Object && ActiveObjects.Contains(Object)) + { + return MakeShared(Object->GetPathName()); + } + if (Object) ActiveObjects.Add(Object); + + TSharedRef Result = MakeShared(); + for (TFieldIterator It(Type); It; ++It) + { + FProperty* Property = *It; + Result->SetField(Property->GetName(), StandaloneSerializeProperty( + Property, Property->ContainerPtrToValuePtr(const_cast(Data)), + ActiveObjects)); + } + if (Object) ActiveObjects.Remove(Object); + return MakeShared(Result); +} + +template +void StandaloneWriteJsonValue( + TWriter& Writer, const TCHAR* Identifier, const TSharedPtr& Value) +{ + if (Identifier) Writer.WriteIdentifierPrefix(Identifier); + if (!Value.IsValid() || Value->Type == EJson::Null) + { + Writer.WriteNull(); + } + else if (Value->Type == EJson::Boolean) + { + Writer.WriteValue(Value->AsBool()); + } + else if (Value->Type == EJson::Number) + { + Writer.WriteValue(Value->AsNumber()); + } + else if (Value->Type == EJson::String) + { + Writer.WriteValue(Value->AsString()); + } + else if (Value->Type == EJson::Array) + { + Writer.WriteArrayStart(); + for (const TSharedPtr& Element : Value->AsArray()) + { + StandaloneWriteJsonValue(Writer, nullptr, Element); + } + Writer.WriteArrayEnd(); + } + else if (Value->Type == EJson::Object) + { + Writer.WriteObjectStart(); + for (const TPair>& Field : Value->AsObject()->Values) + { + StandaloneWriteJsonValue(Writer, *Field.Key, Field.Value); + } + Writer.WriteObjectEnd(); + } + else + { + Writer.WriteNull(); + } +} + +TSharedRef StandaloneVectorJson(const FVector& Value) +{ + TSharedRef Result = MakeShared(); + Result->SetNumberField(TEXT("x"), Value.X); + Result->SetNumberField(TEXT("y"), Value.Y); + Result->SetNumberField(TEXT("z"), Value.Z); + return Result; +} + +TSharedRef StandaloneLandingJson(const FVoxelPassageLanding& Landing) +{ + TSharedRef Result = MakeShared(); + Result->SetObjectField(TEXT("standing_point"), StandaloneVectorJson(Landing.StandingPoint)); + Result->SetObjectField(TEXT("door_point"), StandaloneVectorJson(Landing.DoorPoint)); + Result->SetObjectField(TEXT("door_direction"), StandaloneVectorJson(Landing.DoorDirection)); + Result->SetNumberField(TEXT("floor_z"), Landing.FloorZ); + Result->SetNumberField(TEXT("ceiling_z"), Landing.CeilingZ); + Result->SetNumberField(TEXT("half_width"), Landing.HalfWidth); + Result->SetNumberField(TEXT("floor_thickness"), Landing.FloorThickness); + Result->SetBoolField(TEXT("source_player_fit"), Landing.bSourcePlayerFit); + return Result; +} + +TSharedRef StandalonePassageJson(const FVoxelPassage& Passage) +{ + TSharedRef Result = MakeShared(); + Result->SetNumberField(TEXT("upper_strate_index"), Passage.UpperStrateIndex); + Result->SetNumberField(TEXT("lower_strate_index"), Passage.LowerStrateIndex); + Result->SetObjectField(TEXT("upper_point"), StandaloneVectorJson(Passage.UpperPoint)); + Result->SetObjectField(TEXT("lower_point"), StandaloneVectorJson(Passage.LowerPoint)); + Result->SetObjectField(TEXT("requested_upper_point"), StandaloneVectorJson(Passage.RequestedUpperPoint)); + Result->SetObjectField(TEXT("requested_lower_point"), StandaloneVectorJson(Passage.RequestedLowerPoint)); + Result->SetNumberField(TEXT("radius"), Passage.Radius); + Result->SetNumberField(TEXT("passage_type"), static_cast(Passage.PassageType)); + auto WriteVectors = [](const TCHAR* Key, const TArray& Source, + TSharedRef Out) + { + TArray> Values; + Values.Reserve(Source.Num()); + for (const FVector& Value : Source) + { + Values.Add(MakeShared(StandaloneVectorJson(Value))); + } + Out->SetArrayField(Key, MoveTemp(Values)); + }; + auto WriteFloats = [](const TCHAR* Key, const TArray& Source, + TSharedRef Out) + { + TArray> Values; + Values.Reserve(Source.Num()); + for (float Value : Source) Values.Add(MakeShared(Value)); + Out->SetArrayField(Key, MoveTemp(Values)); + }; + WriteVectors(TEXT("control_points"), Passage.ControlPoints, Result); + WriteFloats(TEXT("control_radii"), Passage.ControlRadii, Result); + Result->SetObjectField(TEXT("upper_landing"), StandaloneLandingJson(Passage.UpperLanding)); + Result->SetObjectField(TEXT("lower_landing"), StandaloneLandingJson(Passage.LowerLanding)); + Result->SetBoolField(TEXT("walkable_tunnel_contract"), Passage.bWalkableTunnelContract); + Result->SetBoolField(TEXT("native_floor_enabled"), Passage.bNativeFloorEnabled); + WriteFloats(TEXT("native_floor_profile_z"), Passage.NativeFloorProfileZ, Result); + WriteFloats(TEXT("native_floor_relief_scales"), Passage.NativeFloorReliefScales, Result); + Result->SetNumberField(TEXT("native_floor_relief_strength"), Passage.NativeFloorReliefStrength); + Result->SetNumberField(TEXT("native_floor_relief_frequency"), Passage.NativeFloorReliefFrequency); + Result->SetNumberField(TEXT("native_floor_seed"), + static_cast(Passage.NativeFloorSeed)); + Result->SetNumberField(TEXT("tunnel_max_gradient_degrees"), Passage.TunnelMaxGradientDegrees); + Result->SetNumberField(TEXT("tunnel_vertical_drop_voxels"), Passage.TunnelVerticalDropVoxels); + Result->SetNumberField(TEXT("tunnel_required_horizontal_run_voxels"), Passage.TunnelRequiredHorizontalRunVoxels); + Result->SetNumberField(TEXT("tunnel_horizontal_path_length_voxels"), Passage.TunnelHorizontalPathLengthVoxels); + Result->SetNumberField(TEXT("tunnel_minimum_clear_width_voxels"), Passage.TunnelMinimumClearWidthVoxels); + Result->SetNumberField(TEXT("tunnel_clear_height_voxels"), Passage.TunnelClearHeightVoxels); + Result->SetObjectField(TEXT("bound_center"), StandaloneVectorJson(Passage.BoundCenter)); + Result->SetNumberField(TEXT("bound_radius"), Passage.BoundRadius); + Result->SetNumberField(TEXT("bound_radius_sq"), Passage.BoundRadiusSq); + return Result; +} + +struct FStandaloneExportSample +{ + FVector Position = FVector::ZeroVector; + FString Kind; + int32 StrateIndex = INDEX_NONE; + float Density = 0.0f; +}; + +bool RunStandaloneWorldExport(const FExploreArguments& Arguments, FString& OutError, FString* OutJson) +{ + UVoxelSettings* AuthoredSettings = LoadObject( + nullptr, *Arguments.SettingsReference); + if (!AuthoredSettings) + { + OutError = FString::Printf(TEXT("Could not load owner settings asset '%s'."), + *Arguments.SettingsReference); + return false; + } + UVoxelStrateManager* Manager = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + int32 Seed = AuthoredSettings->Seed; + float OriginSpineRadius = AuthoredSettings->OriginSpineRadius; + float WorldRadiusVoxels = AuthoredSettings->WorldRadiusVoxels; + if (const UVoxelSeasonAsset* Season = AuthoredSettings->Season.LoadSynchronous()) + { + Seed = Season->GetSeasonSeed(); + OriginSpineRadius = Season->GetOriginSpineRadius(); + WorldRadiusVoxels = Season->GetWorldRadiusVoxels(); + } + if (!Manager->Initialize(AuthoredSettings, Seed)) + { + OutError = TEXT("Owner settings did not produce a valid resolved strate layout."); + return false; + } + Seed = Manager->GetWorldSeed(); + UVoxelGenerator* Generator = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + Generator->InitializeSettings(AuthoredSettings); + Generator->SetStrateManager(Manager); + + TArray Samples; + auto AddSample = [&Samples](const FVector& Position, const TCHAR* Kind, int32 StrateIndex) + { + if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y) + || !VoxelMath::IsFinite(Position.Z)) return; + FStandaloneExportSample Sample; + Sample.Position = Position; + Sample.Kind = Kind; + Sample.StrateIndex = StrateIndex; + Samples.Add(MoveTemp(Sample)); + }; + + const int32 XYAnchors[] = { 0, -1, 1, -128, 128, -512, 512, -2048, 2048, -8192, 8192 }; + FRandomStream SampleRandom(Seed ^ 0x13579BDF); + FVector Camera = FVector::ZeroVector; + bool bCameraFound = false; + for (const FStrateSlot& Slot : Manager->GetLayout()) + { + const float TopZ = static_cast(Slot.TopChunkZ + 1) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float MidZ = (TopZ + BottomZ) * 0.5f; + const int32 MidChunkZ = FMath::FloorToInt(MidZ / CHUNK_SIZE); + for (int32 ZOffset : { -2, -1, 0, 1, 2 }) + { + for (int32 Y : XYAnchors) + for (int32 X : XYAnchors) + { + AddSample(FVector(X, Y, TopZ + ZOffset), TEXT("strate_boundary"), Slot.StrateIndex); + AddSample(FVector(X, Y, BottomZ + ZOffset), TEXT("strate_boundary"), Slot.StrateIndex); + } + } + for (int32 Index = 0; Index < 384; ++Index) + { + const float X = static_cast(SampleRandom.RandRange(-4096, 4096)); + const float Y = static_cast(SampleRandom.RandRange(-4096, 4096)); + const float Z = BottomZ + SampleRandom.RandRange(0, FMath::Max(1, FMath::FloorToInt(TopZ - BottomZ))); + AddSample(FVector(X, Y, Z), TEXT("varied_world"), Slot.StrateIndex); + } + + if (Slot.Definition) + { + const FStrateGenerationParams Params = Manager->GetGenerationParams( + FIntVector(0, 0, MidChunkZ)); + FChunkSDFCache Cache; + VoxelCaveMorphology::BuildChunkCache( + Cache, -384.0f, -384.0f, 384.0f, 384.0f, Params, + static_cast(Seed), Slot.StrateIndex, + &Slot.Definition->TerrainOperations); + for (const FCachedRoom& Room : Cache.Rooms) + { + const FVector Candidate = Room.Center; + const float Density = Generator->GetDensityAt( + Candidate.X, Candidate.Y, Candidate.Z); + if (Density >= 0.0f) + { + AddSample(Candidate, TEXT("room_interior"), Slot.StrateIndex); + if (Slot.StrateIndex == 0 && !bCameraFound + && Generator->GetDensityAt(Candidate.X, Candidate.Y, Candidate.Z + 8.0f) >= 0.0f + && Generator->GetDensityAt(Candidate.X, Candidate.Y, Candidate.Z + 16.0f) >= 0.0f) + { + Camera = Candidate; + bCameraFound = true; + } + } + } + for (const FCachedTunnel& Tunnel : Cache.Tunnels) + { + for (int32 PointIndex = 0; PointIndex < Tunnel.WorldControlPoints.Num(); ++PointIndex) + { + const FVector Candidate = Tunnel.WorldControlPoints[PointIndex]; + const float Density = Generator->GetDensityAt( + Candidate.X, Candidate.Y, Candidate.Z); + if (Density >= 0.0f) + { + AddSample(Candidate, TEXT("tunnel_interior"), Slot.StrateIndex); + } + } + } + } + + if (Slot.StrateIndex == 0) + { + // Prefer an open, body-height column in the first authored strate. This seed pose is + // exported to the standalone viewer; it uses only the owner's live base field. + for (int32 Radius = 0; Radius <= 128 && !bCameraFound; Radius += 8) + for (int32 Y = -Radius; Y <= Radius && !bCameraFound; Y += 8) + for (int32 X = -Radius; X <= Radius && !bCameraFound; X += 8) + { + const float Z = MidZ; + if (Generator->GetDensityAt(static_cast(X), static_cast(Y), Z) >= 0.0f + && Generator->GetDensityAt(static_cast(X), static_cast(Y), Z + 8.0f) >= 0.0f + && Generator->GetDensityAt(static_cast(X), static_cast(Y), Z + 16.0f) >= 0.0f) + { + Camera = FVector(X, Y, Z + 2.0f); + bCameraFound = true; + } + } + } + } + + for (const FVoxelPassage& Passage : Manager->GetPassages()) + { + for (const FVector& Point : Passage.ControlPoints) + { + AddSample(Point, TEXT("inter_strate_passage"), Passage.UpperStrateIndex); + } + AddSample(Passage.UpperLanding.StandingPoint, TEXT("passage_landing"), Passage.UpperStrateIndex); + AddSample(Passage.LowerLanding.StandingPoint, TEXT("passage_landing"), Passage.LowerStrateIndex); + } + + for (FStandaloneExportSample& Sample : Samples) + { + Sample.Density = Generator->GetDensityAt( + static_cast(Sample.Position.X), static_cast(Sample.Position.Y), + static_cast(Sample.Position.Z)); + } + + if (!bCameraFound) + { + OutError = TEXT("Could not locate open camera space in the owner's first strate."); + return false; + } + + FString Json; + TSharedRef Writer = + TJsonWriterFactory>::Create(&Json); + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("schema_version"), 1); + Writer->WriteValue(TEXT("source"), TEXT("VoxelForgeExplore standalone base-world export")); + Writer->WriteValue(TEXT("voxel_size_cm"), VOXEL_SIZE); + Writer->WriteValue(TEXT("seed"), Seed); + Writer->WriteValue(TEXT("open_surface_entry"), AuthoredSettings->bOpenSurfaceEntry); + Writer->WriteValue(TEXT("origin_spine_radius"), OriginSpineRadius); + Writer->WriteValue(TEXT("world_radius_voxels"), WorldRadiusVoxels); + Writer->WriteValue(TEXT("edge_seal_thickness"), AuthoredSettings->EdgeSealThickness); + Writer->WriteValue(TEXT("total_strates"), Manager->GetNumStrates()); + Writer->WriteValue(TEXT("inter_strate_gap_chunks"), AuthoredSettings->InterStrateGapChunks); + Writer->WriteValue(TEXT("clip_radius"), AuthoredSettings->ClipRadius); + Writer->WriteValue(TEXT("max_clip_level"), AuthoredSettings->MaxClipLevel); + Writer->WriteValue(TEXT("full_res_clip_levels"), AuthoredSettings->FullResClipLevels); + Writer->WriteValue(TEXT("coarse_tile_cells"), AuthoredSettings->CoarseTileCells); + Writer->WriteValue(TEXT("render_distance_chunks"), AuthoredSettings->RenderDistanceChunks); + Writer->WriteValue(TEXT("generate_skirts"), AuthoredSettings->bGenerateSkirts); + Writer->WriteValue(TEXT("skirt_cells"), AuthoredSettings->SkirtCells); + Writer->WriteValue(TEXT("lod_octave_drop"), AuthoredSettings->LODOctaveDrop); + Writer->WriteValue(TEXT("strate_content_cut_min_level"), AuthoredSettings->GetEffectiveStrateContentCutMinLevel()); + TSet ActiveObjects; + StandaloneWriteJsonValue(*Writer, TEXT("settings_asset"), + StandaloneSerializeStruct(AuthoredSettings->GetClass(), AuthoredSettings, + ActiveObjects, AuthoredSettings)); + + Writer->WriteArrayStart(TEXT("strates")); + for (const FStrateSlot& Slot : Manager->GetLayout()) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("index"), Slot.StrateIndex); + Writer->WriteValue(TEXT("top_chunk_z"), Slot.TopChunkZ); + Writer->WriteValue(TEXT("bottom_chunk_z"), Slot.BottomChunkZ); + Writer->WriteValue(TEXT("height_chunks"), Slot.HeightInChunks); + StandaloneWriteJsonValue(*Writer, TEXT("asset"), + StandaloneSerializeStruct(Slot.Definition->GetClass(), Slot.Definition, + ActiveObjects, Slot.Definition)); + StandaloneWriteJsonValue(*Writer, TEXT("generation_params"), + StandaloneSerializeStruct(FStrateGenerationParams::StaticStruct(), + &Slot.Definition->GenerationParams, ActiveObjects)); + StandaloneWriteJsonValue(*Writer, TEXT("disturbances"), + StandaloneSerializeStruct(FStrateDisturbanceParams::StaticStruct(), + &Slot.Definition->Disturbances, ActiveObjects)); + StandaloneWriteJsonValue(*Writer, TEXT("passage_config"), + StandaloneSerializeStruct(FStratePassageConfig::StaticStruct(), + &Slot.Definition->PassageConfig, ActiveObjects)); + Writer->WriteArrayStart(TEXT("terrain_operation_assets")); + for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("weight"), Entry.Weight); + Writer->WriteValue(TEXT("probability"), Entry.Probability); + Writer->WriteValue(TEXT("asset_path"), Entry.Operation.ToString()); + if (const UVoxelTerrainOpDefinition* Op = Entry.Operation.LoadSynchronous()) + { + StandaloneWriteJsonValue(*Writer, TEXT("asset"), + StandaloneSerializeStruct(Op->GetClass(), Op, ActiveObjects, Op)); + } + Writer->WriteObjectEnd(); + } + Writer->WriteArrayEnd(); + Writer->WriteObjectEnd(); + } + Writer->WriteArrayEnd(); + + Writer->WriteArrayStart(TEXT("passages")); + for (const FVoxelPassage& Passage : Manager->GetPassages()) + { + StandaloneWriteJsonValue(*Writer, nullptr, + MakeShared(StandalonePassageJson(Passage))); + } + Writer->WriteArrayEnd(); + Writer->WriteArrayStart(TEXT("density_samples")); + for (const FStandaloneExportSample& Sample : Samples) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("x"), static_cast(Sample.Position.X)); + Writer->WriteValue(TEXT("y"), static_cast(Sample.Position.Y)); + Writer->WriteValue(TEXT("z"), static_cast(Sample.Position.Z)); + Writer->WriteValue(TEXT("density"), static_cast(Sample.Density)); + Writer->WriteValue(TEXT("kind"), Sample.Kind); + Writer->WriteValue(TEXT("strate_index"), Sample.StrateIndex); + Writer->WriteObjectEnd(); + } + Writer->WriteArrayEnd(); + Writer->WriteObjectStart(TEXT("viewer_start")); + Writer->WriteValue(TEXT("found_open_space"), bCameraFound); + Writer->WriteValue(TEXT("x"), static_cast(Camera.X)); + Writer->WriteValue(TEXT("y"), static_cast(Camera.Y)); + Writer->WriteValue(TEXT("z"), static_cast(Camera.Z)); + Writer->WriteObjectEnd(); + Writer->WriteObjectEnd(); + Writer->Close(); + + const FString Path = FPaths::Combine(Arguments.OutDirectory, TEXT("owner_world.json")); + if (!FFileHelper::SaveStringToFile( + Json, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutError = FString::Printf(TEXT("Could not write standalone owner data to '%s'."), *Path); + return false; + } + if (OutJson) *OutJson = Json; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStandaloneExport] seed=%d voxel_size_cm=%.3f strates=%d passages=%d samples=%d camera_found=%d file=%s"), + Seed, VOXEL_SIZE, Manager->GetNumStrates(), Manager->GetPassages().Num(), + Samples.Num(), bCameraFound ? 1 : 0, *Path); + return true; +} + } // namespace UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet() @@ -12239,6 +12774,16 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) return 1; } + if (Arguments.bStandaloneWorldExport) + { + if (!RunStandaloneWorldExport(Arguments, Error, OutJson)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] %s"), *Error); + return 1; + } + return 0; + } + FExploreBudget Budget( MainStartSeconds, static_cast(Arguments.BudgetMinutes) * 60.0); diff --git a/Standalone/Build.ps1 b/Standalone/Build.ps1 new file mode 100644 index 0000000..bb7923a --- /dev/null +++ b/Standalone/Build.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$Root = [IO.Path]::GetFullPath($PSScriptRoot) +$BuildDir = Join-Path $Root 'Build' +$BinDir = Join-Path $Root 'bin' +[IO.Directory]::CreateDirectory($BuildDir) | Out-Null +[IO.Directory]::CreateDirectory($BinDir) | Out-Null +$VcVars = 'E:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat' +if (-not (Test-Path -LiteralPath $VcVars -PathType Leaf)) { throw "MSVC environment script not found: $VcVars" } + +$Sources = @( + (Join-Path $Root 'Source\StandaloneViewer.cpp'), + (Join-Path $Root 'Compat\UnrealPerlin.cpp'), + (Join-Path $Root 'Compat\StandaloneDataAssetGlue.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelBiomeDefinition.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelCaveMorphology.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelDensityAblation.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelDensityOpStack.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelDensityProfile.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelDiffLayer.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelGenerator.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelHeightOpStack.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelMarchingCubesMesher.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelSettings.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelStrateComposer.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelStrateManager.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelStrateMeasure.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelTerrainOpDefinition.cpp'), + (Join-Path $Root 'Source\VoxelForge\Private\VoxelWormField.cpp') +) +foreach ($Source in $Sources) { + if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { throw "Required source is missing: $Source" } +} + +$ResponsePath = Join-Path $BuildDir 'standalone.rsp' +$RspLines = @( + '/nologo', '/std:c++20', '/O2', '/arch:AVX2', '/fp:precise', '/EHsc', + '/D_CRT_SECURE_NO_WARNINGS', + ('/I"{0}"' -f (Join-Path $Root 'Compat')), + ('/I"{0}"' -f (Join-Path $Root 'Source')), + ('/I"{0}"' -f (Join-Path $Root 'Source\VoxelForge\Public')), + ('/I"{0}"' -f (Join-Path $Root 'Source\VoxelForge\Private')), + ('/Fe"{0}"' -f (Join-Path $BinDir 'VoxelForgeStandalone.exe')) +) +foreach ($Source in $Sources) { $RspLines += ('"{0}"' -f $Source) } +$RspLines += @('/link','d3d11.lib','dxgi.lib','d3dcompiler.lib','user32.lib','gdi32.lib') +[IO.File]::WriteAllLines($ResponsePath,$RspLines,[Text.UTF8Encoding]::new($false)) + +Push-Location $BuildDir +try { + $CommandLine = 'call "{0}" >nul && cl.exe @"{1}"' -f $VcVars,$ResponsePath + & $env:ComSpec /d /s /c $CommandLine + if ($LASTEXITCODE -ne 0) { throw "Standalone MSVC build failed with exit code $LASTEXITCODE" } +} finally { + Pop-Location +} +Write-Host "Built $((Join-Path $BinDir 'VoxelForgeStandalone.exe'))" diff --git a/Standalone/Compat/AssetRegistry/AssetData.h b/Standalone/Compat/AssetRegistry/AssetData.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/AssetRegistry/AssetData.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/AssetRegistry/AssetRegistryModule.h b/Standalone/Compat/AssetRegistry/AssetRegistryModule.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/AssetRegistry/AssetRegistryModule.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Async/ParallelFor.h b/Standalone/Compat/Async/ParallelFor.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Async/ParallelFor.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/CoreMinimal.h b/Standalone/Compat/CoreMinimal.h new file mode 100644 index 0000000..03796ca --- /dev/null +++ b/Standalone/Compat/CoreMinimal.h @@ -0,0 +1,362 @@ +#pragma once + +// Deliberately small UE-style surface used by the copied VoxelForge CPU sources. +// This is a value/container compatibility layer; it does not start or link Unreal. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define UCLASS(...) +#define USTRUCT(...) +#define UENUM(...) +#define UPROPERTY(...) +#define UFUNCTION(...) +#define UMETA(...) +#define GENERATED_BODY() +#define GENERATED_UCLASS_BODY() +#define VOXELFORGE_API +#define FORCEINLINE inline +#define WITH_EDITOR 0 +#define TRACE_CPUPROFILER_EVENT_SCOPE(...) +#define PRAGMA_DISABLE_SHADOW_VARIABLE_WARNINGS +#define PRAGMA_ENABLE_SHADOW_VARIABLE_WARNINGS +#define UE_ARRAY_COUNT(Array) (sizeof(Array) / sizeof((Array)[0])) +#define TEXT(x) x +#define UE_LOG(...) +#define LogTemp 0 +#define check(x) assert(x) +#define checkf(x, ...) assert(x) +#define ensure(x) (x) +#define ensureMsgf(x, ...) (x) +#define verify(x) (x) +#define INDEX_NONE -1 +#define MIN_int32 (std::numeric_limits::min()) +#define MAX_int32 (std::numeric_limits::max()) +#define MAX_uint32 (std::numeric_limits::max()) +#define MIN_flt (-std::numeric_limits::max()) +#define MAX_flt (std::numeric_limits::max()) +#define PI 3.14159265358979323846f +#define HALF_PI 1.57079632679489661923f +#define SMALL_NUMBER 1.0e-8f +#define KINDA_SMALL_NUMBER 1.0e-4f +#define MoveTemp(x) std::move(x) + +using int8 = std::int8_t; +using uint8 = std::uint8_t; +using int16 = std::int16_t; +using uint16 = std::uint16_t; +using int32 = std::int32_t; +using uint32 = std::uint32_t; +using int64 = std::int64_t; +using uint64 = std::uint64_t; +using UPTRINT = std::uintptr_t; +using TCHAR = char; +using SIZE_T = std::size_t; +constexpr uint64 MAX_uint64 = std::numeric_limits::max(); +enum EForceInit { ForceInit }; +enum class ESearchCase : uint8 { CaseSensitive, IgnoreCase }; + +struct FDefaultAllocator {}; +template struct TInlineAllocator {}; +enum class EAllowShrinking : uint8 { No, Yes }; +template using TFunction = std::function; +template using TFunctionRef = std::function; +template constexpr T&& Forward(std::remove_reference_t& Value){return std::forward(Value);} +template constexpr T&& Forward(std::remove_reference_t&& Value){return std::forward(Value);} + +template +class TArray +{ +public: + using value_type = T; + using FStored = std::conditional_t,uint8,T>; + using FStorage = std::vector; + using iterator = typename FStorage::iterator; + using const_iterator = typename FStorage::const_iterator; + using reference = typename FStorage::reference; + using const_reference = typename FStorage::const_reference; + + TArray() = default; + TArray(std::initializer_list Values) : Values(Values) {} + int32 Num() const { return static_cast(Values.size()); } + bool IsEmpty() const { return Values.empty(); } + bool IsValidIndex(int32 Index) const { return Index >= 0 && Index < Num(); } + void Reserve(int32 Count) { Values.reserve(static_cast(std::max(0, Count))); } + void Reset(int32 Slack = 0) { Values.clear(); if (Slack > 0) Reserve(Slack); } + void Empty(int32 Slack = 0) { Reset(Slack); } + void SetNum(int32 Count) { Values.resize(static_cast(std::max(0, Count))); } + void Init(const T& Value,int32 Count) { Values.assign(static_cast(std::max(0,Count)),static_cast(Value)); } + void SetNumZeroed(int32 Count) { SetNum(Count); if constexpr (std::is_trivially_copyable_v) if (!Values.empty()) std::memset(Values.data(), 0, Values.size() * sizeof(T)); } + void SetNumUninitialized(int32 Count) { SetNum(Count); } + int32 Add(const T& Value) { Values.push_back(Value); return Num() - 1; } + int32 Add(T&& Value) { Values.push_back(std::move(Value)); return Num() - 1; } + template int32 Emplace(Args&&... ArgsIn) { Values.emplace_back(std::forward(ArgsIn)...); return Num() - 1; } + reference AddDefaulted_GetRef() { Values.emplace_back(); return Values.back(); } + int32 AddDefaulted() { Values.emplace_back(); return Num() - 1; } + template reference Emplace_GetRef(Args&&... ArgsIn) { Values.emplace_back(std::forward(ArgsIn)...); return Values.back(); } + template reference Add_GetRef(Args&&... ArgsIn) { Values.emplace_back(std::forward(ArgsIn)...); return Values.back(); } + int32 AddUnique(const T& Value) { if (Contains(Value)) return IndexOfByKey(Value); return Add(Value); } + void Append(const TArray& Other) { Values.insert(Values.end(), Other.Values.begin(), Other.Values.end()); } + void Append(TArray&& Other) { Values.insert(Values.end(), std::make_move_iterator(Other.Values.begin()), std::make_move_iterator(Other.Values.end())); } + void RemoveAt(int32 Index, int32 Count = 1, bool = true) { if (Index >= 0 && Count > 0 && Index < Num()) Values.erase(Values.begin()+Index, Values.begin()+std::min(Num(),Index+Count)); } + void RemoveAt(int32 Index, int32 Count, EAllowShrinking) { RemoveAt(Index, Count, true); } + void RemoveAtSwap(int32 Index, int32 Count = 1, bool = true) { if (Index < 0 || Count <= 0 || Index >= Num()) return; const int32 End = std::min(Num(), Index + Count); for (int32 I = Index; I < End; ++I) Values[I] = std::move(Values.back()); Values.resize(Values.size() - static_cast(End - Index)); } + void RemoveAtSwap(int32 Index, int32 Count, EAllowShrinking) { RemoveAtSwap(Index, Count, true); } + template int32 RemoveAll(Pred P) { const auto Before=Values.size(); Values.erase(std::remove_if(Values.begin(),Values.end(),P),Values.end()); return static_cast(Before-Values.size()); } + bool Contains(const T& Value) const { return std::find(Values.begin(), Values.end(), Value) != Values.end(); } + int32 IndexOfByKey(const T& Value) const { auto I=std::find(Values.begin(),Values.end(),Value); return I==Values.end()?INDEX_NONE:static_cast(I-Values.begin()); } + T* Find(const T& Value) { const int32 I=IndexOfByKey(Value); return I==INDEX_NONE?nullptr:&Values[I]; } + const T* Find(const T& Value) const { const int32 I=IndexOfByKey(Value); return I==INDEX_NONE?nullptr:&Values[I]; } + T* GetData() { return reinterpret_cast(Values.data()); } + const T* GetData() const { return reinterpret_cast(Values.data()); } + std::size_t GetAllocatedSize() const { return Values.capacity() * sizeof(T); } + T& Last() { return Values.back(); } + const T& Last() const { return Values.back(); } + T Pop(bool = false) { T Out=std::move(Values.back()); Values.pop_back(); return Out; } + void Swap(int32 A,int32 B) { std::swap(Values[A],Values[B]); } + template void Sort(Pred P) { std::sort(Values.begin(),Values.end(),P); } + void Sort() { std::sort(Values.begin(),Values.end()); } + reference operator[](int32 I) { return Values[static_cast(I)]; } + const_reference operator[](int32 I) const { return Values[static_cast(I)]; } + iterator begin() { return Values.begin(); } iterator end() { return Values.end(); } + const_iterator begin() const { return Values.begin(); } const_iterator end() const { return Values.end(); } + const_iterator cbegin() const { return Values.cbegin(); } const_iterator cend() const { return Values.cend(); } +private: + FStorage Values; +}; + +template struct TPair { K Key{}; V Value{}; TPair()=default; TPair(const K& K0,const V& V0):Key(K0),Value(V0){}; }; + +template +class TMap +{ +public: + int32 Num() const { return static_cast(Pairs.size()); } + bool IsEmpty() const { return Pairs.empty(); } + void Reserve(int32 N) { Pairs.reserve(static_cast(std::max(0,N))); } + void Reset() { Pairs.clear(); } + void Empty(int32 = 0) { Reset(); } + bool Contains(const K& Key) const { return FindPair(Key) != Pairs.end(); } + V* Find(const K& Key) { auto I=FindPair(Key); return I==Pairs.end()?nullptr:&I->Value; } + const V* Find(const K& Key) const { auto I=FindPair(Key); return I==Pairs.end()?nullptr:&I->Value; } + V& FindOrAdd(const K& Key) { if (V* P=Find(Key)) return *P; Pairs.emplace_back(Key,V{}); return Pairs.back().Value; } + V& Add(const K& Key,const V& Value) { if (V* P=Find(Key)) { *P=Value; return *P; } Pairs.emplace_back(Key,Value); return Pairs.back().Value; } + V& Add(const K& Key,V&& Value) { if (V* P=Find(Key)) { *P=std::move(Value); return *P; } Pairs.emplace_back(Key,std::move(Value)); return Pairs.back().Value; } + int32 Remove(const K& Key) { auto I=FindPair(Key); if(I==Pairs.end())return 0; Pairs.erase(I);return 1; } + V& operator[](const K& Key) { return FindOrAdd(Key); } + const V& operator[](const K& Key) const { const V* P=Find(Key); assert(P); return *P; } + auto begin(){return Pairs.begin();} auto end(){return Pairs.end();} + auto begin()const{return Pairs.begin();} auto end()const{return Pairs.end();} +private: + using It=typename std::vector>::iterator; + using CIt=typename std::vector>::const_iterator; + It FindPair(const K& Key){return std::find_if(Pairs.begin(),Pairs.end(),[&](auto& P){return P.Key==Key;});} + CIt FindPair(const K& Key)const{return std::find_if(Pairs.begin(),Pairs.end(),[&](auto& P){return P.Key==Key;});} + std::vector> Pairs; +}; + +template +class TSet +{ +public: + int32 Num()const{return Values.Num();} void Reset(){Values.Reset();} void Empty(){Reset();} + bool Contains(const T& V)const{return Values.Contains(V);} void Add(const T& V){Values.AddUnique(V);} void Add(T&& V){if(!Contains(V))Values.Add(std::move(V));} + auto begin(){return Values.begin();}auto end(){return Values.end();}auto begin()const{return Values.begin();}auto end()const{return Values.end();} +private:TArray Values; +}; + +template class TUniquePtr +{ +public: + TUniquePtr()=default; TUniquePtr(std::nullptr_t){} explicit TUniquePtr(std::unique_ptr&&P):Ptr(std::move(P)){} + template requires std::is_convertible_v TUniquePtr(TUniquePtr&& P):Ptr(P.Release()){} + TUniquePtr(TUniquePtr&&)=default; TUniquePtr& operator=(TUniquePtr&&)=default; TUniquePtr(const TUniquePtr&)=delete; TUniquePtr& operator=(const TUniquePtr&)=delete; + template requires std::is_convertible_v TUniquePtr& operator=(TUniquePtr&& P){Ptr.reset(P.Release());return *this;} + T* Get()const{return Ptr.get();} T* get()const{return Ptr.get();} T* operator->()const{return Ptr.get();} T& operator*()const{return *Ptr;} explicit operator bool()const{return !!Ptr;} bool IsValid()const{return !!Ptr;} + void Reset(T*P=nullptr){Ptr.reset(P);} void reset(T*P=nullptr){Ptr.reset(P);} T* Release(){return Ptr.release();} void Swap(TUniquePtr&O){Ptr.swap(O.Ptr);} +private:std::unique_ptr Ptr; +}; +enum class ESPMode : uint8 { NotThreadSafe, ThreadSafe }; +template class TSharedPtr { public:TSharedPtr()=default;TSharedPtr(std::nullptr_t){}TSharedPtr(std::shared_ptrP):Ptr(std::move(P)){}template requires std::is_convertible_v TSharedPtr(const TSharedPtr&P):Ptr(P.Std()){}T*Get()const{return Ptr.get();}T*operator->()const{return Ptr.get();}T&operator*()const{return *Ptr;}explicit operator bool()const{return !!Ptr;}bool IsValid()const{return !!Ptr;}void Reset(){Ptr.reset();}void Reset(T*P){Ptr.reset(P);}std::shared_ptr&Std(){return Ptr;}const std::shared_ptr&Std()const{return Ptr;}friend bool operator==(const TSharedPtr&P,std::nullptr_t){return !P.Ptr;}friend bool operator!=(const TSharedPtr&P,std::nullptr_t){return !!P.Ptr;}private:std::shared_ptrPtr; }; +template using TSharedRef = TSharedPtr; +template class TOptional { public:TOptional()=default;TOptional(const T&V):Value(V){}TOptional(T&&V):Value(std::move(V)){}bool IsSet()const{return Value.has_value();}explicit operator bool()const{return Value.has_value();}T&GetValue(){return Value.value();}const T&GetValue()const{return Value.value();}T&Get(){return Value.value();}const T&Get()const{return Value.value();}void Reset(){Value.reset();}private:std::optionalValue;}; +template TUniquePtr MakeUnique(A&&... Args) { return TUniquePtr(std::make_unique(std::forward(Args)...)); } +template TSharedRef MakeShared(A&&... Args) { return TSharedRef(std::make_shared(std::forward(Args)...)); } + +class FString : public std::string +{ +public: + using std::string::string; + FString()=default; FString(const std::string& S):std::string(S){} FString(std::string&& S):std::string(std::move(S)){} + bool IsEmpty()const{return empty();} void Reset(){clear();} + int32 Len()const{return static_cast(size());} + bool Contains(std::string_view S)const{return find(S)!=npos;} + bool StartsWith(std::string_view S)const{return rfind(S,0)==0;} + bool EndsWith(std::string_view S)const{return size()>=S.size()&&compare(size()-S.size(),S.size(),S)==0;} + bool Equals(std::string_view S, ESearchCase SearchCase = ESearchCase::CaseSensitive)const{if(SearchCase==ESearchCase::CaseSensitive)return *this==S;if(size()!=S.size())return false;for(size_t I=0;I((*this)[I]))!=std::tolower(static_cast(S[I])))return false;return true;} + void ToLowerInline(){for(char& C:*this)C=static_cast(std::tolower(static_cast(C)));} + void TrimStartAndEndInline(){auto B=find_first_not_of(" \t\r\n"),E=find_last_not_of(" \t\r\n");*this=(B==npos)?FString{}:substr(B,E-B+1);} + void LeftChopInline(std::size_t N){if(N>=size())clear();else resize(size()-N);} + int32 ReplaceInline(std::string_view From,std::string_view To){if(From.empty())return 0;int32 C=0;std::size_t P=0;while((P=find(From,P))!=npos){replace(P,From.size(),To);P+=To.size();++C;}return C;} + static FString Join(const TArray& Parts, std::string_view Delimiter){FString Result;for(int32 I=0;I(std::min(N,sizeof(Buf)-1)));} +}; +inline const char* operator*(const FString& S){return S.c_str();} +using FName = FString; +constexpr const char* NAME_None = ""; +class UObject; +template T* FindStandaloneAsset(const FString& Path); +void RegisterStandaloneAsset(const FString& Path, UObject* Asset); +class FText { FString Value; public: FText()=default; FText(FString S):Value(std::move(S)){} static FText FromString(const FString& S){return FText(S);} static FText FromName(const FName& S){return FText(S);} FString ToString()const{return Value;} bool IsEmpty()const{return Value.IsEmpty();} }; +class UEnum { public: FString GetNameStringByValue(int64 V)const{return FString::Printf("Value_%lld",static_cast(V));} }; +template const UEnum* StaticEnum(){static const UEnum Value;return &Value;} +constexpr uint32 RF_Transient = 1; +template T* NewObject(UObject* = nullptr,FName = NAME_None,uint32 = 0){return new T;} +template T* DuplicateObject(const T* Source,UObject*){return Source?new T(*Source):nullptr;} +template class TGuardValue { public:TGuardValue(T& Target,const T& NewValue):Ref(Target),Old(Target){Ref=NewValue;}~TGuardValue(){Ref=Old;}TGuardValue(const TGuardValue&)=delete;private:T&Ref;T Old;}; + +struct FIntPoint { int32 X=0,Y=0; constexpr FIntPoint()=default; constexpr FIntPoint(int32 A,int32 B):X(A),Y(B){} static const FIntPoint ZeroValue; bool operator==(const FIntPoint&O)const{return X==O.X&&Y==O.Y;} bool operator!=(const FIntPoint&O)const{return !(*this==O);} FIntPoint operator+(const FIntPoint&O)const{return {X+O.X,Y+O.Y};} FIntPoint operator-(const FIntPoint&O)const{return {X-O.X,Y-O.Y};} }; +inline const FIntPoint FIntPoint::ZeroValue{0,0}; +struct FIntVector { int32 X=0,Y=0,Z=0; constexpr FIntVector()=default; constexpr FIntVector(int32 A,int32 B,int32 C):X(A),Y(B),Z(C){} static const FIntVector ZeroValue; bool operator==(const FIntVector&O)const{return X==O.X&&Y==O.Y&&Z==O.Z;} bool operator!=(const FIntVector&O)const{return !(*this==O);} FIntVector operator+(const FIntVector&O)const{return {X+O.X,Y+O.Y,Z+O.Z};} FIntVector operator-(const FIntVector&O)const{return {X-O.X,Y-O.Y,Z-O.Z};} FIntVector operator*(int32 S)const{return {X*S,Y*S,Z*S};} FIntVector operator/(int32 S)const{return {X/S,Y/S,Z/S};} FIntVector& operator+=(const FIntVector&O){X+=O.X;Y+=O.Y;Z+=O.Z;return *this;} }; +inline const FIntVector FIntVector::ZeroValue{0,0,0}; +inline FIntVector operator*(int32 S,const FIntVector& V){return V*S;} + +struct FVector2D { double X=0,Y=0; constexpr FVector2D()=default; constexpr FVector2D(double A,double B):X(A),Y(B){} static const FVector2D ZeroVector; double SizeSquared()const{return X*X+Y*Y;} double Size()const{return std::sqrt(SizeSquared());} bool Normalize(double Tol=1.e-8){double L=Size();if(L<=Tol)return false;X/=L;Y/=L;return true;} FVector2D GetSafeNormal(double Tol=1.e-8)const{FVector2D V=*this;V.Normalize(Tol);return V;} double DistSquared(const FVector2D&O)const{return (*this-O).SizeSquared();} static double DistSquared(const FVector2D&A,const FVector2D&B){return (A-B).SizeSquared();} FVector2D operator-()const{return {-X,-Y};} FVector2D operator+(const FVector2D&O)const{return {X+O.X,Y+O.Y};} FVector2D operator-(const FVector2D&O)const{return {X-O.X,Y-O.Y};} FVector2D operator*(double S)const{return {X*S,Y*S};} FVector2D operator/(double S)const{return {X/S,Y/S};} FVector2D&operator+=(const FVector2D&O){X+=O.X;Y+=O.Y;return *this;} FVector2D&operator*=(double S){X*=S;Y*=S;return *this;} static double DotProduct(const FVector2D&A,const FVector2D&B){return A.X*B.X+A.Y*B.Y;} }; +inline const FVector2D FVector2D::ZeroVector{0,0}; +struct FVector2f { float X=0,Y=0; constexpr FVector2f()=default; constexpr FVector2f(float A,float B):X(A),Y(B){} }; +struct FVector3f { float X=0,Y=0,Z=0; constexpr FVector3f()=default; constexpr FVector3f(float A,float B,float C):X(A),Y(B),Z(C){} FVector3f operator+(const FVector3f&O)const{return {X+O.X,Y+O.Y,Z+O.Z};} FVector3f operator-(const FVector3f&O)const{return {X-O.X,Y-O.Y,Z-O.Z};} FVector3f operator*(float S)const{return {X*S,Y*S,Z*S};} FVector3f&operator+=(const FVector3f&O){X+=O.X;Y+=O.Y;Z+=O.Z;return *this;} bool operator==(const FVector3f&O)const{return X==O.X&&Y==O.Y&&Z==O.Z;} }; +struct FVector +{ + using FReal = double; + double X=0,Y=0,Z=0; constexpr FVector()=default; constexpr explicit FVector(double S):X(S),Y(S),Z(S){} constexpr FVector(double A,double B,double C):X(A),Y(B),Z(C){} constexpr explicit FVector(const FIntVector& V):X(V.X),Y(V.Y),Z(V.Z){} + static const FVector ZeroVector, OneVector, UpVector, DownVector, ForwardVector, RightVector; + FVector operator+(const FVector&O)const{return {X+O.X,Y+O.Y,Z+O.Z};} FVector operator-(const FVector&O)const{return {X-O.X,Y-O.Y,Z-O.Z};} + FVector operator-()const{return {-X,-Y,-Z};} FVector operator*(double S)const{return {X*S,Y*S,Z*S};} FVector operator/(double S)const{return {X/S,Y/S,Z/S};} FVector operator/(const FVector&O)const{return {X/O.X,Y/O.Y,Z/O.Z};} + FVector& operator+=(const FVector&O){X+=O.X;Y+=O.Y;Z+=O.Z;return *this;} FVector& operator-=(const FVector&O){X-=O.X;Y-=O.Y;Z-=O.Z;return *this;} FVector& operator*=(double S){X*=S;Y*=S;Z*=S;return *this;} FVector& operator/=(double S){X/=S;Y/=S;Z/=S;return *this;} + bool operator==(const FVector&O)const{return X==O.X&&Y==O.Y&&Z==O.Z;} bool operator!=(const FVector&O)const{return !(*this==O);} + double SizeSquared()const{return X*X+Y*Y+Z*Z;} double Size()const{return std::sqrt(SizeSquared());} + double Dist(const FVector&O)const{return (*this-O).Size();} double DistSquared(const FVector&O)const{return (*this-O).SizeSquared();} + static double Dist(const FVector&A,const FVector&B){return (A-B).Size();} static double DistSquared(const FVector&A,const FVector&B){return (A-B).SizeSquared();} + bool Normalize(double Tolerance=1.e-8){double L=Size();if(L<=Tolerance)return false;*this=*this/L;return true;} + FVector GetAbs()const{return {std::abs(X),std::abs(Y),std::abs(Z)};} + FVector GetSafeNormal(double Tolerance=1.e-8)const{FVector V=*this;V.Normalize(Tolerance);return V;} + bool IsNearlyZero(double Tolerance=1.e-8)const{return SizeSquared()<=Tolerance*Tolerance;} + bool ContainsNaN()const{return !std::isfinite(X)||!std::isfinite(Y)||!std::isfinite(Z);} + bool Equals(const FVector&O,double Tolerance=1.e-8)const{return (*this-O).IsNearlyZero(Tolerance);} + double Dot(const FVector&O)const{return X*O.X+Y*O.Y+Z*O.Z;} FVector Cross(const FVector&O)const{return {Y*O.Z-Z*O.Y,Z*O.X-X*O.Z,X*O.Y-Y*O.X};} + static double DotProduct(const FVector&A,const FVector&B){return A.Dot(B);} static FVector CrossProduct(const FVector&A,const FVector&B){return A.Cross(B);} +}; +inline const FVector FVector::ZeroVector{0,0,0}; inline const FVector FVector::OneVector{1,1,1}; inline const FVector FVector::UpVector{0,0,1}; inline const FVector FVector::DownVector{0,0,-1}; inline const FVector FVector::ForwardVector{1,0,0}; inline const FVector FVector::RightVector{0,1,0}; +inline FVector operator*(double S,const FVector&V){return V*S;} +inline FVector operator*(float S,const FVector&V){return V*static_cast(S);} + +struct FBox { FVector Min{MAX_flt,MAX_flt,MAX_flt},Max{MIN_flt,MIN_flt,MIN_flt}; bool IsValid=false; FBox()=default; explicit FBox(EForceInit):FBox(){} FBox(const FVector&A,const FVector&B):Min(A),Max(B),IsValid(A.X<=B.X&&A.Y<=B.Y&&A.Z<=B.Z){} FVector GetCenter()const{return (Min+Max)*0.5;} FVector GetExtent()const{return (Max-Min)*0.5;} bool IsInside(const FVector&P)const{return P.X>=Min.X&&P.X<=Max.X&&P.Y>=Min.Y&&P.Y<=Max.Y&&P.Z>=Min.Z&&P.Z<=Max.Z;} bool operator==(const FBox&O)const{return Min==O.Min&&Max==O.Max&&IsValid==O.IsValid;} bool operator!=(const FBox&O)const{return !(*this==O);} }; +struct FColor { uint8 R=0,G=0,B=0,A=255; constexpr FColor()=default; constexpr FColor(uint8 r,uint8 g,uint8 b,uint8 a=255):R(r),G(g),B(b),A(a){} static const FColor White, Black, Red, Green, Blue, Transparent; }; +inline const FColor FColor::White{255,255,255,255}; inline const FColor FColor::Black{0,0,0,255}; inline const FColor FColor::Red{255,0,0,255}; inline const FColor FColor::Green{0,255,0,255}; inline const FColor FColor::Blue{0,0,255,255}; inline const FColor FColor::Transparent{0,0,0,0}; +struct FLinearColor { float R=0,G=0,B=0,A=1; constexpr FLinearColor()=default; constexpr FLinearColor(float r,float g,float b,float a=1):R(r),G(g),B(b),A(a){} static const FLinearColor White,Black,Red,Green,Blue; FColor ToFColor(bool bSRGB)const{auto cv=[](float V,bool S){V=std::clamp(V,0.0f,1.0f);if(S)V=V<=0.0031308f?12.92f*V:1.055f*std::pow(V,1.0f/2.4f)-0.055f;return static_cast(std::clamp(static_cast(std::floor(V*255.0f+0.5f)),0,255));};return {cv(R,bSRGB),cv(G,bSRGB),cv(B,bSRGB),cv(A,false)};} }; +inline const FLinearColor FLinearColor::White{1,1,1,1}; inline const FLinearColor FLinearColor::Black{0,0,0,1}; inline const FLinearColor FLinearColor::Red{1,0,0,1}; inline const FLinearColor FLinearColor::Green{0,1,0,1}; inline const FLinearColor FLinearColor::Blue{0,0,1,1}; +struct FRotator { double Pitch=0,Yaw=0,Roll=0; constexpr FRotator()=default; constexpr FRotator(double P,double Y,double R):Pitch(P),Yaw(Y),Roll(R){} static const FRotator ZeroRotator; }; +inline const FRotator FRotator::ZeroRotator{0,0,0}; + +namespace FMath +{ + template constexpr auto Min(A A0,B B0){using R=std::common_type_t;R A1=static_cast(A0),B1=static_cast(B0);return A1 constexpr auto Max(A A0,B B0){using R=std::common_type_t;R A1=static_cast(A0),B1=static_cast(B0);return A1>B1?A1:B1;} + template constexpr T Clamp(T V,A A0,B B0){T Lo=static_cast(A0),Hi=static_cast(B0);return VHi?Hi:V);} template constexpr T Abs(T V){return V(std::floor(V));} inline int32 CeilToInt(double V){return static_cast(std::ceil(V));} inline int32 RoundToInt(double V){return static_cast(std::floor(V+0.5));} + inline double FloorToDouble(double V){return std::floor(V);} inline double CeilToDouble(double V){return std::ceil(V);} inline double RoundToDouble(double V){return std::floor(V+0.5);} + inline float FloorToFloat(float V){return std::floor(V);} inline float CeilToFloat(float V){return std::ceil(V);} inline float RoundToFloat(float V){return std::floor(V+0.5f);} + inline bool IsFinite(double V){return std::isfinite(V);} inline bool IsNearlyZero(double V,double T=1.e-8){return std::abs(V)<=T;} inline bool IsNearlyEqual(double A,double B,double T=1.e-8){return std::abs(A-B)<=T;} + inline float Sqrt(float V){return std::sqrt(V);} inline double Sqrt(double V){return std::sqrt(V);} inline float InvSqrt(float V){return 1.0f/std::sqrt(V);} inline double InvSqrt(double V){return 1.0/std::sqrt(V);} + inline float Sin(float V){return std::sin(V);} inline double Sin(double V){return std::sin(V);} inline float Cos(float V){return std::cos(V);} inline double Cos(double V){return std::cos(V);} + inline float Tan(float V){return std::tan(V);} inline double Tan(double V){return std::tan(V);} inline float Asin(float V){return std::asin(V);} inline double Asin(double V){return std::asin(V);} inline float Acos(float V){return std::acos(V);} inline double Acos(double V){return std::acos(V);} + inline float Atan2(float Y,float X){return std::atan2(Y,X);} inline double Atan2(double Y,double X){return std::atan2(Y,X);} inline float Pow(float A,float B){return std::pow(A,B);} inline double Pow(double A,double B){return std::pow(A,B);} + inline float Exp(float V){return std::exp(V);} inline double Exp(double V){return std::exp(V);} inline float Loge(float V){return std::log(V);} inline float Fmod(float A,float B){return std::fmod(A,B);} inline double Fmod(double A,double B){return std::fmod(A,B);} + template constexpr T Square(T V){return V*V;} template constexpr T Sign(T V){return VT(0)?T(1):T(0));} + template constexpr T Max3(T A,T B,T C){return Max(Max(A,B),C);} template constexpr T Min3(T A,T B,T C){return Min(Min(A,B),C);} + inline double DegreesToRadians(double D){return D*(3.1415926535897932384626433832795/180.0);} inline double RadiansToDegrees(double R){return R*(180.0/3.1415926535897932384626433832795);} + inline float Frac(float V){return V-std::floor(V);} inline double Frac(double V){return V-std::floor(V);} + inline int32 DivideAndRoundUp(int32 A,int32 B){return (A+B-1)/B;} + template bool IsPowerOfTwo(T V){return V>0&&(V&(V-1))==0;} + inline int32 FloorLog2(uint32 V){return V?31-std::countl_zero(V):0;} + float PerlinNoise2D(const FVector2D& Location); + float PerlinNoise3D(const FVector& Location); + template inline auto Lerp(const A& A0,const B& B0,C Alpha)->decltype(A0+(B0-A0)*Alpha){return A0+(B0-A0)*Alpha;} + inline float SmoothStep(float A,float B,float X){float T=Clamp((X-A)/(B-A),0.0f,1.0f);return T*T*(3-2*T);} +} + +inline uint32 HashCombine(uint32 A,uint32 B){return A^(B+0x9e3779b9u+(A<<6)+(A>>2));} +inline uint32 GetTypeHash(int32 V){return static_cast(V)*0x9e3779b9u;} +inline uint32 GetTypeHash(uint32 V){return V*0x9e3779b9u;} +inline uint32 GetTypeHash(float V){return std::bit_cast(V);} +inline uint32 GetTypeHash(double V){return static_cast(std::bit_cast(V)^ (std::bit_cast(V)>>32));} +inline uint32 GetTypeHash(int64 V){return static_cast(static_cast(V)^(static_cast(V)>>32));} +inline uint32 GetTypeHash(uint64 V){return static_cast(V^(V>>32));} +inline uint32 GetTypeHash(const FIntVector& V){return HashCombine(HashCombine(GetTypeHash(V.X),GetTypeHash(V.Y)),GetTypeHash(V.Z));} +inline uint32 GetTypeHash(const FIntPoint& V){return HashCombine(GetTypeHash(V.X),GetTypeHash(V.Y));} +struct FMemory { static void Memcpy(void*D,const void*S,std::size_t N){std::memcpy(D,S,N);} static void Memzero(void*D,std::size_t N){std::memset(D,0,N);} static int32 Memcmp(const void*A,const void*B,std::size_t N){return std::memcmp(A,B,N);} static SIZE_T GetAllocSize(const void* P){return P?sizeof(void*):0;} }; + +struct FPrimaryAssetId { FString Type,Name; FPrimaryAssetId()=default; FPrimaryAssetId(FString T,FName N):Type(std::move(T)),Name(std::move(N)){} }; +class UObject { public: virtual ~UObject()=default; virtual void PostLoad(){} virtual FName GetFName()const{return FName{};} virtual FString GetPathName()const{return FString{};} virtual bool Modify(){return true;} virtual void MarkPackageDirty(){} }; +inline std::unordered_map& StandaloneAssetRegistry() +{ + static std::unordered_map Assets; + return Assets; +} +inline void RegisterStandaloneAsset(const FString& Path, UObject* Asset) +{ + if (!Path.IsEmpty() && Asset) StandaloneAssetRegistry()[Path] = Asset; +} +template inline T* FindStandaloneAsset(const FString& Path) +{ + auto It = StandaloneAssetRegistry().find(Path); + return It == StandaloneAssetRegistry().end() ? nullptr : dynamic_cast(It->second); +} +class UDataAsset : public UObject {}; +class UPrimaryDataAsset : public UDataAsset { public: virtual FPrimaryAssetId GetPrimaryAssetId()const{return {};} }; +using FSoftObjectPath = FString; +template class TSoftObjectPtr { public: TSoftObjectPtr()=default; TSoftObjectPtr(T*P):Ptr(P){} TSoftObjectPtr(const FSoftObjectPath&P):Path(P){} bool IsNull()const{return Ptr==nullptr&&Path.IsEmpty();} T* Get()const{return Ptr?Ptr:FindStandaloneAsset(Path);} T* LoadSynchronous()const{return Get();} FString ToString()const{return Path;} void Set(T*P,FString S={}){Ptr=P;Path=std::move(S);} T* operator->()const{return Get();} explicit operator bool()const{return Get()!=nullptr;} private:T*Ptr=nullptr;FString Path; }; +template class TWeakObjectPtr { public:TWeakObjectPtr()=default;TWeakObjectPtr(T*P):Ptr(P){}T*Get()const{return Ptr;}T*operator->()const{return Ptr;}private:T*Ptr=nullptr;}; +template class TSubclassOf { public:TSubclassOf()=default; }; +class FGameplayTag {}; class FGameplayTagContainer {}; +struct FRandomStream { explicit FRandomStream(int32 S=0):Seed(S){} int32 RandRange(int32 Min,int32 Max){Seed=Seed*196314165+907633515;uint32 R=(Seed>>16)&0x7fff;return Min+static_cast((uint64(R)*(uint64(Max-Min)+1))>>15);} int32 GetCurrentSeed()const{return Seed;} private:int32 Seed; }; +struct FCString { static int32 Strcmp(const char*A,const char*B){return std::strcmp(A,B);} static float Atof(const char*S){return static_cast(std::atof(S));} static int32 Atoi(const char*S){return std::atoi(S);} }; +struct FPlatformTime { using Clock=std::chrono::steady_clock; static uint64 Cycles64(){return static_cast(std::chrono::duration_cast(Clock::now().time_since_epoch()).count());} static double Seconds(){return Cycles64()*1e-9;} static double GetSecondsPerCycle64(){return 1e-9;} }; +struct FEvent { void Trigger(){} bool Wait(uint32=0,bool=false){return true;} }; +struct FPlatformProcess { static void Sleep(float S){std::this_thread::sleep_for(std::chrono::duration(S));} static void YieldThread(){std::this_thread::yield();} static void Yield(){std::this_thread::yield();} static FEvent* GetSynchEventFromPool(bool=false){return new FEvent;} static void ReturnSynchEventToPool(FEvent*E){delete E;} }; +using FCriticalSection = std::mutex; +struct FScopeLock { explicit FScopeLock(std::mutex&M):Lock(M){} explicit FScopeLock(std::mutex* M):Lock(*M){} std::lock_guard Lock; }; +struct FRWLock { mutable std::shared_mutex Mutex; }; +struct FReadScopeLock { explicit FReadScopeLock(FRWLock&L):Lock(L.Mutex){} std::shared_lock Lock; }; +struct FWriteScopeLock { explicit FWriteScopeLock(FRWLock&L):Lock(L.Mutex){} std::unique_lock Lock; }; +enum EConsoleVariableFlags { ECVF_Default=0, ECVF_SetByCommandline=1, ECVF_SetByCode=2 }; +class FAutoConsoleVariableRef { public:FAutoConsoleVariableRef(const char*,int32&,const char*,uint32=0){} FAutoConsoleVariableRef(const char*,float&,const char*,uint32=0){} FAutoConsoleVariableRef(const char*,bool&,const char*,uint32=0){} }; +template class TAutoConsoleVariable { public:TAutoConsoleVariable(const char*,T Default,const char*,uint32=0):Value(Default){}T GetValueOnAnyThread()const{return Value.load();}T GetValueOnGameThread()const{return Value.load();}void Set(T V,uint32=0){Value.store(V);} TAutoConsoleVariable*operator->(){return this;}private:std::atomicValue;}; +struct FCommandLine { static const char* Get(){return "";} }; +struct FParse { static bool Value(const char*,const char*,int32&){return false;} static bool Value(const char*,const char*,float&){return false;} static bool Value(const char*,const char*,FString&){return false;} static bool Param(const char*,const char*){return false;} }; +struct FCrc { static uint32 MemCrc32(const void*Data,int32 Length,uint32 Crc=0){const auto*P=static_cast(Data);uint32 V=~Crc;for(int32 I=0;I>1)^(0xEDB88320u&static_cast(-static_cast(V&1)));}return ~V;} static uint32 StrCrc32(const char*S,uint32 Crc=0){return MemCrc32(S,static_cast(std::strlen(S)),Crc);} }; +template using TAtomic = std::atomic; +class FThreadSafeCounter { public:int32 Increment(){return ++Value;}int32 Decrement(){return --Value;}int32 GetValue()const{return Value.load();}void Reset(){Value.store(0);}private:std::atomicValue{0};}; diff --git a/Standalone/Compat/Dom/JsonObject.h b/Standalone/Compat/Dom/JsonObject.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Dom/JsonObject.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Dom/JsonValue.h b/Standalone/Compat/Dom/JsonValue.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Dom/JsonValue.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Engine/DataAsset.h b/Standalone/Compat/Engine/DataAsset.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Engine/DataAsset.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/GameplayTagContainer.h b/Standalone/Compat/GameplayTagContainer.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/GameplayTagContainer.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/HAL/CriticalSection.h b/Standalone/Compat/HAL/CriticalSection.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/HAL/CriticalSection.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/HAL/Event.h b/Standalone/Compat/HAL/Event.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/HAL/Event.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/HAL/FileManager.h b/Standalone/Compat/HAL/FileManager.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/HAL/FileManager.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/HAL/IConsoleManager.h b/Standalone/Compat/HAL/IConsoleManager.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/HAL/IConsoleManager.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/HAL/PlatformProcess.h b/Standalone/Compat/HAL/PlatformProcess.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/HAL/PlatformProcess.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/HAL/PlatformTime.h b/Standalone/Compat/HAL/PlatformTime.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/HAL/PlatformTime.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Json.h b/Standalone/Compat/Json.h new file mode 100644 index 0000000..8c62911 --- /dev/null +++ b/Standalone/Compat/Json.h @@ -0,0 +1,154 @@ +#pragma once + +#include +#include +#include + +namespace VFJson +{ + struct Value + { + using Array = std::vector; + using Object = std::unordered_map; + using Storage = std::variant; + Storage Data = nullptr; + + bool IsObject() const { return std::holds_alternative(Data); } + bool IsArray() const { return std::holds_alternative(Data); } + bool IsString() const { return std::holds_alternative(Data); } + bool IsNumber() const { return std::holds_alternative(Data); } + const Object& AsObject() const { static const Object Empty; return IsObject() ? std::get(Data) : Empty; } + const Array& AsArray() const { static const Array Empty; return IsArray() ? std::get(Data) : Empty; } + const Value& Get(std::string_view Key) const + { + static const Value Null; + const Object& O = AsObject(); + auto It = O.find(std::string(Key)); + return It == O.end() ? Null : It->second; + } + double Number(double Fallback = 0.0) const { return IsNumber() ? std::get(Data) : Fallback; } + int64 Integer(int64 Fallback = 0) const { return IsNumber() ? static_cast(std::get(Data)) : Fallback; } + bool Boolean(bool Fallback = false) const { return std::holds_alternative(Data) ? std::get(Data) : Fallback; } + const std::string& String() const { static const std::string Empty; return IsString() ? std::get(Data) : Empty; } + }; + + class Parser + { + public: + explicit Parser(std::string Source) : Text(std::move(Source)) {} + bool Parse(Value& Out, std::string& Error) + { + SkipSpace(); + if (!ReadValue(Out, Error)) return false; + SkipSpace(); + if (Position != Text.size()) { Error = "trailing characters after JSON value"; return false; } + return true; + } + private: + std::string Text; + size_t Position = 0; + void SkipSpace() { while (Position < Text.size() && static_cast(Text[Position]) <= 0x20) ++Position; } + bool Consume(char C) { SkipSpace(); if (Position < Text.size() && Text[Position] == C) { ++Position; return true; } return false; } + static void AppendUtf8(std::string& Out, uint32 Code) + { + if (Code <= 0x7f) Out.push_back(static_cast(Code)); + else if (Code <= 0x7ff) { Out.push_back(static_cast(0xc0 | (Code >> 6))); Out.push_back(static_cast(0x80 | (Code & 0x3f))); } + else if (Code <= 0xffff) { Out.push_back(static_cast(0xe0 | (Code >> 12))); Out.push_back(static_cast(0x80 | ((Code >> 6) & 0x3f))); Out.push_back(static_cast(0x80 | (Code & 0x3f))); } + else { Out.push_back(static_cast(0xf0 | (Code >> 18))); Out.push_back(static_cast(0x80 | ((Code >> 12) & 0x3f))); Out.push_back(static_cast(0x80 | ((Code >> 6) & 0x3f))); Out.push_back(static_cast(0x80 | (Code & 0x3f))); } + } + bool Hex4(uint32& Out) + { + if (Position + 4 > Text.size()) return false; + Out = 0; + for (int32 I = 0; I < 4; ++I) + { + const char C = Text[Position++]; + const int32 N = (C >= '0' && C <= '9') ? C - '0' : (C >= 'a' && C <= 'f') ? C - 'a' + 10 : (C >= 'A' && C <= 'F') ? C - 'A' + 10 : -1; + if (N < 0) return false; + Out = (Out << 4) | static_cast(N); + } + return true; + } + bool ReadString(std::string& Out, std::string& Error) + { + SkipSpace(); + if (Position >= Text.size() || Text[Position++] != '"') { Error = "expected JSON string"; return false; } + while (Position < Text.size()) + { + const unsigned char C = static_cast(Text[Position++]); + if (C == '"') return true; + if (C < 0x20) { Error = "control character in JSON string"; return false; } + if (C != '\\') { Out.push_back(static_cast(C)); continue; } + if (Position >= Text.size()) break; + const char Escape = Text[Position++]; + switch (Escape) + { + case '"': Out.push_back('"'); break; case '\\': Out.push_back('\\'); break; + case '/': Out.push_back('/'); break; case 'b': Out.push_back('\b'); break; + case 'f': Out.push_back('\f'); break; case 'n': Out.push_back('\n'); break; + case 'r': Out.push_back('\r'); break; case 't': Out.push_back('\t'); break; + case 'u': + { + uint32 Code = 0; if (!Hex4(Code)) { Error = "invalid JSON unicode escape"; return false; } + if (Code >= 0xd800 && Code <= 0xdbff) + { + if (Position + 2 > Text.size() || Text[Position++] != '\\' || Text[Position++] != 'u') { Error = "missing JSON low surrogate"; return false; } + uint32 Low = 0; if (!Hex4(Low) || Low < 0xdc00 || Low > 0xdfff) { Error = "invalid JSON low surrogate"; return false; } + Code = 0x10000 + ((Code - 0xd800) << 10) + (Low - 0xdc00); + } + AppendUtf8(Out, Code); break; + } + default: Error = "invalid JSON escape"; return false; + } + } + Error = "unterminated JSON string"; return false; + } + bool ReadValue(Value& Out, std::string& Error) + { + SkipSpace(); + if (Position >= Text.size()) { Error = "unexpected end of JSON"; return false; } + const char C = Text[Position]; + if (C == '"') { std::string S; if (!ReadString(S, Error)) return false; Out.Data = std::move(S); return true; } + if (C == '{') + { + ++Position; Value::Object O; SkipSpace(); + if (Consume('}')) { Out.Data = std::move(O); return true; } + for (;;) + { + std::string Key; if (!ReadString(Key, Error) || !Consume(':')) { if (Error.empty()) Error = "expected colon in JSON object"; return false; } + Value V; if (!ReadValue(V, Error)) return false; O[std::move(Key)] = std::move(V); + if (Consume('}')) break; if (!Consume(',')) { Error = "expected comma in JSON object"; return false; } + } + Out.Data = std::move(O); return true; + } + if (C == '[') + { + ++Position; Value::Array A; SkipSpace(); + if (Consume(']')) { Out.Data = std::move(A); return true; } + for (;;) { Value V; if (!ReadValue(V, Error)) return false; A.push_back(std::move(V)); if (Consume(']')) break; if (!Consume(',')) { Error = "expected comma in JSON array"; return false; } } + Out.Data = std::move(A); return true; + } + auto Match = [&](std::string_view S) { if (Text.compare(Position, S.size(), S) == 0) { Position += S.size(); return true; } return false; }; + if (Match("true")) { Out.Data = true; return true; } + if (Match("false")) { Out.Data = false; return true; } + if (Match("null")) { Out.Data = nullptr; return true; } + const size_t Start = Position; + if (C == '-') ++Position; + while (Position < Text.size() && Text[Position] >= '0' && Text[Position] <= '9') ++Position; + if (Position < Text.size() && Text[Position] == '.') { ++Position; while (Position < Text.size() && Text[Position] >= '0' && Text[Position] <= '9') ++Position; } + if (Position < Text.size() && (Text[Position] == 'e' || Text[Position] == 'E')) { ++Position; if (Position < Text.size() && (Text[Position] == '+' || Text[Position] == '-')) ++Position; while (Position < Text.size() && Text[Position] >= '0' && Text[Position] <= '9') ++Position; } + if (Start == Position) { Error = "invalid JSON value"; return false; } + char* End = nullptr; const double Number = std::strtod(Text.c_str() + Start, &End); + if (End != Text.c_str() + Position || !std::isfinite(Number)) { Error = "invalid JSON number"; return false; } + Out.Data = Number; return true; + } + }; + + inline bool Load(const std::string& Path, Value& Out, std::string& Error) + { + std::ifstream Input(Path, std::ios::binary); + if (!Input) { Error = "cannot open " + Path; return false; } + std::string Text((std::istreambuf_iterator(Input)), std::istreambuf_iterator()); + return Parser(std::move(Text)).Parse(Out, Error); + } +} diff --git a/Standalone/Compat/Math/RandomStream.h b/Standalone/Compat/Math/RandomStream.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Math/RandomStream.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/CommandLine.h b/Standalone/Compat/Misc/CommandLine.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/CommandLine.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/Crc.h b/Standalone/Compat/Misc/Crc.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/Crc.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/FileHelper.h b/Standalone/Compat/Misc/FileHelper.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/FileHelper.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/Parse.h b/Standalone/Compat/Misc/Parse.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/Parse.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/Paths.h b/Standalone/Compat/Misc/Paths.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/Paths.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/ScopeLock.h b/Standalone/Compat/Misc/ScopeLock.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/ScopeLock.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/ScopeRWLock.h b/Standalone/Compat/Misc/ScopeRWLock.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/ScopeRWLock.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Misc/SecureHash.h b/Standalone/Compat/Misc/SecureHash.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Misc/SecureHash.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Modules/ModuleManager.h b/Standalone/Compat/Modules/ModuleManager.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Modules/ModuleManager.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/ProfilingDebugging/CpuProfilerTrace.h b/Standalone/Compat/ProfilingDebugging/CpuProfilerTrace.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/ProfilingDebugging/CpuProfilerTrace.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Serialization/CustomVersion.h b/Standalone/Compat/Serialization/CustomVersion.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Serialization/CustomVersion.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Serialization/JsonSerializer.h b/Standalone/Compat/Serialization/JsonSerializer.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Serialization/JsonSerializer.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Serialization/JsonWriter.h b/Standalone/Compat/Serialization/JsonWriter.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Serialization/JsonWriter.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/StandaloneDataAssetGlue.cpp b/Standalone/Compat/StandaloneDataAssetGlue.cpp new file mode 100644 index 0000000..351c800 --- /dev/null +++ b/Standalone/Compat/StandaloneDataAssetGlue.cpp @@ -0,0 +1,32 @@ +#include "VoxelSeasonAsset.h" +#include "VoxelSeasonManifest.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStartupTrace.h" + +// The owner's exported settings have no active Season reference. Engine deserialization already +// ran the version migration before export, so the standalone data holder only reapplies the +// current normalized compatibility field. +void UVoxelStrateDefinition::PostLoad() +{ + GenerationParams.bTunnelFloorEnabled = true; +} + +bool UVoxelSeasonAsset::LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const +{ + OutManifest = FVoxelSeasonManifest(); + OutReport = TEXT("Standalone owner snapshot has no active season manifest."); + return false; +} + +namespace VoxelForgeStartupTrace +{ + void BeginFromCommandLine() {} + bool IsActive() { return false; } + void RecordEvent(const TCHAR*, const FString&) {} + void StageBegin(const TCHAR*) {} + void StageEnd(const TCHAR*) {} + void RecordTile(const FTileSample&) {} + void Finish(const TCHAR*) {} + FStageScope::FStageScope(const TCHAR*) {} + FStageScope::~FStageScope() = default; +} diff --git a/Standalone/Compat/Stats/Stats.h b/Standalone/Compat/Stats/Stats.h new file mode 100644 index 0000000..1cd9ff4 --- /dev/null +++ b/Standalone/Compat/Stats/Stats.h @@ -0,0 +1,10 @@ +#pragma once +#define STATCAT_Advanced 0 +#define DECLARE_STATS_GROUP(Name,Group,Category) constexpr int32 Group=0 +#define DECLARE_DWORD_COUNTER_STAT_EXTERN(Name,Stat,Group,Api) constexpr int32 Stat=0 +#define DEFINE_STAT(Stat) +#define INC_DWORD_STAT(Stat) ((void)0) +#define DEC_DWORD_STAT(Stat) ((void)0) +#define SET_DWORD_STAT(Stat,Value) ((void)0) +#define SCOPE_CYCLE_COUNTER(Stat) ((void)0) +#define QUICK_SCOPE_CYCLE_COUNTER(Stat) ((void)0) diff --git a/Standalone/Compat/Templates/SubclassOf.h b/Standalone/Compat/Templates/SubclassOf.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Templates/SubclassOf.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/Templates/UniquePtr.h b/Standalone/Compat/Templates/UniquePtr.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/Templates/UniquePtr.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/UObject/EnumProperty.h b/Standalone/Compat/UObject/EnumProperty.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/UObject/EnumProperty.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/UObject/FieldIterator.h b/Standalone/Compat/UObject/FieldIterator.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/UObject/FieldIterator.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/UObject/Object.h b/Standalone/Compat/UObject/Object.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/UObject/Object.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/UObject/UObjectGlobals.h b/Standalone/Compat/UObject/UObjectGlobals.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/UObject/UObjectGlobals.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/UObject/UnrealType.h b/Standalone/Compat/UObject/UnrealType.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/UObject/UnrealType.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/UnrealPerlin.cpp b/Standalone/Compat/UnrealPerlin.cpp new file mode 100644 index 0000000..d5b2801 --- /dev/null +++ b/Standalone/Compat/UnrealPerlin.cpp @@ -0,0 +1,64 @@ +#include "CoreMinimal.h" +#include "UnrealPerlinHelpers.inl" + +float FMath::PerlinNoise2D(const FVector2D& Location) +{ + using namespace FMathPerlinHelpers; + const float Xfl = FMath::FloorToFloat(static_cast(Location.X)); + const float Yfl = FMath::FloorToFloat(static_cast(Location.Y)); + const int32 Xi = static_cast(Xfl) & 255; + const int32 Yi = static_cast(Yfl) & 255; + const float X = static_cast(Location.X) - Xfl; + const float Y = static_cast(Location.Y) - Yfl; + const float Xm1 = X - 1.0f; + const float Ym1 = Y - 1.0f; + const int32* P = Permutation; + const int32 AA = P[Xi] + Yi; + const int32 AB = AA + 1; + const int32 BA = P[Xi + 1] + Yi; + const int32 BB = BA + 1; + const float U = SmoothCurve(X); + const float V = SmoothCurve(Y); + return FMath::Lerp( + FMath::Lerp(Grad2(P[AA], X, Y), Grad2(P[BA], Xm1, Y), U), + FMath::Lerp(Grad2(P[AB], X, Ym1), Grad2(P[BB], Xm1, Ym1), U), + V); +} + +float FMath::PerlinNoise3D(const FVector& Location) +{ + using namespace FMathPerlinHelpers; + const float Xfl = FMath::FloorToFloat(static_cast(Location.X)); + const float Yfl = FMath::FloorToFloat(static_cast(Location.Y)); + const float Zfl = FMath::FloorToFloat(static_cast(Location.Z)); + const int32 Xi = static_cast(Xfl) & 255; + const int32 Yi = static_cast(Yfl) & 255; + const int32 Zi = static_cast(Zfl) & 255; + const float X = static_cast(Location.X) - Xfl; + const float Y = static_cast(Location.Y) - Yfl; + const float Z = static_cast(Location.Z) - Zfl; + const float Xm1 = X - 1.0f; + const float Ym1 = Y - 1.0f; + const float Zm1 = Z - 1.0f; + const int32* P = Permutation; + const int32 A = P[Xi] + Yi; + const int32 AA = P[A] + Zi; + const int32 AB = P[A + 1] + Zi; + const int32 B = P[Xi + 1] + Yi; + const int32 BA = P[B] + Zi; + const int32 BB = P[B + 1] + Zi; + const float U = SmoothCurve(X); + const float V = SmoothCurve(Y); + const float W = SmoothCurve(Z); + const float Value = 0.97f * FMath::Lerp( + FMath::Lerp( + FMath::Lerp(Grad3(P[AA], X, Y, Z), Grad3(P[BA], Xm1, Y, Z), U), + FMath::Lerp(Grad3(P[AB], X, Ym1, Z), Grad3(P[BB], Xm1, Ym1, Z), U), + V), + FMath::Lerp( + FMath::Lerp(Grad3(P[AA + 1], X, Y, Zm1), Grad3(P[BA + 1], Xm1, Y, Zm1), U), + FMath::Lerp(Grad3(P[AB + 1], X, Ym1, Zm1), Grad3(P[BB + 1], Xm1, Ym1, Zm1), U), + V), + W); + return FMath::Clamp(Value, -1.0f, 1.0f); +} diff --git a/Standalone/Compat/UnrealPerlinHelpers.inl b/Standalone/Compat/UnrealPerlinHelpers.inl new file mode 100644 index 0000000..e268f24 --- /dev/null +++ b/Standalone/Compat/UnrealPerlinHelpers.inl @@ -0,0 +1,82 @@ +// Implementation of 1D, 2D and 3D Perlin noise based on Ken Perlin's improved version https://mrl.nyu.edu/~perlin/noise/ +// (See Random3.tps for additional third party software info.) +namespace FMathPerlinHelpers +{ + // random permutation of 256 numbers, repeated 2x + static const int32 Permutation[512] = { + 63, 9, 212, 205, 31, 128, 72, 59, 137, 203, 195, 170, 181, 115, 165, 40, 116, 139, 175, 225, 132, 99, 222, 2, 41, 15, 197, 93, 169, 90, 228, 43, 221, 38, 206, 204, 73, 17, 97, 10, 96, 47, 32, 138, 136, 30, 219, + 78, 224, 13, 193, 88, 134, 211, 7, 112, 176, 19, 106, 83, 75, 217, 85, 0, 98, 140, 229, 80, 118, 151, 117, 251, 103, 242, 81, 238, 172, 82, 110, 4, 227, 77, 243, 46, 12, 189, 34, 188, 200, 161, 68, 76, 171, 194, + 57, 48, 247, 233, 51, 105, 5, 23, 42, 50, 216, 45, 239, 148, 249, 84, 70, 125, 108, 241, 62, 66, 64, 240, 173, 185, 250, 49, 6, 37, 26, 21, 244, 60, 223, 255, 16, 145, 27, 109, 58, 102, 142, 253, 120, 149, 160, + 124, 156, 79, 186, 135, 127, 14, 121, 22, 65, 54, 153, 91, 213, 174, 24, 252, 131, 192, 190, 202, 208, 35, 94, 231, 56, 95, 183, 163, 111, 147, 25, 67, 36, 92, 236, 71, 166, 1, 187, 100, 130, 143, 237, 178, 158, + 104, 184, 159, 177, 52, 214, 230, 119, 87, 114, 201, 179, 198, 3, 248, 182, 39, 11, 152, 196, 113, 20, 232, 69, 141, 207, 234, 53, 86, 180, 226, 74, 150, 218, 29, 133, 8, 44, 123, 28, 146, 89, 101, 154, 220, 126, + 155, 122, 210, 168, 254, 162, 129, 33, 18, 209, 61, 191, 199, 157, 245, 55, 164, 167, 215, 246, 144, 107, 235, + + 63, 9, 212, 205, 31, 128, 72, 59, 137, 203, 195, 170, 181, 115, 165, 40, 116, 139, 175, 225, 132, 99, 222, 2, 41, 15, 197, 93, 169, 90, 228, 43, 221, 38, 206, 204, 73, 17, 97, 10, 96, 47, 32, 138, 136, 30, 219, + 78, 224, 13, 193, 88, 134, 211, 7, 112, 176, 19, 106, 83, 75, 217, 85, 0, 98, 140, 229, 80, 118, 151, 117, 251, 103, 242, 81, 238, 172, 82, 110, 4, 227, 77, 243, 46, 12, 189, 34, 188, 200, 161, 68, 76, 171, 194, + 57, 48, 247, 233, 51, 105, 5, 23, 42, 50, 216, 45, 239, 148, 249, 84, 70, 125, 108, 241, 62, 66, 64, 240, 173, 185, 250, 49, 6, 37, 26, 21, 244, 60, 223, 255, 16, 145, 27, 109, 58, 102, 142, 253, 120, 149, 160, + 124, 156, 79, 186, 135, 127, 14, 121, 22, 65, 54, 153, 91, 213, 174, 24, 252, 131, 192, 190, 202, 208, 35, 94, 231, 56, 95, 183, 163, 111, 147, 25, 67, 36, 92, 236, 71, 166, 1, 187, 100, 130, 143, 237, 178, 158, + 104, 184, 159, 177, 52, 214, 230, 119, 87, 114, 201, 179, 198, 3, 248, 182, 39, 11, 152, 196, 113, 20, 232, 69, 141, 207, 234, 53, 86, 180, 226, 74, 150, 218, 29, 133, 8, 44, 123, 28, 146, 89, 101, 154, 220, 126, + 155, 122, 210, 168, 254, 162, 129, 33, 18, 209, 61, 191, 199, 157, 245, 55, 164, 167, 215, 246, 144, 107, 235 + }; + + // Gradient functions for 1D, 2D and 3D Perlin noise + + FORCEINLINE float Grad1(int32 Hash, float X) + { + // Slicing Perlin's 3D improved noise would give us only scales of -1, 0 and 1; this looks pretty bad so let's use a different sampling + static const float Grad1Scales[16] = {-8/8, -7/8., -6/8., -5/8., -4/8., -3/8., -2/8., -1/8., 1/8., 2/8., 3/8., 4/8., 5/8., 6/8., 7/8., 8/8}; + return Grad1Scales[Hash & 15] * X; + } + + // Note: If you change the Grad2 or Grad3 functions, check that you don't change the range of the resulting noise as well; it should be (within floating point error) in the range of (-1, 1) + FORCEINLINE float Grad2(int32 Hash, float X, float Y) + { + // corners and major axes (similar to the z=0 projection of the cube-edge-midpoint sampling from improved Perlin noise) + switch (Hash & 7) + { + case 0: return X; + case 1: return X + Y; + case 2: return Y; + case 3: return -X + Y; + case 4: return -X; + case 5: return -X - Y; + case 6: return -Y; + case 7: return X - Y; + // can't happen + default: return 0; + } + } + + FORCEINLINE float Grad3(int32 Hash, float X, float Y, float Z) + { + switch (Hash & 15) + { + // 12 cube midpoints + case 0: return X + Z; + case 1: return X + Y; + case 2: return Y + Z; + case 3: return -X + Y; + case 4: return -X + Z; + case 5: return -X - Y; + case 6: return -Y + Z; + case 7: return X - Y; + case 8: return X - Z; + case 9: return Y - Z; + case 10: return -X - Z; + case 11: return -Y - Z; + // 4 vertices of regular tetrahedron + case 12: return X + Y; + case 13: return -X + Y; + case 14: return -Y + Z; + case 15: return -Y - Z; + // can't happen + default: return 0; + } + } + + // Curve w/ second derivative vanishing at 0 and 1, from Perlin's improved noise paper + FORCEINLINE float SmoothCurve(float X) + { + return X * X * X * (X * (X * 6.0f - 15.0f) + 10.0f); + } +}; \ No newline at end of file diff --git a/Standalone/Compat/VoxelBiomeDefinition.generated.h b/Standalone/Compat/VoxelBiomeDefinition.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelBiomeDefinition.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelBiomeTypes.generated.h b/Standalone/Compat/VoxelBiomeTypes.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelBiomeTypes.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelDiffLayer.generated.h b/Standalone/Compat/VoxelDiffLayer.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelDiffLayer.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelGenerator.generated.h b/Standalone/Compat/VoxelGenerator.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelGenerator.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelMarchingCubesMesher.generated.h b/Standalone/Compat/VoxelMarchingCubesMesher.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelMarchingCubesMesher.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelSeasonAsset.generated.h b/Standalone/Compat/VoxelSeasonAsset.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelSeasonAsset.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelSettings.generated.h b/Standalone/Compat/VoxelSettings.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelSettings.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelStrateComposer.generated.h b/Standalone/Compat/VoxelStrateComposer.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelStrateComposer.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelStrateDefinition.generated.h b/Standalone/Compat/VoxelStrateDefinition.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelStrateDefinition.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelStrateManager.generated.h b/Standalone/Compat/VoxelStrateManager.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelStrateManager.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelStrateTypes.generated.h b/Standalone/Compat/VoxelStrateTypes.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelStrateTypes.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/Compat/VoxelTerrainOpDefinition.generated.h b/Standalone/Compat/VoxelTerrainOpDefinition.generated.h new file mode 100644 index 0000000..7b9637e --- /dev/null +++ b/Standalone/Compat/VoxelTerrainOpDefinition.generated.h @@ -0,0 +1 @@ +#pragma once \ No newline at end of file diff --git a/Standalone/README.md b/Standalone/README.md new file mode 100644 index 0000000..d5df7a2 --- /dev/null +++ b/Standalone/README.md @@ -0,0 +1,100 @@ +# VoxelForge standalone world + +This C++20 program loads the owner snapshot from `data/owner_world.json`, runs the copied +VoxelForge density pipeline and marching-cubes mesher, builds all clipmap levels with six worker +threads, and displays them through Direct3D 11. Unreal is not linked or needed at run time. + +## Build and launch + +From the plugin root, run: + +```powershell +.\Standalone\Build.ps1 +.\Standalone\bin\VoxelForgeStandalone.exe +``` + +The build uses the configured VS 2022 `vcvars64.bat`, MSVC `/std:c++20 /O2 /arch:AVX2 /fp:precise`. +The executable finds the JSON beside itself under `Standalone\data\`. + +## Controls + +- Hold right mouse button and move to look. +- WASD moves; Space/Ctrl or E/Q moves vertically; Shift moves faster; the wheel changes speed. +- F toggles wireframe. +- 1 through 7 toggle clipmap levels 0 through 6 individually; 0 toggles every level. +- N/P changes the seed. T switches between the owner's base tunnels and the research-shaped round, + winding tunnel option. Either action rebuilds the clipmap. Moving three chunks from its center + also recenters and rebuilds it. +- Esc exits. + +The console reports density parity at startup, each level's generation time, and the total time. + +## Copied code and compatibility layer + +`Source\VoxelForge\` contains the plugin's density evaluator and operator stack, cave morphology, +passage geometry, strate definitions/layout/manager/composer, noise, voxel math and types, +marching-cubes tables/mesher, and clipmap tile selection. These are copies. Plugin runtime source +is untouched apart from the allowed editor commandlet export addition. + +`Compat\CoreMinimal.h` supplies the UE-style arrays/maps/sets, vectors and boxes, math helpers, +strings, smart pointers, locks, logging/stat macros, UObject-like data holders, and platform helpers +needed by the copied CPU code. The `Compat` subfolders cover the UE include paths used by those +copies. Perlin 2D/3D uses the copied UE 5.7 implementation in `UnrealPerlin.cpp` and +`UnrealPerlinHelpers.inl`. `OwnerWorldLoader.h` maps the exported settings, resolved strate +definitions, biomes, and terrain operations into copied holders; soft references resolve through a +small local asset registry. The copied strate manager reconstructs runtime passages from that data +and seed. The export also records their geometry in JSON for reference. + +## Behavior differences + +- Standalone `VOXEL_SIZE` is 25 cm; the plugin's checked-out value remains 12.5 cm. No other voxel + conversion constant needed changing. Mesh vertices stay in centimetres in the copied mesher and + the viewer converts them to metres at upload. +- The compatibility `TMap` and `TSet` are vector-backed containers, so lookup is linear and their + iteration follows insertion order rather than Unreal hash-table order. Owner strate slots and + passages are read from ordered JSON arrays. Perlin noise and deterministic `DetSinCos` are copied + unchanged; MSVC uses `/fp:precise` without fast-math/FMA contraction. +- Unreal reflection and UObject construction are replaced by explicit JSON-to-holder mapping. + Density-relevant settings, resolved strate layouts, passages, biome parameters, and terrain-op + assets are loaded. Runtime actor/material assets are not instantiated; the viewer uses one lit + rock material for all generated geometry. +- The owner export has no active season. Standalone season loading is not implemented; an active + season would need to be exported and mapped before it could affect this viewer. +- `WITH_EDITOR` research/debug branches, Unreal logging, startup trace, profiling counters, and + reflection metadata do not run in the standalone. The base generator path is the default. +- T selects a standalone-only tunnel-shape port. It disables the owner's first-strate tunnel + connections and adds the research field's cubic winding centerline and round elliptical tunnel + profile (144-voxel pitch, 28-voxel radius, 26-voxel half-height, deterministic variable radius + and vertical wave), anchored around the camera's current height. It reuses the base rooms and + passages; it does not port the research field's room planner or full parameterized density field. + +## Owner data and parity + +`data/owner_world.json` came from one default-off `VoxelForgeExploreCommandlet` export using the +guarded `Tools\VoxelForgeTest.ps1 -Scenario standalone-export -Build -Assets owner` path. The +snapshot contains the resolved settings/strates/passages and 3,436 plugin density samples. For that +commandlet build, `VOXEL_SIZE` was temporarily set to 25 cm in the worktree and restored afterward; +the exported JSON records 25 cm and the checked-out plugin source still records 12.5 cm. + +Standalone comparison: **3,436/3,436 bit-identical, zero mismatches**. Samples covered strate +boundaries (2,420), varied world coordinates including negative and far-from-origin points (768), +tunnel interiors (222), room interiors (12), inter-strate passages (10), and passage landings (4). + +## Baseline clipmap timing + +Owner seed 0, base world, 25 cm voxels, six workers; the final viewer run generated the full +seven-level clipmap (12,054 tiles, 6,605,189 vertices, 36,307,137 indices) in 515.117 seconds. +These level timers cover CPU meshing and queue handoff; D3D11 buffer upload is separate. An earlier +headless pass over the same tiles took 408.719 seconds, so use one run mode consistently when +comparing later speed changes. + +| Level | Seconds | Tiles | Vertices | Density samples | +|---:|---:|---:|---:|---:| +| 0 | 70.201 | 2,744 | 957,655 | 117.65 M | +| 1 | 65.376 | 2,009 | 657,423 | 86.14 M | +| 2 | 9.042 | 1,274 | 292,267 | 8.74 M | +| 3 | 25.689 | 588 | 235,755 | 4.03 M | +| 4 | 24.248 | 441 | 229,720 | 3.02 M | +| 5 | 23.809 | 294 | 199,503 | 2.02 M | +| 6 | 296.304 | 4,704 | 4,032,866 | 32.26 M | +| **Total** | **515.117** | **12,054** | **6,605,189** | **253.86 M** | diff --git a/Standalone/Source/OwnerWorldLoader.h b/Standalone/Source/OwnerWorldLoader.h new file mode 100644 index 0000000..f1d131b --- /dev/null +++ b/Standalone/Source/OwnerWorldLoader.h @@ -0,0 +1,323 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Json.h" +#include "VoxelBiomeDefinition.h" +#include "VoxelGenerator.h" +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelTerrainOpDefinition.h" + +struct FStandaloneParitySample +{ + FVector PositionVoxels; + float PluginDensity = 0.0f; + std::string Kind; + int32 StrateIndex = INDEX_NONE; +}; + +struct FStandaloneOwnerWorld +{ + std::unique_ptr Settings; + std::unique_ptr Manager; + std::unique_ptr Generator; + std::vector> DefinitionStorage; + std::vector> BiomeStorage; + std::vector> OperationStorage; + std::vector DensitySamples; + FVector ViewerStartVoxels = FVector::ZeroVector; + bool ViewerStartFound = false; + int32 Seed = 0; + int32 ClipRadius = 6; + int32 MaxClipLevel = 6; + int32 FullResClipLevels = 2; + int32 CoarseTileCells = 16; + int32 RenderDistanceChunks = 1536; + int32 StrateContentCutMinLevel = 9; + bool bGenerateSkirts = true; + int32 SkirtCells = 2; + int32 LODOctaveDrop = 0; +}; + +namespace StandaloneOwnerLoader +{ + template + inline void ReadField(const VFJson::Value& Object, const char* Name, T& Out) + { + const VFJson::Value& Value = Object.Get(Name); + if constexpr (std::is_same_v) + { + if (std::holds_alternative(Value.Data)) Out = Value.Boolean(Out); + else if (Value.IsNumber()) Out = Value.Number() != 0.0; + } + else if constexpr (std::is_enum_v) + { + if (Value.IsNumber()) Out = static_cast(Value.Integer()); + } + else if constexpr (std::is_arithmetic_v) + { + if (Value.IsNumber()) Out = static_cast(Value.Number()); + } + } + + inline void ReadText(const VFJson::Value& Object, const char* Name, FText& Out) + { + const VFJson::Value& Value = Object.Get(Name); + if (Value.IsString()) Out = FText::FromString(FString(Value.String())); + } + + inline void ReadLinearColor(const VFJson::Value& Object, FLinearColor& Out) + { + ReadField(Object, "R", Out.R); ReadField(Object, "G", Out.G); + ReadField(Object, "B", Out.B); ReadField(Object, "A", Out.A); + } + + inline void ReadVector(const VFJson::Value& Object, FVector& Out) + { + ReadField(Object, "x", Out.X); ReadField(Object, "y", Out.Y); ReadField(Object, "z", Out.Z); + } + + inline void ReadGenerationParams(const VFJson::Value& J, FStrateGenerationParams& P) + { +#define VF_LOAD_STRATE_FIELD(Name) ReadField(J, #Name, P.Name); + VF_STRATE_PARAM_FIELDS(VF_LOAD_STRATE_FIELD, VF_LOAD_STRATE_FIELD) +#undef VF_LOAD_STRATE_FIELD + ReadField(J, "bTunnelFloorEnabled", P.bTunnelFloorEnabled); + } + + inline void ReadDisturbances(const VFJson::Value& J, FStrateDisturbanceParams& P) + { + ReadField(J,"ChasmDensity",P.ChasmDensity); ReadField(J,"ChasmSpacing",P.ChasmSpacing); + ReadField(J,"ChasmRadius",P.ChasmRadius); ReadField(J,"BridgeDensity",P.BridgeDensity); + ReadField(J,"BridgeSpacing",P.BridgeSpacing); ReadField(J,"BridgeRadius",P.BridgeRadius); + ReadField(J,"RidgeDensity",P.RidgeDensity); ReadField(J,"RidgeSpacing",P.RidgeSpacing); + ReadField(J,"RidgeHeight",P.RidgeHeight); ReadField(J,"RidgeThickness",P.RidgeThickness); + ReadField(J,"BaseDensity",P.BaseDensity); ReadField(J,"BoundarySealThickness",P.BoundarySealThickness); + } + + inline void ReadPassageConfig(const VFJson::Value& J, FStratePassageConfig& P) + { + ReadField(J,"Connections",P.Connections); ReadField(J,"Style",P.Style); + ReadField(J,"MouthRadius",P.MouthRadius); ReadField(J,"MidRadius",P.MidRadius); + ReadField(J,"ReachMin",P.ReachMin); ReadField(J,"ReachMax",P.ReachMax); + ReadField(J,"DistanceMin",P.DistanceMin); ReadField(J,"DistanceMax",P.DistanceMax); + ReadField(J,"Wander",P.Wander); ReadField(J,"Segments",P.Segments); + ReadField(J,"VerticalWobble",P.VerticalWobble); ReadField(J,"SpiralRadius",P.SpiralRadius); + ReadField(J,"SpiralTurns",P.SpiralTurns); ReadField(J,"CascadeSteps",P.CascadeSteps); + ReadField(J,"CascadeLedge",P.CascadeLedge); + } + + inline void ReadSurfaceParams(const VFJson::Value& J, FSurfaceGenerationParams& P) + { +#define VF_SURFACE_FIELD(Name) ReadField(J, #Name, P.Name); + VF_SURFACE_FIELD(BaseGroundRelative) VF_SURFACE_FIELD(ElevationRange) + VF_SURFACE_FIELD(ContinentFrequency) VF_SURFACE_FIELD(MountainStrength) + VF_SURFACE_FIELD(MountainFrequency) VF_SURFACE_FIELD(DetailFrequency) + VF_SURFACE_FIELD(SurfaceRoughness) VF_SURFACE_FIELD(HeightWarpStrength) + VF_SURFACE_FIELD(HeightWarpFrequency) VF_SURFACE_FIELD(ReliefFrequency) + VF_SURFACE_FIELD(ReliefStrength) VF_SURFACE_FIELD(ReliefContrast) + VF_SURFACE_FIELD(TerraceStrength) VF_SURFACE_FIELD(TerraceHeight) + VF_SURFACE_FIELD(TerraceHardness) VF_SURFACE_FIELD(LayerLineDepth) + VF_SURFACE_FIELD(LayerLineSpacing) VF_SURFACE_FIELD(CliffStrength) + VF_SURFACE_FIELD(CliffSlopeThreshold) VF_SURFACE_FIELD(CliffSharpness) + VF_SURFACE_FIELD(CliffSampleDist) VF_SURFACE_FIELD(OverhangStrength) + VF_SURFACE_FIELD(OverhangReach) VF_SURFACE_FIELD(OverhangHeight) + VF_SURFACE_FIELD(OverhangFrequency) VF_SURFACE_FIELD(OverhangZScale) + VF_SURFACE_FIELD(OverhangSlopeThreshold) VF_SURFACE_FIELD(WaterLevelRelative) + VF_SURFACE_FIELD(BeachWidth) VF_SURFACE_FIELD(CeilingRelative) + VF_SURFACE_FIELD(CeilingRoughness) VF_SURFACE_FIELD(CeilingRoughnessFrequency) + VF_SURFACE_FIELD(CeilingUndulation) VF_SURFACE_FIELD(CeilingUndulationFrequency) + VF_SURFACE_FIELD(CeilingRidgeStrength) VF_SURFACE_FIELD(CeilingRidgeFrequency) + VF_SURFACE_FIELD(CeilingWarpStrength) VF_SURFACE_FIELD(CeilingWarpFrequency) + VF_SURFACE_FIELD(BoundarySealThickness) VF_SURFACE_FIELD(BaseDensity) +#undef VF_SURFACE_FIELD + } + + inline void ReadBiomeMap(const VFJson::Value& J, FBiomeMapParams& P) + { + ReadField(J,"CellSize",P.CellSize); ReadField(J,"WarpStrength",P.WarpStrength); + ReadField(J,"WarpFrequency",P.WarpFrequency); ReadField(J,"BorderBlend",P.BorderBlend); + ReadField(J,"ReliefFrequency",P.ReliefFrequency); ReadField(J,"ReliefContrast",P.ReliefContrast); + ReadField(J,"MoistureFrequency",P.MoistureFrequency); + } + + inline void ReadOperation(const VFJson::Value& J, UVoxelTerrainOpDefinition& O) + { + ReadField(J,"Type",O.Type); +#define VF_OPERATION_FIELD(Name) ReadField(J, #Name, O.Name); + VF_OPERATION_FIELD(TerraceStepHeight) VF_OPERATION_FIELD(TerraceHardness) + VF_OPERATION_FIELD(TerraceNoiseDisplacement) VF_OPERATION_FIELD(LayerLineSpacing) + VF_OPERATION_FIELD(LayerLineDepth) VF_OPERATION_FIELD(RibbingSpacing) + VF_OPERATION_FIELD(RibbingDepth) VF_OPERATION_FIELD(CliffStrength) + VF_OPERATION_FIELD(ScallopStrength) VF_OPERATION_FIELD(ScallopFrequency) + VF_OPERATION_FIELD(OverhangStrength) VF_OPERATION_FIELD(OverhangDepth) + VF_OPERATION_FIELD(OverhangFrequency) VF_OPERATION_FIELD(ArchDensity) + VF_OPERATION_FIELD(ArchMinRadius) VF_OPERATION_FIELD(ArchMaxRadius) + VF_OPERATION_FIELD(ColumnDensity) VF_OPERATION_FIELD(ColumnMinRadius) + VF_OPERATION_FIELD(ColumnMaxRadius) VF_OPERATION_FIELD(PitDensity) + VF_OPERATION_FIELD(PitMinRadius) VF_OPERATION_FIELD(PitMaxRadius) + VF_OPERATION_FIELD(PitDepth) VF_OPERATION_FIELD(ChimneyDensity) + VF_OPERATION_FIELD(ChimneyMinRadius) VF_OPERATION_FIELD(ChimneyMaxRadius) + VF_OPERATION_FIELD(ChimneyHeight) VF_OPERATION_FIELD(DomeDensity) + VF_OPERATION_FIELD(DomeMinRadius) VF_OPERATION_FIELD(DomeMaxRadius) + VF_OPERATION_FIELD(DomeHeightRatio) VF_OPERATION_FIELD(PinchDensity) + VF_OPERATION_FIELD(PinchStrength) VF_OPERATION_FIELD(PinchLength) +#undef VF_OPERATION_FIELD + } + + inline void ReadBiome(const VFJson::Value& J, UVoxelBiomeDefinition& B) + { + ReadText(J,"BiomeName",B.BiomeName); ReadText(J,"BiomeDescription",B.BiomeDescription); + ReadLinearColor(J.Get("DebugColor"),B.DebugColor); + ReadField(J,"ReliefMin",B.ReliefMin); ReadField(J,"ReliefMax",B.ReliefMax); + ReadField(J,"MoistureMin",B.MoistureMin); ReadField(J,"MoistureMax",B.MoistureMax); + ReadField(J,"bOverrideTerrain",B.bOverrideTerrain); ReadField(J,"GeneratorType",B.GeneratorType); + ReadSurfaceParams(J.Get("SurfaceParams"),B.SurfaceParams); + ReadField(J,"bOverrideAtmosphere",B.bOverrideAtmosphere); + ReadLinearColor(J.Get("FogColor"),B.FogColor); ReadField(J,"FogDensity",B.FogDensity); + ReadLinearColor(J.Get("AmbientLightColor"),B.AmbientLightColor); + ReadField(J,"AmbientLightIntensity",B.AmbientLightIntensity); + ReadField(J,"MaterialPaletteIndex",B.MaterialPaletteIndex); + } + + inline UVoxelStrateDefinition* ReadDefinition( + FStandaloneOwnerWorld& World, const VFJson::Value& Slot, int32 SlotIndex) + { + const VFJson::Value& J = Slot.Get("asset"); + auto Definition = std::make_unique(); + ReadText(J,"StrateName",Definition->StrateName); + ReadText(J,"StrateDescription",Definition->StrateDescription); + ReadField(J,"StrateHeightInChunks",Definition->StrateHeightInChunks); + ReadField(J,"TransitionType",Definition->TransitionType); + ReadField(J,"TransitionBlendChunks",Definition->TransitionBlendChunks); + ReadField(J,"GeneratorType",Definition->GeneratorType); + ReadField(J,"bEnableWorms",Definition->bEnableWorms); + ReadGenerationParams(Slot.Get("generation_params"),Definition->GenerationParams); + ReadDisturbances(Slot.Get("disturbances"),Definition->Disturbances); + ReadPassageConfig(Slot.Get("passage_config"),Definition->PassageConfig); + ReadSurfaceParams(J.Get("SurfaceParams"),Definition->SurfaceParams); + ReadBiomeMap(J.Get("BiomeMapParams"),Definition->BiomeMapParams); + ReadField(J,"bHasWater",Definition->bHasWater); + + const auto& Biomes = J.Get("Biomes").AsArray(); + Definition->Biomes.Reserve(static_cast(Biomes.size())); + for (const VFJson::Value& BiomeJson : Biomes) + { + auto Biome = std::make_unique(); + ReadBiome(BiomeJson,*Biome); + Definition->Biomes.Add(Biome.get()); + World.BiomeStorage.push_back(std::move(Biome)); + } + + const auto& OperationEntries = Slot.Get("terrain_operation_assets").AsArray(); + Definition->TerrainOperations.Reserve(static_cast(OperationEntries.size())); + for (int32 I = 0; I < static_cast(OperationEntries.size()); ++I) + { + const VFJson::Value& EntryJson = OperationEntries[static_cast(I)]; + const VFJson::Value& AssetJson = EntryJson.Get("asset"); + const VFJson::Value& PathJson = EntryJson.Get("asset_path"); + const FString Path = PathJson.IsString() ? FString(PathJson.String()) : FString{}; + FStrateTerrainOpEntry Entry; + ReadField(EntryJson,"weight",Entry.Weight); + ReadField(EntryJson,"probability",Entry.Probability); + if (AssetJson.IsObject()) + { + auto Operation = std::make_unique(); + ReadOperation(AssetJson,*Operation); + UVoxelTerrainOpDefinition* Raw = Operation.get(); + World.OperationStorage.push_back(std::move(Operation)); + Entry.Operation.Set(Raw,Path); + RegisterStandaloneAsset(Path,Raw); + } + else if (!Path.IsEmpty()) Entry.Operation = TSoftObjectPtr(Path); + Definition->TerrainOperations.Add(std::move(Entry)); + } + + const FString SyntheticPath = FString::Printf("/Standalone/Strate_%d",SlotIndex); + Definition->PostLoad(); + UVoxelStrateDefinition* Raw = Definition.get(); + World.DefinitionStorage.push_back(std::move(Definition)); + RegisterStandaloneAsset(SyntheticPath,Raw); + return Raw; + } + + inline bool Load(const std::string& Path, FStandaloneOwnerWorld& Out, std::string& Error) + { + VFJson::Value Root; + if (!VFJson::Load(Path,Root,Error)) return false; + if (Root.Get("voxel_size_cm").Number() != VOXEL_SIZE) + { + Error = "export voxel_size_cm does not match the standalone VOXEL_SIZE"; + return false; + } + Out.Seed = static_cast(Root.Get("seed").Integer()); + Out.ClipRadius = static_cast(Root.Get("clip_radius").Integer(6)); + Out.MaxClipLevel = static_cast(Root.Get("max_clip_level").Integer(6)); + Out.FullResClipLevels = static_cast(Root.Get("full_res_clip_levels").Integer(2)); + Out.CoarseTileCells = static_cast(Root.Get("coarse_tile_cells").Integer(16)); + Out.RenderDistanceChunks = static_cast(Root.Get("render_distance_chunks").Integer(1536)); + Out.StrateContentCutMinLevel = static_cast(Root.Get("strate_content_cut_min_level").Integer(9)); + Out.bGenerateSkirts = Root.Get("generate_skirts").Boolean(true); + Out.SkirtCells = static_cast(Root.Get("skirt_cells").Integer(2)); + Out.LODOctaveDrop = static_cast(Root.Get("lod_octave_drop").Integer(0)); + ReadVector(Root.Get("viewer_start"),Out.ViewerStartVoxels); + Out.ViewerStartFound = Root.Get("viewer_start").Get("found_open_space").Boolean(); + for (const VFJson::Value& SampleJson : Root.Get("density_samples").AsArray()) + { + FStandaloneParitySample Sample; + ReadField(SampleJson,"x",Sample.PositionVoxels.X); + ReadField(SampleJson,"y",Sample.PositionVoxels.Y); + ReadField(SampleJson,"z",Sample.PositionVoxels.Z); + ReadField(SampleJson,"density",Sample.PluginDensity); + ReadField(SampleJson,"strate_index",Sample.StrateIndex); + Sample.Kind = SampleJson.Get("kind").String(); + Out.DensitySamples.push_back(std::move(Sample)); + } + + Out.Settings = std::make_unique(); + const VFJson::Value& SettingsJson = Root.Get("settings_asset"); + Out.Settings->Seed = Out.Seed; + ReadField(Root,"origin_spine_radius",Out.Settings->OriginSpineRadius); + ReadField(Root,"world_radius_voxels",Out.Settings->WorldRadiusVoxels); + ReadField(Root,"edge_seal_thickness",Out.Settings->EdgeSealThickness); + ReadField(Root,"open_surface_entry",Out.Settings->bOpenSurfaceEntry); + ReadField(Root,"inter_strate_gap_chunks",Out.Settings->InterStrateGapChunks); + ReadField(SettingsJson,"total_strates",Out.Settings->TotalStrates); + ReadField(Root,"clip_radius",Out.Settings->ClipRadius); + ReadField(Root,"max_clip_level",Out.Settings->MaxClipLevel); + ReadField(Root,"full_res_clip_levels",Out.Settings->FullResClipLevels); + ReadField(Root,"coarse_tile_cells",Out.Settings->CoarseTileCells); + ReadField(Root,"render_distance_chunks",Out.Settings->RenderDistanceChunks); + ReadField(Root,"generate_skirts",Out.Settings->bGenerateSkirts); + ReadField(Root,"skirt_cells",Out.Settings->SkirtCells); + ReadField(Root,"lod_octave_drop",Out.Settings->LODOctaveDrop); + ReadField(Root,"strate_content_cut_min_level",Out.Settings->StrateContentCutMinLevel); + ReadField(SettingsJson,"TotalStrates",Out.Settings->TotalStrates); + ReadField(SettingsJson,"InterStrateGapChunks",Out.Settings->InterStrateGapChunks); + Out.Settings->StratePool.Reset(); + Out.Settings->FixedStrates.Empty(); + const auto& Strates = Root.Get("strates").AsArray(); + Out.Settings->TotalStrates = static_cast(Strates.size()); + for (int32 I = 0; I < static_cast(Strates.size()); ++I) + { + UVoxelStrateDefinition* Definition = ReadDefinition(Out,Strates[static_cast(I)],I); + const FString SyntheticPath = FString::Printf("/Standalone/Strate_%d",I); + TSoftObjectPtr SoftDefinition; + SoftDefinition.Set(Definition,SyntheticPath); + Out.Settings->FixedStrates.Add(I,std::move(SoftDefinition)); + } + if (Strates.empty()) { Error = "owner JSON contains no resolved strates"; return false; } + + Out.Manager = std::make_unique(); + if (!Out.Manager->Initialize(Out.Settings.get(),Out.Seed)) + { + Error = "copied strate manager rejected the owner settings snapshot"; + return false; + } + Out.Generator = std::make_unique(); + Out.Generator->InitializeSettings(Out.Settings.get()); + Out.Generator->SetStrateManager(Out.Manager.get()); + return true; + } +} diff --git a/Standalone/Source/StandaloneResearchTunnels.h b/Standalone/Source/StandaloneResearchTunnels.h new file mode 100644 index 0000000..ae5cbc3 --- /dev/null +++ b/Standalone/Source/StandaloneResearchTunnels.h @@ -0,0 +1,128 @@ +#pragma once + +#include + +// Standalone-only port of the research field's round, winding tunnel profile. +// The cubic centerline, variable radius and elliptical vertical section follow +// GeneratedTunnelEdgeFromColumnProfile in VoxelRedefinedDensityField.cpp. +namespace FStandaloneResearchTunnels +{ + inline std::atomic Enabled{false}; + inline std::atomic CenterZVoxels{0.0f}; + + inline uint32 Mix(uint32 X) + { + X ^= X >> 16; + X *= 0x7FEB352Du; + X ^= X >> 15; + X *= 0x846CA68Bu; + X ^= X >> 16; + return X; + } + + inline uint32 EdgeHash(int32 X, int32 Y, int32 Axis, uint32 Seed) + { + uint32 H = Seed ^ 0x9E3779B9u; + H = Mix(H ^ static_cast(X) * 0x85EBCA6Bu); + H = Mix(H ^ static_cast(Y) * 0xC2B2AE35u); + return Mix(H ^ static_cast(Axis) * 0x27D4EB2Fu); + } + + inline float Unit(uint32 X) + { + return static_cast(Mix(X) >> 8) * (1.0f / 16777216.0f); + } + + inline void Set(bool bEnabled, float CenterZ) + { + CenterZVoxels.store(CenterZ,std::memory_order_relaxed); + Enabled.store(bEnabled,std::memory_order_release); + } + + inline float Apply(float BaseDensity, float X, float Y, float Z, uint32 Seed) + { + if (!Enabled.load(std::memory_order_acquire)) return BaseDensity; + const float CenterZ = CenterZVoxels.load(std::memory_order_relaxed); + if (FMath::Abs(Z-CenterZ) > 36.0f) return BaseDensity; + + constexpr float Pitch = 144.0f; + constexpr float RadiusBase = 28.0f; + constexpr float HalfHeight = 26.0f; + const int32 BaseCellX = FMath::FloorToInt(X/Pitch); + const int32 BaseCellY = FMath::FloorToInt(Y/Pitch); + float Best = -FLT_MAX; + bool bHasEdge = false; + const auto CellCenter = [&](int32 CX, int32 CY, float& OutX, float& OutY) + { + const uint32 H = EdgeHash(CX,CY,3,Seed); + OutX = (static_cast(CX)+0.5f)*Pitch + + (Unit(H^0xA511E9B3u)-0.5f)*Pitch*0.18f; + OutY = (static_cast(CY)+0.5f)*Pitch + + (Unit(H^0x63D83595u)-0.5f)*Pitch*0.18f; + }; + const auto EvaluateEdge = [&](int32 CX, int32 CY, int32 Axis) + { + const uint32 H = EdgeHash(CX,CY,Axis,Seed); + if (Unit(H^0x68E31DA4u) > 0.56f) return; + bHasEdge = true; + const int32 NX = CX + (Axis == 0 ? 1 : 0); + const int32 NY = CY + (Axis == 1 ? 1 : 0); + float AX,AY,BX,BY; + CellCenter(CX,CY,AX,AY); + CellCenter(NX,NY,BX,BY); + + const float ABX = BX-AX, ABY = BY-AY; + const float LengthSquared = ABX*ABX+ABY*ABY; + float T = LengthSquared > 1.0e-8f + ? FMath::Clamp(((X-AX)*ABX+(Y-AY)*ABY)/LengthSquared,0.0f,1.0f) + : 0.0f; + const float Length = FMath::Sqrt(LengthSquared); + const float NXrm = Length > 1.0e-4f ? -ABY/Length : 0.0f; + const float NYrm = Length > 1.0e-4f ? ABX/Length : 0.0f; + const float BendA = (Unit(H^0xB5297A4Du)*2.0f-1.0f)*Pitch*0.16f; + const float BendB = (Unit(H^0x1B56C4E9u)*2.0f-1.0f)*Pitch*0.16f; + const float C1X=AX+ABX/3.0f+NXrm*BendA, C1Y=AY+ABY/3.0f+NYrm*BendA; + const float C2X=AX+ABX*(2.0f/3.0f)+NXrm*BendB; + const float C2Y=AY+ABY*(2.0f/3.0f)+NYrm*BendB; + + for (int32 Iteration=0;Iteration<4;++Iteration) + { + const float U=T, O=1.0f-U; + const float PX=O*O*O*AX+3.0f*O*O*U*C1X+3.0f*O*U*U*C2X+U*U*U*BX; + const float PY=O*O*O*AY+3.0f*O*O*U*C1Y+3.0f*O*U*U*C2Y+U*U*U*BY; + const float DX=3.0f*O*O*(C1X-AX)+6.0f*O*U*(C2X-C1X)+3.0f*U*U*(BX-C2X); + const float DY=3.0f*O*O*(C1Y-AY)+6.0f*O*U*(C2Y-C1Y)+3.0f*U*U*(BY-C2Y); + const float DDX=6.0f*O*(C2X-2.0f*C1X+AX)+6.0f*U*(BX-2.0f*C2X+C1X); + const float DDY=6.0f*O*(C2Y-2.0f*C1Y+AY)+6.0f*U*(BY-2.0f*C2Y+C1Y); + const float Den=DX*DX+DY*DY+(PX-X)*DDX+(PY-Y)*DDY; + if (FMath::Abs(Den)>1.0e-4f) + T=FMath::Clamp(U-((PX-X)*DX+(PY-Y)*DY)/Den,0.0f,1.0f); + } + + const float O=1.0f-T; + const float PX=O*O*O*AX+3.0f*O*O*T*C1X+3.0f*O*T*T*C2X+T*T*T*BX; + const float PY=O*O*O*AY+3.0f*O*O*T*C1Y+3.0f*O*T*T*C2Y+T*T*T*BY; + const float DX=X-PX,DY=Y-PY; + const float Distance=FMath::Sqrt(DX*DX+DY*DY); + const float Phase=Unit(H^0xA511E9B3u)*6.2831853071795864769f; + const float Radius=RadiusBase*(0.82f+0.36f*Unit(H^0x63D83595u)) + *(1.0f+0.22f*FMath::Sin(6.2831853071795864769f*T+Phase)); + const float VerticalWave=4.0f*FMath::Sin(3.14159265358979323846f*T) + *FMath::Sin(6.2831853071795864769f*T+Phase); + const float VerticalOffset=Z-(CenterZ+VerticalWave); + const float HorizontalDistance=FMath::Max(0.0f,Distance); + const float Normalized=FMath::Sqrt( + FMath::Square(HorizontalDistance/FMath::Max(Radius,1.0f)) + +FMath::Square(VerticalOffset/FMath::Max(HalfHeight,1.0f))); + Best=FMath::Max(Best,(1.0f-Normalized)*FMath::Min(Radius,HalfHeight)); + }; + + for (int32 CY=BaseCellY-1;CY<=BaseCellY+1;++CY) + for (int32 CX=BaseCellX-1;CX<=BaseCellX+1;++CX) + { + EvaluateEdge(CX,CY,0); + EvaluateEdge(CX,CY,1); + } + return bHasEdge ? FMath::Max(BaseDensity,Best) : BaseDensity; + } +} diff --git a/Standalone/Source/StandaloneViewer.cpp b/Standalone/Source/StandaloneViewer.cpp new file mode 100644 index 0000000..8a700a7 --- /dev/null +++ b/Standalone/Source/StandaloneViewer.cpp @@ -0,0 +1,492 @@ +#include "OwnerWorldLoader.h" +#include "WorldMesher.h" +#include "StandaloneResearchTunnels.h" + +#ifdef TEXT +#undef TEXT +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib,"d3d11.lib") +#pragma comment(lib,"dxgi.lib") +#pragma comment(lib,"d3dcompiler.lib") +#pragma comment(lib,"user32.lib") +#pragma comment(lib,"gdi32.lib") + +namespace +{ + struct V3 { float X=0.0f,Y=0.0f,Z=0.0f; }; + V3 Add(V3 A,V3 B){return {A.X+B.X,A.Y+B.Y,A.Z+B.Z};} + V3 Sub(V3 A,V3 B){return {A.X-B.X,A.Y-B.Y,A.Z-B.Z};} + V3 Mul(V3 A,float S){return {A.X*S,A.Y*S,A.Z*S};} + float Dot(V3 A,V3 B){return A.X*B.X+A.Y*B.Y+A.Z*B.Z;} + V3 Cross(V3 A,V3 B){return {A.Y*B.Z-A.Z*B.Y,A.Z*B.X-A.X*B.Z,A.X*B.Y-A.Y*B.X};} + V3 Normalize(V3 A){return Mul(A,1.0f/std::sqrt(std::max(1.0e-20f,Dot(A,A))));} + struct M4 { float M[4][4]{}; }; + M4 Identity(){M4 R{};for(int I=0;I<4;++I)R.M[I][I]=1.0f;return R;} + M4 Transpose(M4 A){M4 B{};for(int I=0;I<4;++I)for(int J=0;J<4;++J)B.M[I][J]=A.M[J][I];return B;} + M4 Perspective(float Fov,float Aspect,float NearZ,float FarZ) + { + M4 R{};const float Y=1.0f/std::tan(Fov*0.5f); + R.M[0][0]=Y/Aspect;R.M[1][1]=Y;R.M[2][2]=FarZ/(FarZ-NearZ); + R.M[2][3]=1.0f;R.M[3][2]=-NearZ*FarZ/(FarZ-NearZ);return R; + } + M4 View(V3 Position,V3 Forward,V3 Right,V3 Up) + { + M4 R=Identity(); + R.M[0][0]=Right.X;R.M[1][0]=Right.Y;R.M[2][0]=Right.Z;R.M[3][0]=-Dot(Right,Position); + R.M[0][1]=Up.X;R.M[1][1]=Up.Y;R.M[2][1]=Up.Z;R.M[3][1]=-Dot(Up,Position); + R.M[0][2]=Forward.X;R.M[1][2]=Forward.Y;R.M[2][2]=Forward.Z;R.M[3][2]=-Dot(Forward,Position); + return R; + } + + struct FGpuVertex { float Position[3]; float Normal[3]; }; + struct FConstants + { + M4 ViewProjection; + float Camera[3]; float FogStart; + float Forward[3]; float FogEnd; + float LightDirection[3]; float Ambient; + float Padding[4]; + }; + + constexpr int32 MaxLevels=8; + struct FLevelGpu + { + ID3D11Buffer* VertexBuffer=nullptr; + ID3D11Buffer* IndexBuffer=nullptr; + UINT IndexCount=0; + uint64 VertexCount=0; + double Seconds=0.0; + uint64 Tiles=0; + uint64 Samples=0; + bool Ready=false; + }; + + struct FReadyLevel { int32 Level=0; FStandaloneCpuLevel Mesh; }; + + struct FApp + { + HWND Window=nullptr; + ID3D11Device* Device=nullptr; + ID3D11DeviceContext* Context=nullptr; + IDXGISwapChain* SwapChain=nullptr; + ID3D11RenderTargetView* RenderTarget=nullptr; + ID3D11DepthStencilView* DepthView=nullptr; + ID3D11VertexShader* VertexShader=nullptr; + ID3D11PixelShader* PixelShader=nullptr; + ID3D11InputLayout* InputLayout=nullptr; + ID3D11Buffer* Constants=nullptr; + ID3D11RasterizerState* SolidRasterizer=nullptr; + ID3D11RasterizerState* WireRasterizer=nullptr; + FLevelGpu Levels[MaxLevels+1]; + + std::unique_ptr World; + std::string DataPath; + std::mutex ReadyMutex; + std::vector ReadyLevels; + std::thread GenerationThread; + std::atomic GenerationDone{false}; + std::atomic CancelGeneration{false}; + FStandaloneCpuClipmap LastClipmap; + bool bGenerationRunning=false; + bool bRebuildPending=false; + bool bParityChecked=false; + bool bRoundTunnels=false; + bool bWireframe=false; + bool bCaptured=false; + bool bShowLevel[MaxLevels+1]={true,true,true,true,true,true,true,true,true}; + bool Keys[256]{}; + int32 Seed=0; + int32 Width=1280,Height=800; + float Yaw=0.0f,Pitch=0.0f,MoveSpeed=8.0f; + V3 Position{}; + POINT LastMouse{}; + std::chrono::steady_clock::time_point PreviousFrame=std::chrono::steady_clock::now(); + double FrameMs=0.0; + uint64 TotalVertices=0; + uint64 TotalIndices=0; + FIntVector BuildTargetChunk=FIntVector::ZeroValue; + + ~FApp(){ + StopGeneration(true);ReleaseMeshes(); + if(SolidRasterizer)SolidRasterizer->Release();if(WireRasterizer)WireRasterizer->Release(); + if(Constants)Constants->Release();if(InputLayout)InputLayout->Release(); + if(VertexShader)VertexShader->Release();if(PixelShader)PixelShader->Release(); + if(RenderTarget)RenderTarget->Release();if(DepthView)DepthView->Release(); + if(SwapChain)SwapChain->Release();if(Context)Context->Release();if(Device)Device->Release(); + } + + void ReleaseMeshes() + { + for(FLevelGpu& L:Levels){if(L.VertexBuffer)L.VertexBuffer->Release();if(L.IndexBuffer)L.IndexBuffer->Release();L={};} + TotalVertices=0;TotalIndices=0; + } + + void StopGeneration(bool bCancel=false) + { + if(bCancel)CancelGeneration.store(true,std::memory_order_relaxed); + if(GenerationThread.joinable())GenerationThread.join(); + bGenerationRunning=false; + } + + V3 Forward() const + {return Normalize({std::sin(Yaw)*std::cos(Pitch),std::cos(Yaw)*std::cos(Pitch),std::sin(Pitch)});} + + FIntVector CameraChunk() const + {return StandaloneWorldMesher::CameraChunk(FVector(Position.X*4.0,Position.Y*4.0,Position.Z*4.0));} + + bool NeedsRecenter(const FIntVector& Center) const + { + const FIntVector Current=CameraChunk(); + const int32 Threshold=FMath::Max(1,(World?World->ClipRadius:6)/2); + return FMath::Abs(Current.X-Center.X)>=Threshold + ||FMath::Abs(Current.Y-Center.Y)>=Threshold + ||FMath::Abs(Current.Z-Center.Z)>=Threshold; + } + + bool MakeBuffer(ID3D11Buffer** Out,const void* Data,size_t Bytes,UINT Bind) + { + if(Bytes==0||Bytes>std::numeric_limits::max())return false; + D3D11_BUFFER_DESC Desc{};Desc.ByteWidth=static_cast(Bytes); + Desc.Usage=D3D11_USAGE_DEFAULT;Desc.BindFlags=Bind; + D3D11_SUBRESOURCE_DATA Initial{};Initial.pSysMem=Data; + return SUCCEEDED(Device->CreateBuffer(&Desc,&Initial,Out)); + } + + bool Resize(UINT NewWidth,UINT NewHeight) + { + if(!Device||NewWidth==0||NewHeight==0)return false; + Width=static_cast(NewWidth);Height=static_cast(NewHeight); + if(RenderTarget){RenderTarget->Release();RenderTarget=nullptr;} + if(DepthView){DepthView->Release();DepthView=nullptr;} + if(SwapChain->ResizeBuffers(0,NewWidth,NewHeight,DXGI_FORMAT_UNKNOWN,0)!=S_OK)return false; + ID3D11Texture2D* Back=nullptr; + if(FAILED(SwapChain->GetBuffer(0,IID_PPV_ARGS(&Back))))return false; + const HRESULT RtvResult=Device->CreateRenderTargetView(Back,nullptr,&RenderTarget);Back->Release(); + if(FAILED(RtvResult))return false; + D3D11_TEXTURE2D_DESC DepthDesc{};DepthDesc.Width=NewWidth;DepthDesc.Height=NewHeight; + DepthDesc.MipLevels=1;DepthDesc.ArraySize=1;DepthDesc.Format=DXGI_FORMAT_D24_UNORM_S8_UINT; + DepthDesc.SampleDesc.Count=1;DepthDesc.Usage=D3D11_USAGE_DEFAULT;DepthDesc.BindFlags=D3D11_BIND_DEPTH_STENCIL; + ID3D11Texture2D* Depth=nullptr; + if(FAILED(Device->CreateTexture2D(&DepthDesc,nullptr,&Depth)))return false; + const HRESULT DsvResult=Device->CreateDepthStencilView(Depth,nullptr,&DepthView);Depth->Release(); + return SUCCEEDED(DsvResult); + } + + bool InitializeDevice() + { + DXGI_SWAP_CHAIN_DESC Desc{};Desc.BufferCount=1;Desc.BufferDesc.Format=DXGI_FORMAT_R8G8B8A8_UNORM; + Desc.BufferUsage=DXGI_USAGE_RENDER_TARGET_OUTPUT;Desc.OutputWindow=Window;Desc.SampleDesc.Count=1; + Desc.Windowed=TRUE;Desc.SwapEffect=DXGI_SWAP_EFFECT_DISCARD; + D3D_FEATURE_LEVEL Feature{}; + if(FAILED(D3D11CreateDeviceAndSwapChain(nullptr,D3D_DRIVER_TYPE_HARDWARE,nullptr,0,nullptr,0, + D3D11_SDK_VERSION,&Desc,&SwapChain,&Device,&Feature,&Context)))return false; + const char* ShaderSource=R"( +cbuffer C:register(b0){float4x4 VP;float3 Camera;float FogStart;float3 Forward;float FogEnd;float3 LightDir;float Ambient;float4 Pad;} +struct I{float3 p:POSITION;float3 n:NORMAL;}; +struct O{float4 p:SV_POSITION;float3 world:TEXCOORD0;float3 n:NORMAL;}; +O VS(I i){O o;o.p=mul(float4(i.p,1),VP);o.world=i.p;o.n=normalize(i.n);return o;} +float4 PS(O i):SV_TARGET{ + float3 N=normalize(i.n);float3 V=normalize(Camera-i.world);float3 L=normalize(-LightDir); + float Diffuse=saturate(dot(N,L));float Head=saturate(dot(N,V)); + float Dist=length(Camera-i.world);float Atten=1.0-saturate(Dist/95.0);Head=Head*Head*Atten; + float3 Rock=lerp(float3(0.28,0.34,0.38),float3(0.43,0.47,0.48),saturate(N.z*0.5+0.5)); + float3 Lit=Rock*(Ambient+0.88*Diffuse)+float3(0.62,0.76,0.9)*Head*1.25; + float Fog=saturate((Dist-FogStart)/(FogEnd-FogStart)); + Lit=lerp(Lit,float3(0.10,0.14,0.19),Fog);return float4(Lit,1); +})"; + ID3DBlob* VsCode=nullptr;ID3DBlob* PsCode=nullptr;ID3DBlob* Errors=nullptr; + HRESULT Hr=D3DCompile(ShaderSource,std::strlen(ShaderSource),nullptr,nullptr,nullptr,"VS","vs_5_0",0,0,&VsCode,&Errors); + if(FAILED(Hr)){if(Errors)std::fprintf(stderr,"Vertex shader compile: %s\n",static_cast(Errors->GetBufferPointer()));if(Errors)Errors->Release();return false;} + Hr=D3DCompile(ShaderSource,std::strlen(ShaderSource),nullptr,nullptr,nullptr,"PS","ps_5_0",0,0,&PsCode,&Errors); + if(FAILED(Hr)){if(Errors)std::fprintf(stderr,"Pixel shader compile: %s\n",static_cast(Errors->GetBufferPointer()));if(Errors)Errors->Release();VsCode->Release();return false;} + Hr=Device->CreateVertexShader(VsCode->GetBufferPointer(),VsCode->GetBufferSize(),nullptr,&VertexShader); + if(SUCCEEDED(Hr))Hr=Device->CreatePixelShader(PsCode->GetBufferPointer(),PsCode->GetBufferSize(),nullptr,&PixelShader); + D3D11_INPUT_ELEMENT_DESC Elements[]={{"POSITION",0,DXGI_FORMAT_R32G32B32_FLOAT,0,0,D3D11_INPUT_PER_VERTEX_DATA,0}, + {"NORMAL",0,DXGI_FORMAT_R32G32B32_FLOAT,0,12,D3D11_INPUT_PER_VERTEX_DATA,0}}; + if(SUCCEEDED(Hr))Hr=Device->CreateInputLayout(Elements,2,VsCode->GetBufferPointer(),VsCode->GetBufferSize(),&InputLayout); + VsCode->Release();PsCode->Release();if(FAILED(Hr))return false; + D3D11_BUFFER_DESC ConstantDesc{};ConstantDesc.ByteWidth=sizeof(FConstants);ConstantDesc.Usage=D3D11_USAGE_DEFAULT; + ConstantDesc.BindFlags=D3D11_BIND_CONSTANT_BUFFER; + if(FAILED(Device->CreateBuffer(&ConstantDesc,nullptr,&Constants)))return false; + D3D11_RASTERIZER_DESC Raster{};Raster.FillMode=D3D11_FILL_SOLID;Raster.CullMode=D3D11_CULL_NONE; + Raster.DepthClipEnable=TRUE;if(FAILED(Device->CreateRasterizerState(&Raster,&SolidRasterizer)))return false; + Raster.FillMode=D3D11_FILL_WIREFRAME;if(FAILED(Device->CreateRasterizerState(&Raster,&WireRasterizer)))return false; + return Resize(Width,Height); + } + + void PrintParity() + { + if(bParityChecked||!World)return; + bParityChecked=true;size_t MismatchCount=0; + std::map Kinds; + for(size_t I=0;IDensitySamples.size();++I) + { + const FStandaloneParitySample& S=World->DensitySamples[I]; + const float Actual=World->Generator->GetDensityAt(static_cast(S.PositionVoxels.X), + static_cast(S.PositionVoxels.Y),static_cast(S.PositionVoxels.Z)); + ++Kinds[S.Kind]; + if(std::bit_cast(Actual)!=std::bit_cast(S.PluginDensity)) + { + ++MismatchCount; + std::fprintf(stderr,"PARITY MISMATCH %s strate=%d voxels=(%.6f,%.6f,%.6f) plugin=%.9g standalone=%.9g\n", + S.Kind.c_str(),S.StrateIndex,S.PositionVoxels.X,S.PositionVoxels.Y,S.PositionVoxels.Z,S.PluginDensity,Actual); + } + } + std::printf("Density parity: %zu/%zu bit-identical; mismatches=%zu\n", + World->DensitySamples.size()-MismatchCount,World->DensitySamples.size(),MismatchCount); + for(const auto& [Kind,Count]:Kinds)std::printf(" %-24s %zu\n",Kind.c_str(),Count); + std::fflush(stdout); + std::fflush(stdout); + } + + bool ConfigureWorld(std::string& Error) + { + World=std::make_unique(); + if(!StandaloneOwnerLoader::Load(DataPath,*World,Error))return false; + if(!bParityChecked&&Seed==World->Seed&&!bRoundTunnels)PrintParity(); + World->Generator.reset();World->Manager.reset(); + World->Settings->Seed=Seed;World->Seed=Seed; + if(bRoundTunnels) + { + // The research tunnel graph replaces the owner's first-strate corridor graph. + for(auto& Pair:World->Settings->FixedStrates) + { + UVoxelStrateDefinition* Definition=Pair.Value.Get(); + if(Pair.Key==0&&Definition&&Definition->GeneratorType==ECaveGeneratorType::TunnelNetwork) + { + Definition->GenerationParams.TunnelDensity=0.0f; + Definition->GenerationParams.TunnelWarpStrength=0.0f; + } + } + FStandaloneResearchTunnels::Set(true,Position.Z*4.0f); + } + else FStandaloneResearchTunnels::Set(false,0.0f); + World->Manager=std::make_unique(); + if(!World->Manager->Initialize(World->Settings.get(),Seed)) + {Error="standalone copied strate manager failed while applying viewer seed";return false;} + World->Generator=std::make_unique(); + World->Generator->InitializeSettings(World->Settings.get()); + World->Generator->SetStrateManager(World->Manager.get()); + return true; + } + + void StartGeneration() + { + StopGeneration(); + ReleaseMeshes(); + std::string Error; + if(!ConfigureWorld(Error)) + {std::fprintf(stderr,"World load failed: %s\n",Error.c_str());MessageBoxA(Window,Error.c_str(),"VoxelForge viewer",MB_ICONERROR);return;} + const FVector CameraVoxels(Position.X*4.0,Position.Y*4.0,Position.Z*4.0); + BuildTargetChunk=StandaloneWorldMesher::CameraChunk(CameraVoxels); + ReadyLevels.clear();CancelGeneration.store(false,std::memory_order_relaxed); + GenerationDone.store(false,std::memory_order_relaxed);bGenerationRunning=true;bRebuildPending=false; + std::printf("Building seed %d%s full clipmap (six workers)...\n",Seed,bRoundTunnels?" with research round/winding tunnels":""); + GenerationThread=std::thread([this,CameraVoxels]() + { + LastClipmap=StandaloneWorldMesher::Build(*World,CameraVoxels,6, + [this](int32 Level,FStandaloneCpuLevel&& Mesh) + { + std::lock_guard Lock(ReadyMutex); + ReadyLevels.push_back({Level,std::move(Mesh)}); + },false,&CancelGeneration); + GenerationDone.store(true,std::memory_order_release); + }); + } + + void RequestSeed(int32 Delta) + { + const uint32 Next=static_cast(Seed)+static_cast(Delta); + Seed=static_cast(Next);bRebuildPending=true; + CancelGeneration.store(true,std::memory_order_relaxed); + std::printf("Seed changed to %d; rebuilding after the active clipmap finishes.\n",Seed); + } + + bool UploadLevel(int32 Index,FStandaloneCpuLevel&& Mesh) + { + FLevelGpu& L=Levels[Index]; + if(L.VertexBuffer)L.VertexBuffer->Release();if(L.IndexBuffer)L.IndexBuffer->Release();L={}; + L.Seconds=Mesh.Seconds;L.Tiles=Mesh.TileCount;L.Samples=Mesh.DensitySamples; + L.VertexCount=Mesh.VertexCount;L.IndexCount=static_cast(Mesh.Indices.size()); + if(Mesh.Vertices.empty()||Mesh.Indices.empty()){L.Ready=true;return true;} + if(Mesh.Indices.size()>std::numeric_limits::max()) + {std::fprintf(stderr,"LOD %d has too many indices for one D3D11 draw.\n",Index);return false;} + const bool bVertex=MakeBuffer(&L.VertexBuffer,Mesh.Vertices.data(),Mesh.Vertices.size()*sizeof(FStandaloneCpuVertex),D3D11_BIND_VERTEX_BUFFER); + const bool bIndex=bVertex&&MakeBuffer(&L.IndexBuffer,Mesh.Indices.data(),Mesh.Indices.size()*sizeof(uint32),D3D11_BIND_INDEX_BUFFER); + if(!bIndex){if(L.VertexBuffer){L.VertexBuffer->Release();L.VertexBuffer=nullptr;}std::fprintf(stderr,"D3D11 buffer upload failed for LOD %d.\n",Index);return false;} + L.IndexCount=static_cast(Mesh.Indices.size());L.Ready=true; + TotalVertices+=L.VertexCount;TotalIndices+=L.IndexCount; + std::printf("LOD %d uploaded: %llu vertices, %.3f s, %llu tiles.\n",Index, + static_cast(L.VertexCount),L.Seconds,static_cast(L.Tiles)); + return true; + } + + void PumpGeneration() + { + std::vector Ready; + {std::lock_guard Lock(ReadyMutex);Ready.swap(ReadyLevels);} + for(FReadyLevel& R:Ready)UploadLevel(R.Level,std::move(R.Mesh)); + if(bGenerationRunning&&GenerationDone.load(std::memory_order_acquire)) + { + StopGeneration(); + if(!LastClipmap.bCancelled) + std::printf("Full clipmap ready: %.3f s on %d workers; %llu vertices, %llu indices.\n", + LastClipmap.TotalSeconds,LastClipmap.WorkerCount, + static_cast(TotalVertices),static_cast(TotalIndices)); + for(int32 L=0;L<=World->MaxClipLevel;++L) + std::printf(" LOD %d: %.3f s, %llu tiles, %llu vertices, %.2f M density samples\n",L, + LastClipmap.Levels[L].Seconds,static_cast(LastClipmap.Levels[L].TileCount), + static_cast(LastClipmap.Levels[L].VertexCount), + static_cast(LastClipmap.Levels[L].DensitySamples)/1.0e6); + std::fflush(stdout); + if(bRebuildPending)StartGeneration(); + } + else if(!bGenerationRunning&&bRebuildPending)StartGeneration(); + } + + void Capture(bool bOn) + { + bCaptured=bOn; + if(bOn){SetCapture(Window);ShowCursor(FALSE);GetCursorPos(&LastMouse);POINT C{Width/2,Height/2};ClientToScreen(Window,&C);SetCursorPos(C.x,C.y);} + else{ReleaseCapture();ShowCursor(TRUE);} + } + + void UpdateTitle() + { + uint64 VisibleIndices=0;for(int32 I=0;I<=MaxLevels;++I)if(bShowLevel[I])VisibleIndices+=Levels[I].IndexCount; + std::ostringstream S;S<<"VoxelForge standalone | seed "<(Now-PreviousFrame).count(),0.0f,0.1f); + PreviousFrame=Now;FrameMs=Dt*1000.0; + const V3 F=Forward(),R=Normalize(Cross(F,{0,0,1})),U=Normalize(Cross(R,F)); + const float Step=MoveSpeed*Dt*(Keys[VK_SHIFT]?4.0f:1.0f); + if(Keys['W'])Position=Add(Position,Mul(F,Step));if(Keys['S'])Position=Sub(Position,Mul(F,Step)); + if(Keys['A'])Position=Sub(Position,Mul(R,Step));if(Keys['D'])Position=Add(Position,Mul(R,Step)); + if(Keys[VK_SPACE]||Keys['E'])Position.Z+=Step;if(Keys[VK_CONTROL]||Keys['Q'])Position.Z-=Step; + if(bGenerationRunning&&NeedsRecenter(BuildTargetChunk)) + {bRebuildPending=true;CancelGeneration.store(true,std::memory_order_relaxed);} + else if(!bGenerationRunning&&World&&NeedsRecenter(LastClipmap.CenterChunk)) + {bRebuildPending=true;} + const float Clear[4]={0.055f,0.075f,0.105f,1.0f}; + Context->OMSetRenderTargets(1,&RenderTarget,DepthView);Context->ClearRenderTargetView(RenderTarget,Clear); + Context->ClearDepthStencilView(DepthView,D3D11_CLEAR_DEPTH,1.0f,0); + D3D11_VIEWPORT Viewport{0,0,static_cast(Width),static_cast(Height),0,1};Context->RSSetViewports(1,&Viewport); + Context->RSSetState(bWireframe?WireRasterizer:SolidRasterizer);Context->IASetInputLayout(InputLayout); + Context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + FConstants C{};const M4 V=View(Position,F,R,U);const M4 P=Perspective(1.04719755f,static_cast(Width)/std::max(1,Height),0.05f,5000.0f); + M4 Combined{};for(int I=0;I<4;++I)for(int J=0;J<4;++J)for(int K=0;K<4;++K)Combined.M[I][J]+=V.M[I][K]*P.M[K][J]; + C.ViewProjection=Transpose(Combined);C.Camera[0]=Position.X;C.Camera[1]=Position.Y;C.Camera[2]=Position.Z; + C.Forward[0]=F.X;C.Forward[1]=F.Y;C.Forward[2]=F.Z;C.LightDirection[0]=-0.5f;C.LightDirection[1]=-0.72f;C.LightDirection[2]=-0.48f; + C.FogStart=260.0f;C.FogEnd=2600.0f;C.Ambient=0.36f; + Context->UpdateSubresource(Constants,0,nullptr,&C,0,0);Context->VSSetConstantBuffers(0,1,&Constants);Context->PSSetConstantBuffers(0,1,&Constants); + Context->VSSetShader(VertexShader,nullptr,0);Context->PSSetShader(PixelShader,nullptr,0); + for(int32 I=0;I<=MaxLevels;++I) + { + const FLevelGpu& L=Levels[I];if(!bShowLevel[I]||!L.VertexBuffer||!L.IndexBuffer||!L.IndexCount)continue; + const UINT Stride=sizeof(FGpuVertex),Offset=0;Context->IASetVertexBuffers(0,1,&L.VertexBuffer,&Stride,&Offset); + Context->IASetIndexBuffer(L.IndexBuffer,DXGI_FORMAT_R32_UINT,0);Context->DrawIndexed(L.IndexCount,0,0); + } + SwapChain->Present(1,0);UpdateTitle(); + } + }; + + FApp* GApp=nullptr; + LRESULT CALLBACK WindowProc(HWND H,UINT Message,WPARAM W,LPARAM L) + { + if(!GApp)return DefWindowProc(H,Message,W,L); + switch(Message) + { + case WM_SIZE:if(W!=SIZE_MINIMIZED)GApp->Resize(LOWORD(L),HIWORD(L));return 0; + case WM_KEYDOWN: + { + const bool bWasDown=(L&(1ll<<30))!=0;if(W<256)GApp->Keys[W]=true; + if(W==VK_ESCAPE){if(GApp->bCaptured)GApp->Capture(false);else DestroyWindow(H);} + if(!bWasDown&&W=='F')GApp->bWireframe=!GApp->bWireframe; + if(!bWasDown&&W=='T'){GApp->bRoundTunnels=!GApp->bRoundTunnels;GApp->bRebuildPending=true;GApp->CancelGeneration.store(true,std::memory_order_relaxed);} + if(!bWasDown&&(W=='N'||W=='P'))GApp->RequestSeed(W=='N'?1:-1); + if(!bWasDown&&W>='1'&&W<='8'){const int32 I=static_cast(W-'1');GApp->bShowLevel[I]=!GApp->bShowLevel[I];} + if(!bWasDown&&W=='9')GApp->bShowLevel[8]=!GApp->bShowLevel[8]; + if(!bWasDown&&W=='0')for(bool& B:GApp->bShowLevel)B=!B; + return 0; + } + case WM_KEYUP:if(W<256)GApp->Keys[W]=false;return 0; + case WM_RBUTTONDOWN:GApp->Capture(true);return 0; + case WM_RBUTTONUP:GApp->Capture(false);return 0; + case WM_MOUSEMOVE: + if(GApp->bCaptured){POINT C{GApp->Width/2,GApp->Height/2};ClientToScreen(H,&C);POINT P{GET_X_LPARAM(L),GET_Y_LPARAM(L)},Screen=P;ClientToScreen(H,&Screen);const int32 DX=Screen.x-C.x,DY=Screen.y-C.y;if(DX||DY){GApp->Yaw+=DX*0.0035f;GApp->Pitch=std::clamp(GApp->Pitch-DY*0.0035f,-1.55f,1.55f);SetCursorPos(C.x,C.y);}}return 0; + case WM_MOUSEWHEEL:GApp->MoveSpeed=std::clamp(GApp->MoveSpeed*(GET_WHEEL_DELTA_WPARAM(W)>0?1.25f:0.8f),0.25f,1000.0f);return 0; + case WM_DESTROY:GApp->CancelGeneration.store(true,std::memory_order_relaxed);PostQuitMessage(0);return 0; + } + return DefWindowProc(H,Message,W,L); + } + + std::filesystem::path DefaultDataPath() + { + wchar_t Module[MAX_PATH]{};GetModuleFileNameW(nullptr,Module,MAX_PATH); + const std::filesystem::path Exe(Module); + return Exe.parent_path().parent_path()/L"data"/L"owner_world.json"; + } +} + +int main(int Argc,char** Argv) +{ + FApp App;App.DataPath=Argc>1?Argv[1]:DefaultDataPath().string(); + std::string Error; + App.World=std::make_unique(); + if(!StandaloneOwnerLoader::Load(App.DataPath,*App.World,Error)){std::fprintf(stderr,"Owner data load failed: %s\n",Error.c_str());return 2;} + App.Seed=App.World->Seed; + App.Position={static_cast(App.World->ViewerStartVoxels.X*0.25), + static_cast(App.World->ViewerStartVoxels.Y*0.25),static_cast(App.World->ViewerStartVoxels.Z*0.25)}; + App.World.reset(); + + WNDCLASSA Class{};Class.lpfnWndProc=WindowProc;Class.hInstance=GetModuleHandleA(nullptr);Class.lpszClassName="VoxelForgeStandaloneWorld"; + Class.hCursor=LoadCursor(nullptr,IDC_ARROW);RegisterClassA(&Class); + RECT Rect{0,0,App.Width,App.Height};AdjustWindowRect(&Rect,WS_OVERLAPPEDWINDOW,FALSE); + App.Window=CreateWindowA(Class.lpszClassName,"VoxelForge standalone world",WS_OVERLAPPEDWINDOW,CW_USEDEFAULT,CW_USEDEFAULT, + Rect.right-Rect.left,Rect.bottom-Rect.top,nullptr,nullptr,Class.hInstance,nullptr); + GApp=&App; + if(!App.Window||!App.InitializeDevice()){MessageBoxA(nullptr,"Direct3D 11 initialization failed.","VoxelForge standalone",MB_ICONERROR);return 3;} + ShowWindow(App.Window,SW_SHOW);UpdateWindow(App.Window); + std::printf("Controls: hold right mouse to look; WASD move; Space/Ctrl (E/Q) up/down; Shift faster; wheel changes speed; F wireframe; 1-9 toggle LOD; 0 toggle all levels; N/P change seed; T swap to round/winding research tunnels; Esc quit.\n"); + std::printf("Owner data: %s; starting at first-strate open-space sample (%.2f, %.2f, %.2f) m.\n", + App.DataPath.c_str(),App.Position.X,App.Position.Y,App.Position.Z); + std::fflush(stdout); + App.StartGeneration(); + MSG Message{}; + while(Message.message!=WM_QUIT) + { + if(PeekMessage(&Message,nullptr,0,0,PM_REMOVE)){TranslateMessage(&Message);DispatchMessage(&Message);} + else App.Draw(); + } + GApp=nullptr; + return 0; +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelBiomeDefinition.cpp b/Standalone/Source/VoxelForge/Private/VoxelBiomeDefinition.cpp new file mode 100644 index 0000000..238a763 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelBiomeDefinition.cpp @@ -0,0 +1,5 @@ +// VoxelBiomeDefinition.cpp +// All data lives in the header; this TU exists so UHT generates the asset's body and +// the module has an object file for it. + +#include "VoxelBiomeDefinition.h" diff --git a/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp new file mode 100644 index 0000000..b9877ba --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -0,0 +1,9779 @@ +// VoxelCaveMorphology.cpp +// Hash-based room/tunnel generation and SDF evaluation. +// +// TWO-PHASE EVALUATION: +// Phase 1 — BuildChunkCache: collects rooms, builds backbone, resolves tunnels. +// Called ONCE per chunk (~1 time per 32³ = 32,768 voxels). +// Phase 2 — EvaluateSDFCached: evaluates room/tunnel SDFs with distance culling. +// Called PER VOXEL using the cached data. +// +// FEATURES: +// - Origin room: guaranteed finite landing room at (0,0) per strate — reserved for the spine +// - Hash-based rooms: ellipsoid, rounded box, and capsule shapes +// - Tunnels: tapered capsules with deterministic wandering control-point chains +// - Horizontal bias: tunnels prefer horizontal connections +// - Endpoint Z offset: tunnels enter rooms at different heights +// - Per-room/tunnel distance culling: skip SDFs that can't affect this voxel +// +// CROSS-CHUNK DETERMINISM (the invariant that prevents seams): +// The room/tunnel GRAPH must reconstruct IDENTICALLY in every chunk whose voxels a +// tunnel touches. Room geometry is a pure hash of its cell, so that half is trivially +// identical. The fragile half is the EXISTENCE decision (the nearest-neighbor +// backbone), which historically depended on the per-chunk window of collected rooms. +// We now separate two regions (see BuildChunkCache): +// - COLLECT region (wide): every room whose NN-candidate set could influence a +// tunnel touching this chunk. Connectivity is decided over THIS set, so the +// decision is window-invariant. +// - STORE region (tight): only rooms/tunnels that can actually reach a voxel in +// this chunk are kept for the per-voxel loop, so hot-path cost stays low. + +#include "VoxelCaveMorphology.h" +#include "VoxelDensityProfile.h" +#include "VoxelDensityPrimitives.h" +#include "VoxelDensityAblation.h" +#include "VoxelStrateMeasure.h" +#include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 +#include "VoxelStrateTypes.h" +#include "VoxelNoise.h" // Pure FBM used by the slab landing query +#include "VoxelPassageGeometry.h" +#include "VoxelTerrainOpDefinition.h" +#include "HAL/IConsoleManager.h" +#include "HAL/PlatformProcess.h" +#include "Misc/CommandLine.h" +#include "Misc/Parse.h" + +#include + +namespace +{ +#if WITH_EDITOR + thread_local bool GVoxelResearchBrickBinningEnabled = false; + thread_local float GVoxelResearchBrickRoomReachScale = 1.0f; +#endif + + using FVFPlayerDensitySampler = TFunctionRef; + + // Density coordinates and cached cull radii are float-valued. FVector is double-valued in + // UE5, so using FVector::DistSquared here needlessly widens every candidate reject. Keep the + // cull in the same build-fixed float domain as the density query; the exact field output is + // intentionally not required to match a double-precision round-trip. + FORCEINLINE float VF_FloatDistSquared( + float X, float Y, float Z, const FVector& Center) + { + const float DX = X - static_cast(Center.X); + const float DY = Y - static_cast(Center.Y); + const float DZ = Z - static_cast(Center.Z); + return DX * DX + DY * DY + DZ * DZ; + } + + FORCEINLINE float VF_DistanceSquaredToAabb( + float X, float Y, float Z, + const FVector& Min, + const FVector& Max) + { + const float DX = X < static_cast(Min.X) + ? static_cast(Min.X) - X + : (X > static_cast(Max.X) ? X - static_cast(Max.X) : 0.0f); + const float DY = Y < static_cast(Min.Y) + ? static_cast(Min.Y) - Y + : (Y > static_cast(Max.Y) ? Y - static_cast(Max.Y) : 0.0f); + const float DZ = Z < static_cast(Min.Z) + ? static_cast(Min.Z) - Z + : (Z > static_cast(Max.Z) ? Z - static_cast(Max.Z) : 0.0f); + return DX * DX + DY * DY + DZ * DZ; + } + + struct FVFTunnelShapeEvaluation + { + float SDF = FLT_MAX; + bool bHasSweptFloor = false; + float SweptFloorZ = -FLT_MAX; + float SweptFloorRadius = 0.0f; + }; + + // FMath::PerlinNoise2D is the same two-octave field used by room floors. The derivative bound + // below is deliberately loose: each Grad2 component is in [-1, 1], the interpolation + // difference is bounded by four, and SmoothCurve' <= 1.875. Keeping this bound conservative + // is what lets the corridor use the authored relief without turning the walkability promise + // into a sampling accident. + constexpr float VF_Perlin2DPartialAbsBound = 8.5f; + constexpr float VF_MaxTunnelFloorReliefScale = 0.20f; + + // The weighted two-octave field is bounded by one in exact arithmetic. Leave a margin for + // the implementation's interpolation/rounding before using it as a proof bound. This bound + // is only used to skip a floor evaluation when the existing SmoothMax is provably the identity; + // it never changes a value that could affect the isosurface. + constexpr float VF_FloorReliefNoiseAbsBound = 1.01f; + // A room owns a short, build-authored mouth apron. The full room radius is intentionally + // not used here: doing so lets a centre-to-centre tunnel floor overwrite the room floor far + // inside the chamber and recreates the broad raised shelf this hand-off is meant to remove. + constexpr float VF_TunnelMouthBlendRadiusVoxels = + VoxelPassageGeometry::WalkableTunnelLandingApronVoxels; + // The boundary probe describes where the *centreline* may leave the room. Keep one complete + // landing-carve band plus the player radius behind that probe so the tapered end-cap and the + // fitted capsule can overlap the room on the sampled lattice. This is a finite mouth apron, + // not a centre-to-centre tunnel extension. + constexpr float VF_TunnelMouthPlayerFitOverlapVoxels = + VoxelPassageGeometry::LandingCarveBlendVoxels + + VoxelPassageGeometry::PlayerRadiusVoxels; + + static float VF_TunnelMouthInset(float TunnelRadius, float BlendRadius) + { + return FMath::Max( + 0.5f, + FMath::Abs(TunnelRadius) * 0.5f + + FMath::Max(FMath::Abs(BlendRadius) * 0.25f, 0.0f) + + VF_TunnelMouthPlayerFitOverlapVoxels); + } + +#if !UE_BUILD_SHIPPING + static TAutoConsoleVariable CVarVoxelForgeTunnelMouthTrim( + TEXT("voxel.TunnelMouthTrim"), + 1, + TEXT("Development-only room-wall tunnel-mouth trim; 1 makes the room own the mouth by default."), + ECVF_Default); +#endif + + enum class EVFFloorReliefFeature : uint8 + { + RoomDouble, + TunnelFloat, + LandingFloat, + LandingDouble + }; + + struct FVFFloorReliefColumnKey + { + bool bInteger = false; + int32 X = 0; + int32 Y = 0; + }; + + FORCEINLINE FVFFloorReliefColumnKey VF_MakeFloorReliefColumnKey( + double X, double Y) + { + FVFFloorReliefColumnKey Key; + const bool bIntegerX = VoxelMath::IsFinite(X) + && X >= static_cast(MIN_int32) + && X <= static_cast(MAX_int32) + && FMath::FloorToDouble(X) == X; + const bool bIntegerY = VoxelMath::IsFinite(Y) + && Y >= static_cast(MIN_int32) + && Y <= static_cast(MAX_int32) + && FMath::FloorToDouble(Y) == Y; + if (bIntegerX && bIntegerY) + { + Key.bInteger = true; + Key.X = FMath::FloorToInt(X); + Key.Y = FMath::FloorToInt(Y); + } + return Key; + } + + // Relief is a function of the actual SDF-domain X/Y, not necessarily the original world X/Y: + // CaveWarp is a 3D warp and therefore makes those coordinates vary with Z. A cache keyed by + // the actual domain is exact in both cases: it hits on genuine repeated columns (the world + // structural path and unwarped queries), and safely falls through to the original calculation + // when a warped sample is not the same column. The table is direct-mapped and fixed-size so + // it cannot become another retained per-tile allocation. Collisions only lose a hit. + struct FVFFloorReliefCacheEntry + { + const void* CacheIdentity = nullptr; + EVFFloorReliefFeature Feature = EVFFloorReliefFeature::RoomDouble; + int32 FeatureIndex = INDEX_NONE; + int32 X = 0; + int32 Y = 0; + uint32 FloorSeed = 0; + float Frequency = 0.0f; + float Noise = 0.0f; + bool bValid = false; + }; + + struct FVFFloorReliefCache + { + // About 1.25 MiB on the current x64 layout, bounded independently of chunk/region size. + static constexpr int32 Capacity = 32768; + static_assert((Capacity & (Capacity - 1)) == 0, "floor relief cache must be a power of two"); + FVFFloorReliefCacheEntry Entries[Capacity]; + + FORCEINLINE static uint32 HashKey( + const void* CacheIdentity, + EVFFloorReliefFeature Feature, + int32 FeatureIndex, + int32 X, + int32 Y, + uint32 FloorSeed, + float Frequency) + { + const UPTRINT PointerBits = reinterpret_cast(CacheIdentity); + uint32 Hash = static_cast(PointerBits) + ^ static_cast(PointerBits >> 32); + auto Combine = [&Hash](uint32 Value) + { + Hash ^= Value + 0x9e3779b9u + (Hash << 6) + (Hash >> 2); + }; + Combine(static_cast(Feature)); + Combine(static_cast(FeatureIndex)); + Combine(static_cast(X)); + Combine(static_cast(Y)); + Combine(FloorSeed); + Combine(GetTypeHash(Frequency)); + return VoxelHash::Mix(Hash); + } + + FORCEINLINE float GetOrCompute( + double X, double Y, + uint32 FloorSeed, float Frequency, + const void* CacheIdentity, + EVFFloorReliefFeature Feature, + int32 FeatureIndex, + const FVFFloorReliefColumnKey& Column, + float (*Compute)(double, double, uint32, float)) + { + if (!Column.bInteger) + { + return Compute(X, Y, FloorSeed, Frequency); + } + + FVFFloorReliefCacheEntry& Entry = Entries[HashKey( + CacheIdentity, Feature, FeatureIndex, + Column.X, Column.Y, FloorSeed, Frequency) + & (Capacity - 1)]; + if (Entry.bValid + && Entry.CacheIdentity == CacheIdentity + && Entry.Feature == Feature + && Entry.FeatureIndex == FeatureIndex + && Entry.X == Column.X + && Entry.Y == Column.Y + && Entry.FloorSeed == FloorSeed + && Entry.Frequency == Frequency) + { + return Entry.Noise; + } + + const float Noise = Compute(X, Y, FloorSeed, Frequency); + Entry.CacheIdentity = CacheIdentity; + Entry.Feature = Feature; + Entry.FeatureIndex = FeatureIndex; + Entry.X = Column.X; + Entry.Y = Column.Y; + Entry.FloorSeed = FloorSeed; + Entry.Frequency = Frequency; + Entry.Noise = Noise; + Entry.bValid = true; + return Noise; + } + }; + + static thread_local FVFFloorReliefCache GFloorReliefCache; + + FORCEINLINE float VF_ComputeFloorReliefNoiseDouble( + double X, double Y, uint32 FloorSeed, float Frequency) + { + const float SF = static_cast(FloorSeed) * 0.00001f; + float Noise = FMath::PerlinNoise2D( + FVector2D(X * Frequency + SF, Y * Frequency + SF * 1.7f)) * 0.65f; + Noise += FMath::PerlinNoise2D( + FVector2D(X * Frequency * 2.3f + SF * 3.1f, + Y * Frequency * 2.3f + SF * 5.3f)) * 0.35f; + return Noise; + } + + FORCEINLINE float VF_ComputeFloorReliefNoiseFloat( + double X, double Y, uint32 FloorSeed, float Frequency) + { + const float FX = static_cast(X); + const float FY = static_cast(Y); + const float SF = static_cast(FloorSeed) * 0.00001f; + float Noise = FMath::PerlinNoise2D( + FVector2D(FX * Frequency + SF, FY * Frequency + SF * 1.7f)) * 0.65f; + Noise += FMath::PerlinNoise2D( + FVector2D(FX * Frequency * 2.3f + SF * 3.1f, + FY * Frequency * 2.3f + SF * 5.3f)) * 0.35f; + return Noise; + } + + FORCEINLINE float VF_FloorReliefNoiseDouble( + double X, double Y, uint32 FloorSeed, float Frequency, + const void* CacheIdentity, + EVFFloorReliefFeature Feature, + int32 FeatureIndex, + const FVFFloorReliefColumnKey& Column) + { + return GFloorReliefCache.GetOrCompute( + X, Y, FloorSeed, Frequency, CacheIdentity, Feature, FeatureIndex, + Column, + &VF_ComputeFloorReliefNoiseDouble); + } + + FORCEINLINE float VF_FloorReliefNoiseFloat( + float X, float Y, uint32 FloorSeed, float Frequency, + const void* CacheIdentity, + EVFFloorReliefFeature Feature, + int32 FeatureIndex, + const FVFFloorReliefColumnKey& Column) + { + return GFloorReliefCache.GetOrCompute( + static_cast(X), static_cast(Y), + FloorSeed, Frequency, CacheIdentity, Feature, FeatureIndex, + Column, + &VF_ComputeFloorReliefNoiseFloat); + } + + FORCEINLINE bool VF_GetFloorReliefBound( + float Strength, float Envelope, float ReliefScale, float Frequency, + float& OutBound) + { + OutBound = 0.0f; + if (!(Strength > 0.0f) + || !VoxelMath::IsFinite(Strength) + || !VoxelMath::IsFinite(Frequency) + || !(Envelope > 0.0f) + || !VoxelMath::IsFinite(Envelope) + || !(ReliefScale > 0.0f) + || !VoxelMath::IsFinite(ReliefScale)) + { + return false; + } + + OutBound = FMath::Abs(Strength) * VOXEL_NOISE_SCALE + * VF_FloorReliefNoiseAbsBound * Envelope * ReliefScale; + return VoxelMath::IsFinite(OutBound) && OutBound >= 0.0f; + } + + // Keep corridor relief on the exact two-octave field already used by room floors. The + // endpoint fade is not a second noise system: it is the landing apron that lets the swept + // corridor meet the room's own floor without a seed-dependent vertical lip. + FORCEINLINE float VF_TunnelFloorRelief( + float X, float Y, const FCachedTunnel& Tunnel, + float Envelope, float ReliefScale, + const void* CacheIdentity, int32 TunnelIndex, + const FVFFloorReliefColumnKey& Column) + { + if (!(Tunnel.FloorReliefStrength > 0.0f) + || !VoxelMath::IsFinite(Tunnel.FloorReliefStrength) + || !VoxelMath::IsFinite(Tunnel.FloorReliefFrequency) + || !(Envelope > 0.0f) + || !(ReliefScale > 0.0f)) + { + return 0.0f; + } + + const float Noise = VF_FloorReliefNoiseFloat( + X, Y, Tunnel.FloorSeed, Tunnel.FloorReliefFrequency, + CacheIdentity, EVFFloorReliefFeature::TunnelFloat, TunnelIndex, + Column); + return Noise * VOXEL_NOISE_SCALE * Tunnel.FloorReliefStrength + * Envelope * ReliefScale; + } + + FORCEINLINE float VF_TunnelFloorProfileT( + const FVector& A, float RadiusA, + const FVector& B, float RadiusB, + float T) + { + // Legacy/cache fallback is deliberately continuous. A malformed or old cache may no + // longer manufacture a staircase from a per-sample step count; safe callers either use + // the authored profile or retain the smooth tapered-capsule floor. + return FMath::Clamp(T, 0.0f, 1.0f); + } + + FORCEINLINE float VF_TunnelFloorReliefScale( + const FVector& A, float RadiusA, + const FVector& B, float RadiusB, + int32 SegmentIndex, int32 NumSegments, + const FCachedTunnel& Tunnel) + { + if (!(Tunnel.FloorReliefStrength > 0.0f) + || !VoxelMath::IsFinite(Tunnel.FloorReliefStrength) + || !VoxelMath::IsFinite(Tunnel.FloorReliefFrequency)) + { + return 0.0f; + } + + const float HorizontalRun = FVector2D( + static_cast(B.X - A.X), static_cast(B.Y - A.Y)).Size(); + if (!(HorizontalRun > KINDA_SMALL_NUMBER)) + { + return 0.0f; + } + + const float FloorDelta = FMath::Abs( + VoxelPassageGeometry::TunnelFloorZ(B, FMath::Abs(RadiusB)) + - VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA))); + const float BaseGradient = FloorDelta / HorizontalRun; + const float GentleThreshold = VoxelMath::IsFinite( + Tunnel.TunnelFloorGentleSlopeGradient) + ? FMath::Max(Tunnel.TunnelFloorGentleSlopeGradient, 0.0f) + : VoxelPassageGeometry::PlayerWalkableFloorMaxGradient; + const float AvailableGradient = GentleThreshold - BaseGradient; + if (!(AvailableGradient > 0.0f)) + { + return 0.0f; + } + + const float Frequency = FMath::Abs(Tunnel.FloorReliefFrequency); + const float Amplitude = FMath::Abs(Tunnel.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float OctaveFrequencyWeight = 0.65f + 0.35f * 2.3f; + const float NoiseGradientBound = + 1.4142135623730951f * VF_Perlin2DPartialAbsBound + * OctaveFrequencyWeight * Frequency * Amplitude; + const int32 LandingEnvelopeCount = (SegmentIndex == 0 ? 1 : 0) + + (SegmentIndex + 1 == NumSegments ? 1 : 0); + const float EnvelopeDerivativeBound = LandingEnvelopeCount > 0 + ? (1.5f * static_cast(LandingEnvelopeCount) + / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels) + * Amplitude + : 0.0f; + const float ReliefGradientBound = NoiseGradientBound + + EnvelopeDerivativeBound; + if (!(ReliefGradientBound > KINDA_SMALL_NUMBER) + || !VoxelMath::IsFinite(ReliefGradientBound)) + { + return 0.0f; + } + + return FMath::Clamp( + FMath::Min( + VF_MaxTunnelFloorReliefScale, + AvailableGradient / ReliefGradientBound), + 0.0f, VF_MaxTunnelFloorReliefScale); + } + + FORCEINLINE float VF_TunnelFloorReliefScaleForGradient( + const FVector& A, const FVector& B, + int32 SegmentIndex, int32 NumSegments, + const FCachedTunnel& Tunnel, + float BaseGradient, bool bTerraced) + { + if (bTerraced + || !(Tunnel.FloorReliefStrength > 0.0f) + || !VoxelMath::IsFinite(Tunnel.FloorReliefStrength) + || !VoxelMath::IsFinite(Tunnel.FloorReliefFrequency)) + { + return 0.0f; + } + + const float HorizontalRun = FVector2D( + static_cast(B.X - A.X), static_cast(B.Y - A.Y)).Size(); + if (!(HorizontalRun > KINDA_SMALL_NUMBER) + || !VoxelMath::IsFinite(BaseGradient) + || BaseGradient < 0.0f) + { + return 0.0f; + } + + const float GentleThreshold = VoxelMath::IsFinite( + Tunnel.TunnelFloorGentleSlopeGradient) + ? FMath::Max(Tunnel.TunnelFloorGentleSlopeGradient, 0.0f) + : VoxelPassageGeometry::PlayerWalkableFloorMaxGradient; + const float AvailableGradient = GentleThreshold - BaseGradient; + if (!(AvailableGradient > 0.0f)) + { + return 0.0f; + } + + const float Frequency = FMath::Abs(Tunnel.FloorReliefFrequency); + const float Amplitude = FMath::Abs(Tunnel.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float OctaveFrequencyWeight = 0.65f + 0.35f * 2.3f; + const float NoiseGradientBound = + 1.4142135623730951f * VF_Perlin2DPartialAbsBound + * OctaveFrequencyWeight * Frequency * Amplitude; + const int32 LandingEnvelopeCount = (SegmentIndex == 0 ? 1 : 0) + + (SegmentIndex + 1 == NumSegments ? 1 : 0); + const float EnvelopeDerivativeBound = LandingEnvelopeCount > 0 + ? (1.5f * static_cast(LandingEnvelopeCount) + / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels) + * Amplitude + : 0.0f; + const float ReliefGradientBound = NoiseGradientBound + + EnvelopeDerivativeBound; + if (!(ReliefGradientBound > KINDA_SMALL_NUMBER) + || !VoxelMath::IsFinite(ReliefGradientBound)) + { + return 0.0f; + } + + return FMath::Clamp( + FMath::Min( + VF_MaxTunnelFloorReliefScale, + AvailableGradient / ReliefGradientBound), + 0.0f, VF_MaxTunnelFloorReliefScale); + } + + static void VF_BuildTunnelFloorProfile( + const TArray& ControlPoints, + const TArray& ControlRadii, + const FCachedTunnel& Tunnel, + const TArray* FloorLedgeLevels, + TArray& OutProfiles) + { + OutProfiles.Reset(); + if (ControlPoints.Num() < 2 + || ControlRadii.Num() != ControlPoints.Num()) + { + return; + } + + const int32 NumSegments = ControlPoints.Num() - 1; + OutProfiles.SetNum(NumSegments); + TArray> FloorAtControl; + FloorAtControl.SetNum(ControlPoints.Num()); + for (int32 Index = 0; Index < ControlPoints.Num(); ++Index) + { + FloorAtControl[Index] = VoxelPassageGeometry::TunnelFloorZ( + ControlPoints[Index], FMath::Abs(ControlRadii[Index])); + } + + const int32 MaxLedges = FMath::Clamp(Tunnel.TunnelFloorMaxLedges, 1, 4096); + const int32 AuthoredLedgeCount = FMath::Clamp( + Tunnel.TunnelFloorAuthoredLedgeCount, 1, + FMath::Min(MaxLedges, FMath::Max(1, NumSegments))); + const bool bHasLedgeLevels = Tunnel.bDramaticLedge + && AuthoredLedgeCount > 0 + && FloorLedgeLevels != nullptr + && FloorLedgeLevels->Num() == ControlPoints.Num(); + const float TotalFloorDelta = FloorAtControl.Last() - FloorAtControl[0]; + + for (int32 SegmentIndex = 0; SegmentIndex < NumSegments; ++SegmentIndex) + { + FTunnelFloorSegmentProfile& Profile = OutProfiles[SegmentIndex]; + const FVector& A = ControlPoints[SegmentIndex]; + const FVector& B = ControlPoints[SegmentIndex + 1]; + const float FloorA = FloorAtControl[SegmentIndex]; + const float FloorB = FloorAtControl[SegmentIndex + 1]; + const float HorizontalRun = FVector2D( + static_cast(B.X - A.X), + static_cast(B.Y - A.Y)).Size(); + Profile.StartFloorZ = FloorA; + Profile.EndFloorZ = FloorB; + Profile.NaturalStartFloorZ = FloorA; + Profile.NaturalEndFloorZ = FloorB; + Profile.bLedgeTransition = false; + + if (bHasLedgeLevels) + { + // The graph builder has already selected the few boundaries. The profile records + // one explicit vertical transition there; it never quantises samples inside the + // segment, so a steep edge cannot turn into a staircase. + const int32 StartLevel = FMath::Clamp( + (*FloorLedgeLevels)[SegmentIndex], 0, AuthoredLedgeCount); + const int32 EndLevel = FMath::Clamp( + (*FloorLedgeLevels)[SegmentIndex + 1], 0, AuthoredLedgeCount); + Profile.StartFloorZ = FloorAtControl[0] + + TotalFloorDelta * static_cast(StartLevel) + / static_cast(AuthoredLedgeCount); + Profile.EndFloorZ = FloorAtControl[0] + + TotalFloorDelta * static_cast(EndLevel) + / static_cast(AuthoredLedgeCount); + Profile.bLedgeTransition = EndLevel != StartLevel; + } + + const bool bTransition = Profile.bLedgeTransition + && !FMath::IsNearlyEqual(Profile.StartFloorZ, Profile.EndFloorZ); + const float ProfileGradient = (!bTransition && HorizontalRun > KINDA_SMALL_NUMBER) + ? FMath::Abs(Profile.EndFloorZ - Profile.StartFloorZ) / HorizontalRun + : 0.0f; + Profile.ReliefScale = VF_TunnelFloorReliefScaleForGradient( + A, B, SegmentIndex, NumSegments, Tunnel, + ProfileGradient, bTransition); + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + uint64 AuthoredLedges = 0; + uint64 LedgesBelow2 = 0; + uint64 Ledges2To4 = 0; + uint64 Ledges4To8 = 0; + uint64 Ledges8Plus = 0; + for (const FTunnelFloorSegmentProfile& Profile : OutProfiles) + { + if (Profile.bLedgeTransition) + { + ++AuthoredLedges; + const float LedgeHeight = FMath::Abs( + Profile.EndFloorZ - Profile.StartFloorZ); + if (LedgeHeight < 2.0f) { ++LedgesBelow2; } + else if (LedgeHeight < 4.0f) { ++Ledges2To4; } + else if (LedgeHeight < 8.0f) { ++Ledges4To8; } + else { ++Ledges8Plus; } + } + } + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileBuilds); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileSegments, + static_cast(OutProfiles.Num())); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges, + AuthoredLedges); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedgesBelow2Voxels, + LedgesBelow2); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges2To4Voxels, + Ledges2To4); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges4To8Voxels, + Ledges4To8); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges8PlusVoxels, + Ledges8Plus); + } + } + + struct FVFRoomFloorOwnership + { + bool bValid = false; + // The room-floor blend is intentionally short, but the tunnel capsule and the fitted + // player envelope can still overlap the room beyond that blend. This separate hand-off + // bit prevents the graph tunnel's lower-side polarity from turning that overlap into a + // wall without extending the room floor itself into the corridor. + bool bSuppressTunnelBottom = false; + float FloorZ = 0.0f; + float Weight = 0.0f; + uint32 RoomHash = 0; + }; + + static FVFRoomFloorOwnership VF_FindTunnelMouthOwnership( + const FVector& Position, + const TArray& ControlPoints, + const TArray& ControlRadii, + float MouthBlendRadiusA, + float MouthBlendRadiusB, + float MouthFloorZA, + float MouthFloorZB, + uint32 FloorRoomHashA, + uint32 FloorRoomHashB, + float SDFBlendRadius) + { + FVFRoomFloorOwnership Result; + const int32 ControlPointCount = ControlPoints.Num(); + if (ControlPointCount < 2 + || ControlRadii.Num() != ControlPointCount) + { + return Result; + } + + const FVector* ControlPointData = ControlPoints.GetData(); + const float* ControlRadiusData = ControlRadii.GetData(); + + const float Fade = FMath::Max( + FMath::Max(SDFBlendRadius, 0.0f) * 0.35f, + 1.0f); + float BestScore = FLT_MAX; + for (int32 Endpoint = 0; Endpoint < 2; ++Endpoint) + { + const int32 ControlIndex = Endpoint == 0 + ? 0 : ControlPointCount - 1; + const FVector& Mouth = ControlPointData[ControlIndex]; + const float DX = static_cast(Position.X - Mouth.X); + const float DY = static_cast(Position.Y - Mouth.Y); + const float Distance = FMath::Sqrt(DX * DX + DY * DY); + const float BlendRadius = FMath::Max( + Endpoint == 0 ? MouthBlendRadiusA : MouthBlendRadiusB, + 0.0f); + const float EndpointRadius = FMath::Abs(ControlRadiusData[ControlIndex]); + const float BottomHandoffRadius = BlendRadius + + VF_TunnelMouthPlayerFitOverlapVoxels + + EndpointRadius; + const float RelativeDistance = Distance - BlendRadius; + const float Weight = RelativeDistance <= 0.0f + ? 1.0f + : (RelativeDistance < Fade + ? 1.0f - SmoothStep01(RelativeDistance / Fade) + : 0.0f); + const bool bInBottomHandoff = VoxelMath::IsFinite(BottomHandoffRadius) + && Distance <= BottomHandoffRadius; + if (!(Weight > 0.0f) && !bInBottomHandoff) + { + continue; + } + + // Prefer the nearest normalized mouth. Endpoint order is the deterministic tie-break + // when two large rooms overlap the same tunnel entrance. + const float Score = Distance / FMath::Max(BlendRadius, 1.0f); + if (Score >= BestScore) + { + continue; + } + const float DefaultFloorZ = static_cast(Mouth.Z) + - FMath::Abs(ControlRadiusData[ControlIndex]); + const float StoredFloorZ = Endpoint == 0 ? MouthFloorZA : MouthFloorZB; + const float FloorZ = StoredFloorZ > -FLT_MAX + && VoxelMath::IsFinite(StoredFloorZ) + ? StoredFloorZ + : DefaultFloorZ; + if (!VoxelMath::IsFinite(FloorZ)) + { + continue; + } + BestScore = Score; + Result.bValid = Weight > 0.0f; + Result.bSuppressTunnelBottom = bInBottomHandoff; + Result.FloorZ = FloorZ; + Result.Weight = Weight; + Result.RoomHash = Endpoint == 0 ? FloorRoomHashA : FloorRoomHashB; + } + return Result; + } + + static FVFRoomFloorOwnership VF_FindSDFTunnelMouthOwnership( + const FVector& Position, + const FCachedTunnel& Tunnel, + float SDFBlendRadius) + { + if (!Tunnel.bHasFloorRoomOwnership) + { + return FVFRoomFloorOwnership(); + } + return VF_FindTunnelMouthOwnership( + Position, Tunnel.ControlPoints, Tunnel.ControlRadii, + Tunnel.SDFMouthBlendRadiusA, Tunnel.SDFMouthBlendRadiusB, + Tunnel.SDFMouthFloorZA, Tunnel.SDFMouthFloorZB, + Tunnel.FloorRoomHashA, Tunnel.FloorRoomHashB, SDFBlendRadius); + } + + static FVFRoomFloorOwnership VF_FindWorldTunnelMouthOwnership( + const FVector& Position, + const FCachedTunnel& Tunnel, + float SDFBlendRadius) + { + if (!Tunnel.bHasFloorRoomOwnership) + { + return FVFRoomFloorOwnership(); + } + return VF_FindTunnelMouthOwnership( + Position, Tunnel.WorldControlPoints, Tunnel.WorldControlRadii, + Tunnel.WorldMouthBlendRadiusA, Tunnel.WorldMouthBlendRadiusB, + Tunnel.WorldMouthFloorZA, Tunnel.WorldMouthFloorZB, + Tunnel.FloorRoomHashA, Tunnel.FloorRoomHashB, SDFBlendRadius); + } + + // The mouth hand-off is finite. Most tunnel samples are in open corridor space, so reject + // them with two squared-distance comparisons before entering the exact sqrt/SmoothStep query. + // The extra voxel is a conservative rounding pad; it cannot exclude a point accepted by + // VF_FindTunnelMouthOwnership. Returning true for malformed/non-finite cache data keeps the + // legacy exact query as the safety fallback. + static bool VF_MayHaveTunnelMouthOwnership( + const FVector& Position, + const FCachedTunnel& Tunnel, + bool bWorldChain, + float SDFBlendRadius) + { + if (!Tunnel.bHasFloorRoomOwnership) + { + return false; + } + + const TArray& ControlPoints = bWorldChain + ? Tunnel.WorldControlPoints : Tunnel.ControlPoints; + const TArray& ControlRadii = bWorldChain + ? Tunnel.WorldControlRadii : Tunnel.ControlRadii; + const int32 ControlPointCount = ControlPoints.Num(); + if (ControlPointCount < 2 || ControlRadii.Num() != ControlPointCount) + { + return false; + } + + const float Fade = FMath::Max( + FMath::Max(SDFBlendRadius, 0.0f) * 0.35f, + 1.0f); + if (!VoxelMath::IsFinite(Fade) + || !VoxelMath::IsFinite(Position.X) + || !VoxelMath::IsFinite(Position.Y)) + { + return true; + } + + const float BlendRadiusA = FMath::Max( + bWorldChain ? Tunnel.WorldMouthBlendRadiusA : Tunnel.SDFMouthBlendRadiusA, + 0.0f); + const float BlendRadiusB = FMath::Max( + bWorldChain ? Tunnel.WorldMouthBlendRadiusB : Tunnel.SDFMouthBlendRadiusB, + 0.0f); + const float BlendRadii[2] = {BlendRadiusA, BlendRadiusB}; + for (int32 Endpoint = 0; Endpoint < 2; ++Endpoint) + { + const int32 ControlIndex = Endpoint == 0 ? 0 : ControlPointCount - 1; + const FVector& Mouth = ControlPoints[ControlIndex]; + const float EndpointRadius = FMath::Abs(ControlRadii[ControlIndex]); + const float BlendRadius = BlendRadii[Endpoint]; + const float FadeReach = BlendRadius + Fade; + const float HandoffReach = BlendRadius + + VF_TunnelMouthPlayerFitOverlapVoxels + EndpointRadius; + const float Reach = FMath::Max(FadeReach, HandoffReach) + 1.0f; + if (!VoxelMath::IsFinite(Mouth.X) + || !VoxelMath::IsFinite(Mouth.Y) + || !VoxelMath::IsFinite(EndpointRadius) + || !VoxelMath::IsFinite(BlendRadius) + || !VoxelMath::IsFinite(Reach)) + { + return true; + } + + const float DX = static_cast(Position.X - Mouth.X); + const float DY = static_cast(Position.Y - Mouth.Y); + const float DistanceSquared = DX * DX + DY * DY; + if (!VoxelMath::IsFinite(DistanceSquared) + || DistanceSquared <= FMath::Square(Reach)) + { + return true; + } + } + return false; + } + + struct FVFTunnelFloorTerms + { + float BaseFloorZ = 0.0f; + float ReliefEnvelope = 0.0f; + float ReliefScale = 0.0f; + }; + + FORCEINLINE FVFTunnelFloorTerms VF_TunnelFloorTerms( + const FVector& A, float RadiusA, + const FVector& B, float RadiusB, + float T, int32 SegmentIndex, int32 NumSegments, + const FCachedTunnel& Tunnel, + const FTunnelFloorSegmentProfile* FloorProfile, + const FVFRoomFloorOwnership* RoomFloorOwnership) + { + FVFTunnelFloorTerms Terms; + if (FloorProfile != nullptr) + { + const float ClampedT = FMath::Clamp(T, 0.0f, 1.0f); + const float ProfileT = FloorProfile->bLedgeTransition + ? (ClampedT >= 1.0f + ? 1.0f + : 0.0f) + : ClampedT; + Terms.BaseFloorZ = FMath::Lerp( + FloorProfile->StartFloorZ, FloorProfile->EndFloorZ, ProfileT); + Terms.ReliefScale = FloorProfile->ReliefScale; + } + else + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileFallbacks); + } + const float ProfileT = VF_TunnelFloorProfileT( + A, RadiusA, B, RadiusB, T); + Terms.BaseFloorZ = FMath::Lerp( + VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA)), + VoxelPassageGeometry::TunnelFloorZ(B, FMath::Abs(RadiusB)), + ProfileT); + Terms.ReliefScale = VF_TunnelFloorReliefScale( + A, RadiusA, B, RadiusB, SegmentIndex, NumSegments, Tunnel); + } + + // The room is the owner of its own floor volume. A tunnel capsule penetrates past the + // mouth into that room; without this reconciliation its floor cut can leave a raised or + // lowered apron in the room. Blend only across the SDF mouth band so the tunnel remains + // unchanged in open corridor space. + const bool bOwnsThisTunnelMouth = RoomFloorOwnership != nullptr + && RoomFloorOwnership->bValid + && Tunnel.bHasFloorRoomOwnership + && (RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashA + || RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashB); + if (bOwnsThisTunnelMouth) + { + // The room is A and the corridor is B: inside the finite mouth hand-off, A owns the + // floor regardless of which endpoint happened to be higher. The old one-sided Min + // only removed a raised tunnel apron; a lower tunnel still won the SmoothMin union + // and left a lowered shelf in the chamber. Interpolate to the room floor in the + // authored apron so both representations agree at the room boundary. + Terms.BaseFloorZ = FMath::Lerp( + Terms.BaseFloorZ, RoomFloorOwnership->FloorZ, + FMath::Clamp(RoomFloorOwnership->Weight, 0.0f, 1.0f)); + // The room's floor relief is already included in FloorZ. Fade the tunnel relief out + // with the ownership blend, so the transition cannot stack two independent floors. + Terms.ReliefScale *= 1.0f - FMath::Clamp( + RoomFloorOwnership->Weight, 0.0f, 1.0f); + } + + const double DeltaX = static_cast(static_cast(B.X - A.X)); + const double DeltaY = static_cast(static_cast(B.Y - A.Y)); + const float HorizontalRun = static_cast(FMath::Sqrt( + DeltaX * DeltaX + DeltaY * DeltaY)); + Terms.ReliefEnvelope = 1.0f; + if (SegmentIndex == 0) + { + Terms.ReliefEnvelope *= SmoothStep01(FMath::Clamp( + (T * HorizontalRun) + / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, + 0.0f, 1.0f)); + } + if (SegmentIndex + 1 == NumSegments) + { + Terms.ReliefEnvelope *= SmoothStep01(FMath::Clamp( + ((1.0f - T) * HorizontalRun) + / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, + 0.0f, 1.0f)); + } + return Terms; + } + + FORCEINLINE float VF_TunnelFloorFromTerms( + const FVector& Position, + const FCachedTunnel& Tunnel, + const FVFTunnelFloorTerms& Terms, + const void* CacheIdentity, int32 TunnelIndex, + bool bEvaluateRelief, + const FVFFloorReliefColumnKey& Column) + { + float FloorZ = Terms.BaseFloorZ; + if (bEvaluateRelief) + { + FloorZ += VF_TunnelFloorRelief( + static_cast(Position.X), static_cast(Position.Y), + Tunnel, Terms.ReliefEnvelope, Terms.ReliefScale, + CacheIdentity, TunnelIndex, Column); + } + return FloorZ; + } + + FORCEINLINE bool VF_ProjectTunnelSegmentXY( + const FVector& Position, const FVector& A, const FVector& B, + float& OutT, float& OutDistanceSquared) + { + const double AXY_X = static_cast(static_cast(A.X)); + const double AXY_Y = static_cast(static_cast(A.Y)); + const double DeltaX = static_cast(static_cast(B.X - A.X)); + const double DeltaY = static_cast(static_cast(B.Y - A.Y)); + const float LengthSquared = static_cast( + DeltaX * DeltaX + DeltaY * DeltaY); + if (LengthSquared <= KINDA_SMALL_NUMBER) + { + OutT = 0.0f; + OutDistanceSquared = FLT_MAX; + return false; + } + + const double QueryX = static_cast(static_cast(Position.X)); + const double QueryY = static_cast(static_cast(Position.Y)); + const double Dot = (QueryX - AXY_X) * DeltaX + + (QueryY - AXY_Y) * DeltaY; + OutT = FMath::Clamp(Dot / LengthSquared, 0.0f, 1.0f); + const double ClosestX = AXY_X + DeltaX * OutT; + const double ClosestY = AXY_Y + DeltaY * OutT; + const double DistanceX = QueryX - ClosestX; + const double DistanceY = QueryY - ClosestY; + OutDistanceSquared = static_cast( + DistanceX * DistanceX + DistanceY * DistanceY); + return true; + } + + static FVFTunnelShapeEvaluation VF_EvaluateSweptTunnelChain( + const FVector& Position, + const TArray& ControlPoints, + const TArray& ControlRadii, + const FCachedTunnel& Tunnel, + float SDFBlendRadius, + bool bApplyFloorCut, + const void* CacheIdentity, int32 TunnelIndex, + const FVFFloorReliefColumnKey& ReliefColumn, + const TArray* FloorProfiles, + const FVFRoomFloorOwnership* RoomFloorOwnership) + { + FVFTunnelShapeEvaluation Result; + const int32 ControlPointCount = ControlPoints.Num(); + if (ControlPointCount < 2 + || ControlRadii.Num() != ControlPointCount) + { + return Result; + } + + const int32 NumSegments = ControlPointCount - 1; + const FVector* ControlPointData = ControlPoints.GetData(); + const float* ControlRadiusData = ControlRadii.GetData(); + const FTunnelFloorSegmentProfile* FloorProfileData = nullptr; + if (FloorProfiles != nullptr && FloorProfiles->Num() == NumSegments) + { + FloorProfileData = FloorProfiles->GetData(); + } + const float FloorBlend = FMath::Max(SDFBlendRadius, 0.0f) * 0.35f; + for (int32 SegmentIndex = 0; + SegmentIndex < NumSegments; + ++SegmentIndex) + { + const FVector& A = ControlPointData[SegmentIndex]; + const FVector& B = ControlPointData[SegmentIndex + 1]; + const float RadiusA = ControlRadiusData[SegmentIndex]; + const float RadiusB = ControlRadiusData[SegmentIndex + 1]; + float T = 0.0f; + float HorizontalDistanceSquared = FLT_MAX; + const bool bHasHorizontalProjection = VF_ProjectTunnelSegmentXY( + Position, A, B, T, HorizontalDistanceSquared); + if (!bHasHorizontalProjection) + { + // A vertical/degenerate XY segment has no finite floor projection. Retain the + // capsule for air, but never invent a support surface there. + Result.SDF = FMath::Min( + Result.SDF, + VoxelSDF::TaperedCapsule(Position, A, B, RadiusA, RadiusB)); + continue; + } + + const FTunnelFloorSegmentProfile* Profile = FloorProfileData != nullptr + ? FloorProfileData + SegmentIndex : nullptr; + const FVFTunnelFloorTerms FloorTerms = VF_TunnelFloorTerms( + A, RadiusA, B, RadiusB, + T, SegmentIndex, NumSegments, Tunnel, Profile, + RoomFloorOwnership); + const float NaturalFloorZ = Profile != nullptr + ? FMath::Lerp( + Profile->NaturalStartFloorZ, + Profile->NaturalEndFloorZ, + FMath::Clamp(T, 0.0f, 1.0f)) + : FMath::Lerp( + VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA)), + VoxelPassageGeometry::TunnelFloorZ(B, FMath::Abs(RadiusB)), + FMath::Clamp(T, 0.0f, 1.0f)); + const float SupportRadius = FMath::Max( + FMath::Min(FMath::Abs(RadiusA), FMath::Abs(RadiusB)) - 0.5f, + VoxelPassageGeometry::PlayerRadiusVoxels); + float ReliefBound = 0.0f; + const bool bHasReliefBound = VF_GetFloorReliefBound( + Tunnel.FloorReliefStrength, + FloorTerms.ReliefEnvelope, + FloorTerms.ReliefScale, + Tunnel.FloorReliefFrequency, + ReliefBound); + + // The corridor is authored as one shape: translate this tapered capsule so its + // bottom follows the immutable floor profile. A zero shift is deliberately kept on + // the original call, preserving the natural path exactly. The provisional shift + // excludes relief only long enough to decide whether the bounded relief can matter; + // the final shift below includes the actual cached column value. + // Every complete build-time profile owns the floor. A route that was deliberately + // wound, or that contains an explicit ledge transition, also moves the swept curve + // with that authored floor. A direct smooth ramp keeps the established natural arch + // and lets the SmoothMax floor cut provide the walkable D-floor; this avoids changing + // headroom in every gentle tunnel merely because a profile is now always present. + const bool bUseAuthoredProfile = bApplyFloorCut + && Profile != nullptr + && (Tunnel.bFloorRouteWasWound || Tunnel.bDramaticLedge); + const bool bAnchorCapsule = bUseAuthoredProfile + && VoxelMath::IsFinite(NaturalFloorZ); + const auto EvaluateAnchoredShape = [ + &Position, &A, &B, RadiusA, RadiusB, + NaturalFloorZ, bAnchorCapsule] + (float FloorZ) -> float + { + const float Shift = FloorZ - NaturalFloorZ; + if (!bAnchorCapsule + || !VoxelMath::IsFinite(Shift) + || FMath::Abs(Shift) <= KINDA_SMALL_NUMBER) + { + return VoxelSDF::TaperedCapsule( + Position, A, B, RadiusA, RadiusB); + } + const FVector Offset(0.0f, 0.0f, Shift); + return VoxelSDF::TaperedCapsule( + Position, A + Offset, B + Offset, RadiusA, RadiusB); + }; + float SegmentSDF = EvaluateAnchoredShape(FloorTerms.BaseFloorZ); + + bool bEvaluateRelief = bApplyFloorCut; + if (bEvaluateRelief && bHasReliefBound) + { + // If the capsule is already above the highest possible floor by at least the + // SmoothMax radius, the cut is exactly the identity. The support test below is a + // separate obligation: a floor that does not alter SDF at this sample may still + // be the support band queried by the post stack. + const bool bFloorCannotAffect = SegmentSDF >= + FloorTerms.BaseFloorZ + ReliefBound + - static_cast(Position.Z) + FloorBlend; + const bool bCannotBeSupport = + SegmentSDF > 0.0f + || HorizontalDistanceSquared > FMath::Square(SupportRadius) + || Position.Z < FloorTerms.BaseFloorZ - ReliefBound + - VoxelPassageGeometry::LandingFloorThicknessVoxels + || Position.Z > FloorTerms.BaseFloorZ + ReliefBound + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; + bEvaluateRelief = !bFloorCannotAffect || !bCannotBeSupport; + } + if (bApplyFloorCut && RoomFloorOwnership != nullptr + && RoomFloorOwnership->bValid + && Tunnel.bHasFloorRoomOwnership + && (RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashA + || RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashB) + && RoomFloorOwnership->Weight > 0.0f) + { + // Room ownership can move the floor even when the uncapped tunnel floor is + // outside its own relief bound. Keep the exact room relief in that mouth band. + bEvaluateRelief = true; + } + + const float FloorZ = VF_TunnelFloorFromTerms( + Position, Tunnel, FloorTerms, + CacheIdentity, TunnelIndex, bEvaluateRelief, ReliefColumn); + // Relief belongs to the authored floor as well as to its SmoothMax cut. Re-evaluate + // the translated arch only when the column relief actually changed its anchor; most + // samples retain the cheap provisional result. + if (bAnchorCapsule + && VoxelMath::IsFinite(FloorZ) + && FMath::Abs(FloorZ - FloorTerms.BaseFloorZ) > KINDA_SMALL_NUMBER) + { + SegmentSDF = EvaluateAnchoredShape(FloorZ); + } + const float SegmentResultSDF = bApplyFloorCut + ? VoxelSDF::SmoothMax( + SegmentSDF, FloorZ - static_cast(Position.Z), FloorBlend) + : SegmentSDF; + if (SegmentResultSDF < Result.SDF) + { + Result.SDF = SegmentResultSDF; + // This is shape metadata, not a second floor field. It records which side of + // the winning swept SDF is its own bottom so the final MC hand-off can restore + // that side when a room's generic air field would otherwise erase it. + Result.bHasSweptFloor = bApplyFloorCut + && VoxelMath::IsFinite(FloorZ) + && VoxelMath::IsFinite(SupportRadius) + && VoxelMath::IsFinite(HorizontalDistanceSquared) + && HorizontalDistanceSquared <= FMath::Square(SupportRadius) + // The room owns the finite mouth apron. Do not let the tunnel's bottom + // polarity turn that overlap into a wall; the corridor interior remains + // owned by this swept shape. + && !(RoomFloorOwnership != nullptr + && RoomFloorOwnership->bSuppressTunnelBottom); + Result.SweptFloorZ = Result.bHasSweptFloor ? FloorZ : -FLT_MAX; + Result.SweptFloorRadius = Result.bHasSweptFloor ? SupportRadius : 0.0f; + } + + } + return Result; + } + + static FVFTunnelShapeEvaluation VF_EvaluateSweptTunnel( + const FVector& Position, + const FCachedTunnel& Tunnel, + bool bWorldChain, + float SDFBlendRadius, + bool bApplyFloorCut, + const void* CacheIdentity, int32 TunnelIndex, + const FVFRoomFloorOwnership* RoomFloorOwnership = nullptr) + { + const FVFFloorReliefColumnKey ReliefColumn = + VF_MakeFloorReliefColumnKey( + static_cast(Position.X), static_cast(Position.Y)); + const TArray& ShapeControlPoints = bWorldChain + ? Tunnel.WorldControlPoints : Tunnel.ControlPoints; + const TArray& ShapeControlRadii = bWorldChain + ? Tunnel.WorldControlRadii : Tunnel.ControlRadii; + if (ShapeControlPoints.Num() >= 2 + && ShapeControlRadii.Num() == ShapeControlPoints.Num()) + { + return VF_EvaluateSweptTunnelChain( + Position, ShapeControlPoints, ShapeControlRadii, + Tunnel, SDFBlendRadius, bApplyFloorCut, + CacheIdentity, TunnelIndex, ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); + } + + if (Tunnel.bHasMidpoint) + { + TArray Points; + TArray Radii; + Points.Add(Tunnel.EndpointA); + Points.Add(Tunnel.Midpoint); + Points.Add(Tunnel.EndpointB); + Radii.Add(Tunnel.RadiusA); + Radii.Add(Tunnel.RadiusMid); + Radii.Add(Tunnel.RadiusB); + // The fallback descriptor is only retained for cooked/old cache compatibility. The + // temporary arrays stay inline and do not allocate on the normal chain path. + return VF_EvaluateSweptTunnelChain( + Position, Points, Radii, + Tunnel, SDFBlendRadius, bApplyFloorCut, + CacheIdentity, TunnelIndex, ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); + } + + TArray Points; + TArray Radii; + Points.Add(Tunnel.EndpointA); + Points.Add(Tunnel.EndpointB); + Radii.Add(Tunnel.RadiusA); + Radii.Add(Tunnel.RadiusB); + return VF_EvaluateSweptTunnelChain( + Position, Points, Radii, + Tunnel, SDFBlendRadius, bApplyFloorCut, + CacheIdentity, TunnelIndex, ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); + } + +} + +void FChunkSDFSpatialIndex::Reset() +{ + MinChunkX = 0; + MinChunkY = 0; + NumCellsX = 0; + NumCellsY = 0; + bValid = false; + CellOffsets.Reset(); + ItemIndices.Reset(); +} + +void FChunkSDFSpatialIndex::Release() +{ + MinChunkX = 0; + MinChunkY = 0; + NumCellsX = 0; + NumCellsY = 0; + bValid = false; + CellOffsets.Empty(); + ItemIndices.Empty(); +} + +SIZE_T FChunkSDFSpatialIndex::GetAllocatedSize() const +{ + return static_cast(CellOffsets.GetAllocatedSize()) + + static_cast(ItemIndices.GetAllocatedSize()); +} + +bool FChunkSDFSpatialIndex::GetRange( + float WorldX, float WorldY, int32& OutBegin, int32& OutEnd) const +{ + OutBegin = 0; + OutEnd = 0; + if (!bValid + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || NumCellsX <= 0 || NumCellsY <= 0) + { + return false; + } + + const double ChunkXDouble = FMath::FloorToDouble( + static_cast(WorldX) / static_cast(CHUNK_SIZE)); + const double ChunkYDouble = FMath::FloorToDouble( + static_cast(WorldY) / static_cast(CHUNK_SIZE)); + if (ChunkXDouble < static_cast(MIN_int32) + || ChunkXDouble > static_cast(MAX_int32) + || ChunkYDouble < static_cast(MIN_int32) + || ChunkYDouble > static_cast(MAX_int32)) + { + return false; + } + + const int64 LocalX = static_cast(ChunkXDouble) - static_cast(MinChunkX); + const int64 LocalY = static_cast(ChunkYDouble) - static_cast(MinChunkY); + if (LocalX < 0 || LocalX >= static_cast(NumCellsX) + || LocalY < 0 || LocalY >= static_cast(NumCellsY)) + { + // A point outside the indexed rectangle is uncommon for the requesting tile, but the + // exact fallback is safer than assuming the index covers a malformed/legacy query. + return false; + } + + const int64 Slot = LocalY * static_cast(NumCellsX) + LocalX; + if (Slot < 0 || Slot + 1 >= static_cast(CellOffsets.Num())) + { + return false; + } + + const int32* CellOffsetData = CellOffsets.GetData(); + OutBegin = CellOffsetData[static_cast(Slot)]; + OutEnd = CellOffsetData[static_cast(Slot + 1)]; + return OutBegin >= 0 && OutEnd >= OutBegin && OutEnd <= ItemIndices.Num(); +} + +void FChunkSDFBrickIndex::Reset() +{ + MinBrickX = MinBrickY = MinBrickZ = 0; + NumBricksX = NumBricksY = NumBricksZ = 0; + bValid = false; + CellOffsets.Reset(); + ItemIndices.Reset(); +} + +void FChunkSDFBrickIndex::Release() +{ + MinBrickX = MinBrickY = MinBrickZ = 0; + NumBricksX = NumBricksY = NumBricksZ = 0; + bValid = false; + CellOffsets.Empty(); + ItemIndices.Empty(); +} + +SIZE_T FChunkSDFBrickIndex::GetAllocatedSize() const +{ + return static_cast(CellOffsets.GetAllocatedSize()) + + static_cast(ItemIndices.GetAllocatedSize()); +} + +bool FChunkSDFBrickIndex::GetRange( + float WorldX, float WorldY, float WorldZ, + int32& OutBegin, int32& OutEnd) const +{ + OutBegin = OutEnd = 0; + constexpr double BrickSize = 8.0; + if (!bValid || NumBricksX <= 0 || NumBricksY <= 0 || NumBricksZ <= 0 + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(WorldZ)) + { + return false; + } + const double BX = FMath::FloorToDouble(static_cast(WorldX) / BrickSize); + const double BY = FMath::FloorToDouble(static_cast(WorldY) / BrickSize); + const double BZ = FMath::FloorToDouble(static_cast(WorldZ) / BrickSize); + if (BX < MIN_int32 || BX > MAX_int32 || BY < MIN_int32 || BY > MAX_int32 + || BZ < MIN_int32 || BZ > MAX_int32) + { + return false; + } + const int64 X = static_cast(BX) - MinBrickX; + const int64 Y = static_cast(BY) - MinBrickY; + const int64 Z = static_cast(BZ) - MinBrickZ; + if (X < 0 || X >= NumBricksX || Y < 0 || Y >= NumBricksY + || Z < 0 || Z >= NumBricksZ) + { + return false; + } + const int64 Slot = (Z * NumBricksY + Y) * NumBricksX + X; + if (Slot < 0 || Slot + 1 >= CellOffsets.Num()) + { + return false; + } + OutBegin = CellOffsets[static_cast(Slot)]; + OutEnd = CellOffsets[static_cast(Slot + 1)]; + return OutBegin >= 0 && OutEnd >= OutBegin && OutEnd <= ItemIndices.Num(); +} + +namespace +{ + struct FVFSpatialBounds + { + float MinX = 0.0f; + float MinY = 0.0f; + float MaxX = 0.0f; + float MaxY = 0.0f; + }; + + static bool VF_SetSpatialBounds( + float CenterX, float CenterY, float Radius, FVFSpatialBounds& OutBounds) + { + if (!VoxelMath::IsFinite(CenterX) || !VoxelMath::IsFinite(CenterY) + || !VoxelMath::IsFinite(Radius) || Radius < 0.0f) + { + return false; + } + OutBounds.MinX = CenterX - Radius; + OutBounds.MinY = CenterY - Radius; + OutBounds.MaxX = CenterX + Radius; + OutBounds.MaxY = CenterY + Radius; + return VoxelMath::IsFinite(OutBounds.MinX) && VoxelMath::IsFinite(OutBounds.MinY) + && VoxelMath::IsFinite(OutBounds.MaxX) && VoxelMath::IsFinite(OutBounds.MaxY) + && OutBounds.MinX <= OutBounds.MaxX && OutBounds.MinY <= OutBounds.MaxY; + } + + static bool VF_SetSpatialAabb( + float MinX, float MinY, float MaxX, float MaxY, + float Radius, FVFSpatialBounds& OutBounds) + { + if (!VoxelMath::IsFinite(MinX) || !VoxelMath::IsFinite(MinY) + || !VoxelMath::IsFinite(MaxX) || !VoxelMath::IsFinite(MaxY) + || !VoxelMath::IsFinite(Radius) || Radius < 0.0f + || MinX > MaxX || MinY > MaxY) + { + return false; + } + OutBounds.MinX = MinX - Radius; + OutBounds.MinY = MinY - Radius; + OutBounds.MaxX = MaxX + Radius; + OutBounds.MaxY = MaxY + Radius; + return VoxelMath::IsFinite(OutBounds.MinX) && VoxelMath::IsFinite(OutBounds.MinY) + && VoxelMath::IsFinite(OutBounds.MaxX) && VoxelMath::IsFinite(OutBounds.MaxY) + && OutBounds.MinX <= OutBounds.MaxX && OutBounds.MinY <= OutBounds.MaxY; + } + + static bool VF_IncludeSpatialBounds( + FVFSpatialBounds& InOut, const FVFSpatialBounds& Other, bool& bHasBounds) + { + if (!bHasBounds) + { + InOut = Other; + bHasBounds = true; + return true; + } + InOut.MinX = FMath::Min(InOut.MinX, Other.MinX); + InOut.MinY = FMath::Min(InOut.MinY, Other.MinY); + InOut.MaxX = FMath::Max(InOut.MaxX, Other.MaxX); + InOut.MaxY = FMath::Max(InOut.MaxY, Other.MaxY); + return true; + } + + template + static void VF_BuildSpatialIndex( + FChunkSDFSpatialIndex& OutIndex, int32 ItemCount, FGetBounds&& GetBounds) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfSpatialIndexBuild); + } + OutIndex.Reset(); + if (ItemCount <= 0) + { + return; + } + + // Never turn a malformed authored bound into a giant allocation. A failed build is + // deliberately indistinguishable from the unindexed linear scan to the density evaluator. + constexpr int64 MaxBuckets = 1024 * 1024; + constexpr int64 MaxReferences = 4 * 1024 * 1024; + TArray ItemMinX; + TArray ItemMinY; + TArray ItemMaxX; + TArray ItemMaxY; + ItemMinX.SetNumUninitialized(ItemCount); + ItemMinY.SetNumUninitialized(ItemCount); + ItemMaxX.SetNumUninitialized(ItemCount); + ItemMaxY.SetNumUninitialized(ItemCount); + + FVFSpatialBounds AllBounds; + bool bHasBounds = false; + int32 GlobalMinX = MAX_int32; + int32 GlobalMinY = MAX_int32; + int32 GlobalMaxX = MIN_int32; + int32 GlobalMaxY = MIN_int32; + int64 TotalReferences = 0; + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + FVFSpatialBounds Bounds; + if (!GetBounds(ItemIndex, Bounds)) + { + return; + } + + const double MinChunkXDouble = FMath::FloorToDouble( + static_cast(Bounds.MinX) / static_cast(CHUNK_SIZE)); + const double MinChunkYDouble = FMath::FloorToDouble( + static_cast(Bounds.MinY) / static_cast(CHUNK_SIZE)); + const double MaxChunkXDouble = FMath::FloorToDouble( + static_cast(Bounds.MaxX) / static_cast(CHUNK_SIZE)); + const double MaxChunkYDouble = FMath::FloorToDouble( + static_cast(Bounds.MaxY) / static_cast(CHUNK_SIZE)); + if (MinChunkXDouble < static_cast(MIN_int32) + || MinChunkXDouble > static_cast(MAX_int32) + || MinChunkYDouble < static_cast(MIN_int32) + || MinChunkYDouble > static_cast(MAX_int32) + || MaxChunkXDouble < static_cast(MIN_int32) + || MaxChunkXDouble > static_cast(MAX_int32) + || MaxChunkYDouble < static_cast(MIN_int32) + || MaxChunkYDouble > static_cast(MAX_int32)) + { + return; + } + + const int32 MinX = static_cast(MinChunkXDouble); + const int32 MinY = static_cast(MinChunkYDouble); + const int32 MaxX = static_cast(MaxChunkXDouble); + const int32 MaxY = static_cast(MaxChunkYDouble); + if (MinX > MaxX || MinY > MaxY) + { + return; + } + + ItemMinX[ItemIndex] = MinX; + ItemMinY[ItemIndex] = MinY; + ItemMaxX[ItemIndex] = MaxX; + ItemMaxY[ItemIndex] = MaxY; + GlobalMinX = FMath::Min(GlobalMinX, MinX); + GlobalMinY = FMath::Min(GlobalMinY, MinY); + GlobalMaxX = FMath::Max(GlobalMaxX, MaxX); + GlobalMaxY = FMath::Max(GlobalMaxY, MaxY); + TotalReferences += (static_cast(MaxX) - MinX + 1) + * (static_cast(MaxY) - MinY + 1); + if (TotalReferences > MaxReferences) + { + return; + } + VF_IncludeSpatialBounds(AllBounds, Bounds, bHasBounds); + } + + const int64 NumCellsX64 = static_cast(GlobalMaxX) - GlobalMinX + 1; + const int64 NumCellsY64 = static_cast(GlobalMaxY) - GlobalMinY + 1; + if (!bHasBounds || NumCellsX64 <= 0 || NumCellsY64 <= 0 + || NumCellsX64 > MAX_int32 || NumCellsY64 > MAX_int32 + || NumCellsX64 * NumCellsY64 > MaxBuckets) + { + return; + } + + OutIndex.MinChunkX = GlobalMinX; + OutIndex.MinChunkY = GlobalMinY; + OutIndex.NumCellsX = static_cast(NumCellsX64); + OutIndex.NumCellsY = static_cast(NumCellsY64); + const int64 BucketCount = NumCellsX64 * NumCellsY64; + OutIndex.CellOffsets.SetNumZeroed(static_cast(BucketCount + 1)); + + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + for (int32 Y = ItemMinY[ItemIndex]; Y <= ItemMaxY[ItemIndex]; ++Y) + { + const int64 Row = static_cast(Y - OutIndex.MinChunkY) + * static_cast(OutIndex.NumCellsX); + for (int32 X = ItemMinX[ItemIndex]; X <= ItemMaxX[ItemIndex]; ++X) + { + const int64 Slot = Row + static_cast(X - OutIndex.MinChunkX); + ++OutIndex.CellOffsets[static_cast(Slot + 1)]; + } + } + } + for (int32 Slot = 0; Slot < static_cast(BucketCount); ++Slot) + { + OutIndex.CellOffsets[Slot + 1] += OutIndex.CellOffsets[Slot]; + } + + OutIndex.ItemIndices.SetNumUninitialized(static_cast(TotalReferences)); + TArray WriteOffsets = OutIndex.CellOffsets; + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + for (int32 Y = ItemMinY[ItemIndex]; Y <= ItemMaxY[ItemIndex]; ++Y) + { + const int64 Row = static_cast(Y - OutIndex.MinChunkY) + * static_cast(OutIndex.NumCellsX); + for (int32 X = ItemMinX[ItemIndex]; X <= ItemMaxX[ItemIndex]; ++X) + { + const int64 Slot = Row + static_cast(X - OutIndex.MinChunkX); + OutIndex.ItemIndices[WriteOffsets[static_cast(Slot)]++] = ItemIndex; + } + } + } + OutIndex.bValid = true; + } + + template + static int32 VF_ForEachSpatialCandidate( + const FChunkSDFSpatialIndex& Index, int32 ItemCount, + float WorldX, float WorldY, FVisit&& Visit, + bool bUseSpatialIndex = true) + { + int32 Begin = 0; + int32 End = 0; + int32 CandidateCount = 0; + if (bUseSpatialIndex && Index.GetRange(WorldX, WorldY, Begin, End)) + { + const int32* ItemIndexData = Index.ItemIndices.GetData(); + for (int32 Cursor = Begin; Cursor < End; ++Cursor) + { + const int32 ItemIndex = ItemIndexData[Cursor]; + if (ItemIndex >= 0 && ItemIndex < ItemCount) + { + ++CandidateCount; + Visit(ItemIndex); + } + } + return CandidateCount; + } + + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + ++CandidateCount; + Visit(ItemIndex); + } + return CandidateCount; + } + + template + static FORCEINLINE int32 VF_ForEachSDFCandidate( + const FChunkSDFSpatialIndex& SpatialIndex, +#if WITH_EDITOR + const FChunkSDFBrickIndex& BrickIndex, +#endif + int32 ItemCount, float WorldX, float WorldY, float WorldZ, + FVisit&& Visit, bool bUseSpatialIndex = true) + { +#if WITH_EDITOR + if (VoxelCaveMorphology::IsResearchBrickBinningEnabled()) + { + int32 Begin = 0; + int32 End = 0; + if (BrickIndex.GetRange(WorldX, WorldY, WorldZ, Begin, End)) + { + int32 CandidateCount = 0; + const int32* Indices = BrickIndex.ItemIndices.GetData(); + for (int32 Cursor = Begin; Cursor < End; ++Cursor) + { + const int32 ItemIndex = Indices[Cursor]; + if (ItemIndex >= 0 && ItemIndex < ItemCount) + { + ++CandidateCount; + Visit(ItemIndex); + } + } + return CandidateCount; + } + } +#endif + return VF_ForEachSpatialCandidate( + SpatialIndex, ItemCount, WorldX, WorldY, + Forward(Visit), bUseSpatialIndex); + } + + static bool VF_GetRoomSpatialBounds(int32, const FCachedRoom& Room, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + static_cast(Room.Center.X), static_cast(Room.Center.Y), + FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)), OutBounds); + } + + static float VF_CachedRoomShapeSDF( + const FCachedRoom& Room, const FVector& Position) + { + switch (Room.ShapeType) + { + case 1: + return VoxelSDF::RoundedBox( + Position, Room.Center, Room.ShapeA, Room.ShapeR); + case 2: + return VoxelSDF::Capsule( + Position, Room.ShapeA, Room.ShapeB, Room.ShapeR); + default: + return VoxelSDF::Ellipsoid(Position, Room.Center, Room.ShapeA); + } + } + + static bool VF_IsInsideCachedRoomShape( + const FVector& Position, + const FChunkSDFCache& Cache, + bool bUseSpatialIndex) + { + bool bInside = false; + auto VisitRoom = [&](int32 RoomIndex) + { + if (bInside || !Cache.Rooms.IsValidIndex(RoomIndex)) + { + return; + } + const FCachedRoom& Room = Cache.Rooms[RoomIndex]; + if (VF_FloatDistSquared( + static_cast(Position.X), + static_cast(Position.Y), + static_cast(Position.Z), + Room.Center) > Room.CullRadiusSq) + { + return; + } + bInside = VF_CachedRoomShapeSDF(Room, Position) < 0.0f; + }; + VF_ForEachSDFCandidate( + Cache.RoomSpatialIndex, +#if WITH_EDITOR + Cache.RoomBrickIndex, +#endif + Cache.Rooms.Num(), static_cast(Position.X), + static_cast(Position.Y), static_cast(Position.Z), + VisitRoom, bUseSpatialIndex); + return bInside; + } + + static bool VF_GetJoinSpatialBounds( + int32, const FCachedRoomFloorJoin& Join, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + static_cast(Join.BoundCenter.X), static_cast(Join.BoundCenter.Y), + FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetTunnelSpatialBounds( + int32, const FCachedTunnel& Tunnel, FVFSpatialBounds& OutBounds) + { + bool bHasBounds = false; + auto IncludeCenterRadius = [&](float X, float Y, float Radius) -> bool + { + FVFSpatialBounds Bounds; + if (!VF_SetSpatialBounds(X, Y, Radius, Bounds)) + { + return false; + } + return VF_IncludeSpatialBounds(OutBounds, Bounds, bHasBounds); + }; + auto IncludeAabbRadius = [&](const FVector& Min, const FVector& Max, float Radius) -> bool + { + FVFSpatialBounds Bounds; + if (!VF_SetSpatialAabb( + static_cast(Min.X), static_cast(Min.Y), + static_cast(Max.X), static_cast(Max.Y), + FMath::Max(Radius, 0.0f), Bounds)) + { + return false; + } + return VF_IncludeSpatialBounds(OutBounds, Bounds, bHasBounds); + }; + + if (!IncludeCenterRadius( + static_cast(Tunnel.BoundCenter.X), + static_cast(Tunnel.BoundCenter.Y), + FMath::Sqrt(FMath::Max(Tunnel.BoundRadiusSq, 0.0f))) + || !IncludeCenterRadius( + static_cast(Tunnel.WorldBoundCenter.X), + static_cast(Tunnel.WorldBoundCenter.Y), + FMath::Sqrt(FMath::Max(Tunnel.WorldBoundRadiusSq, 0.0f))) + || !IncludeAabbRadius( + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax, + Tunnel.SDFInfluenceRadius) + || !IncludeAabbRadius( + Tunnel.WorldCenterlineMin, Tunnel.WorldCenterlineMax, + Tunnel.WorldInfluenceRadius)) + { + return false; + } + return bHasBounds; + } + + static bool VF_GetPitSpatialBounds( + int32, const FCachedPit& Pit, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + Pit.CenterX, Pit.CenterY, + FMath::Sqrt(FMath::Max(Pit.BoundXYRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetChimneySpatialBounds( + int32, const FCachedChimney& Chimney, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + Chimney.CenterX, Chimney.CenterY, + FMath::Sqrt(FMath::Max(Chimney.BoundXYRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetColumnSpatialBounds( + int32, const FCachedColumn& Column, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + Column.CenterX, Column.CenterY, + FMath::Sqrt(FMath::Max(Column.BoundXYRadiusSq, 0.0f)), OutBounds); + } + +#if WITH_EDITOR + struct FVFBrickBounds + { + FVector Min = FVector::ZeroVector; + FVector Max = FVector::ZeroVector; + }; + + static bool VF_IncludeBrickBounds( + FVFBrickBounds& InOut, const FVector& Min, const FVector& Max, bool& bHasBounds) + { + 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; + } + if (!bHasBounds) + { + InOut.Min = Min; + InOut.Max = Max; + bHasBounds = true; + } + else + { + InOut.Min.X = FMath::Min(InOut.Min.X, Min.X); + InOut.Min.Y = FMath::Min(InOut.Min.Y, Min.Y); + InOut.Min.Z = FMath::Min(InOut.Min.Z, Min.Z); + InOut.Max.X = FMath::Max(InOut.Max.X, Max.X); + InOut.Max.Y = FMath::Max(InOut.Max.Y, Max.Y); + InOut.Max.Z = FMath::Max(InOut.Max.Z, Max.Z); + } + return true; + } + + static bool VF_IncludeBrickSphere( + FVFBrickBounds& InOut, const FVector& Center, float Radius, bool& bHasBounds) + { + if (!VoxelMath::IsFinite(Radius) || Radius < 0.0f) + { + return false; + } + const FVector FloatCenter( + static_cast(Center.X), static_cast(Center.Y), static_cast(Center.Z)); + const float CoordinateScale = FMath::Max3( + 1.0f, FMath::Abs(static_cast(Center.X)), + FMath::Max3(FMath::Abs(static_cast(Center.Y)), + FMath::Abs(static_cast(Center.Z)), FMath::Abs(Radius))); + // Leave a float-operation margin around the exact live cull. It covers rounding in the + // float center conversion, distance square/sum, and radius-square comparison; it is a + // numerical bound, not an authored feature-radius guess. + const float ConservativeRadius = Radius + CoordinateScale * (32.0f * FLT_EPSILON); + const FVector R(ConservativeRadius, ConservativeRadius, ConservativeRadius); + return VF_IncludeBrickBounds(InOut, FloatCenter - R, FloatCenter + R, bHasBounds); + } + + template + static void VF_BuildBrickIndex( + FChunkSDFBrickIndex& OutIndex, int32 ItemCount, FGetBounds&& GetBounds) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfBrickIndexBuild); + } + OutIndex.Reset(); + if (ItemCount <= 0) + { + OutIndex.bValid = true; + return; + } + + constexpr double BrickSize = 8.0; + constexpr int64 MaxBuckets = 1024 * 1024; + constexpr int64 MaxReferences = 4 * 1024 * 1024; + TArray ItemMin; + TArray ItemMax; + ItemMin.SetNumUninitialized(ItemCount); + ItemMax.SetNumUninitialized(ItemCount); + int32 GlobalMinX = MAX_int32, GlobalMinY = MAX_int32, GlobalMinZ = MAX_int32; + int32 GlobalMaxX = MIN_int32, GlobalMaxY = MIN_int32, GlobalMaxZ = MIN_int32; + int64 TotalReferences = 0; + for (int32 Item = 0; Item < ItemCount; ++Item) + { + FVFBrickBounds Bounds; + if (!GetBounds(Item, Bounds) + || !VoxelMath::IsFinite(Bounds.Min.X) || !VoxelMath::IsFinite(Bounds.Min.Y) + || !VoxelMath::IsFinite(Bounds.Min.Z) || !VoxelMath::IsFinite(Bounds.Max.X) + || !VoxelMath::IsFinite(Bounds.Max.Y) || !VoxelMath::IsFinite(Bounds.Max.Z)) + { + return; + } + const double MinX = FMath::FloorToDouble(Bounds.Min.X / BrickSize); + const double MinY = FMath::FloorToDouble(Bounds.Min.Y / BrickSize); + const double MinZ = FMath::FloorToDouble(Bounds.Min.Z / BrickSize); + const double MaxX = FMath::FloorToDouble(Bounds.Max.X / BrickSize); + const double MaxY = FMath::FloorToDouble(Bounds.Max.Y / BrickSize); + const double MaxZ = FMath::FloorToDouble(Bounds.Max.Z / BrickSize); + if (MinX < MIN_int32 || MinX > MAX_int32 || MinY < MIN_int32 || MinY > MAX_int32 + || MinZ < MIN_int32 || MinZ > MAX_int32 || MaxX < MIN_int32 || MaxX > MAX_int32 + || MaxY < MIN_int32 || MaxY > MAX_int32 || MaxZ < MIN_int32 || MaxZ > MAX_int32) + { + return; + } + const FIntVector Lower( + static_cast(MinX), static_cast(MinY), static_cast(MinZ)); + const FIntVector Upper( + static_cast(MaxX), static_cast(MaxY), static_cast(MaxZ)); + if (Lower.X > Upper.X || Lower.Y > Upper.Y || Lower.Z > Upper.Z) + { + return; + } + ItemMin[Item] = Lower; + ItemMax[Item] = Upper; + GlobalMinX = FMath::Min(GlobalMinX, Lower.X); + GlobalMinY = FMath::Min(GlobalMinY, Lower.Y); + GlobalMinZ = FMath::Min(GlobalMinZ, Lower.Z); + GlobalMaxX = FMath::Max(GlobalMaxX, Upper.X); + GlobalMaxY = FMath::Max(GlobalMaxY, Upper.Y); + GlobalMaxZ = FMath::Max(GlobalMaxZ, Upper.Z); + const int64 NX = static_cast(Upper.X) - Lower.X + 1; + const int64 NY = static_cast(Upper.Y) - Lower.Y + 1; + const int64 NZ = static_cast(Upper.Z) - Lower.Z + 1; + if (NX <= 0 || NY <= 0 || NZ <= 0 || NX > MaxReferences / NY + || NX * NY > MaxReferences / NZ) + { + return; + } + TotalReferences += NX * NY * NZ; + if (TotalReferences > MaxReferences) + { + return; + } + } + + const int64 NumX = static_cast(GlobalMaxX) - GlobalMinX + 1; + const int64 NumY = static_cast(GlobalMaxY) - GlobalMinY + 1; + const int64 NumZ = static_cast(GlobalMaxZ) - GlobalMinZ + 1; + if (NumX <= 0 || NumY <= 0 || NumZ <= 0 || NumX > MaxBuckets / NumY + || NumX * NumY > MaxBuckets / NumZ) + { + return; + } + const int64 BucketCount = NumX * NumY * NumZ; + OutIndex.MinBrickX = GlobalMinX; + OutIndex.MinBrickY = GlobalMinY; + OutIndex.MinBrickZ = GlobalMinZ; + OutIndex.NumBricksX = static_cast(NumX); + OutIndex.NumBricksY = static_cast(NumY); + OutIndex.NumBricksZ = static_cast(NumZ); + OutIndex.CellOffsets.SetNumZeroed(static_cast(BucketCount + 1)); + for (int32 Item = 0; Item < ItemCount; ++Item) + { + for (int32 Z = ItemMin[Item].Z; Z <= ItemMax[Item].Z; ++Z) + { + for (int32 Y = ItemMin[Item].Y; Y <= ItemMax[Item].Y; ++Y) + { + const int64 Row = (static_cast(Z - OutIndex.MinBrickZ) + * OutIndex.NumBricksY + (Y - OutIndex.MinBrickY)) + * OutIndex.NumBricksX; + for (int32 X = ItemMin[Item].X; X <= ItemMax[Item].X; ++X) + { + const int64 Slot = Row + (X - OutIndex.MinBrickX); + ++OutIndex.CellOffsets[static_cast(Slot + 1)]; + } + } + } + } + for (int32 Slot = 0; Slot < static_cast(BucketCount); ++Slot) + { + OutIndex.CellOffsets[Slot + 1] += OutIndex.CellOffsets[Slot]; + } + OutIndex.ItemIndices.SetNumUninitialized(static_cast(TotalReferences)); + TArray WriteOffsets = OutIndex.CellOffsets; + for (int32 Item = 0; Item < ItemCount; ++Item) + { + for (int32 Z = ItemMin[Item].Z; Z <= ItemMax[Item].Z; ++Z) + { + for (int32 Y = ItemMin[Item].Y; Y <= ItemMax[Item].Y; ++Y) + { + const int64 Row = (static_cast(Z - OutIndex.MinBrickZ) + * OutIndex.NumBricksY + (Y - OutIndex.MinBrickY)) + * OutIndex.NumBricksX; + for (int32 X = ItemMin[Item].X; X <= ItemMax[Item].X; ++X) + { + const int64 Slot = Row + (X - OutIndex.MinBrickX); + OutIndex.ItemIndices[WriteOffsets[static_cast(Slot)]++] = Item; + } + } + } + } + OutIndex.bValid = true; + } + + static bool VF_GetRoomBrickBoundsSafe( + int32, const FCachedRoom& Room, FVFBrickBounds& OutBounds) + { + // EvaluateSDFCached receives the already-warped room query coordinate. Bin in that same + // space, so we preserve the live query's exact warp without estimating an inverse-warp + // displacement. CullRadiusSq is the baked superset of room shape, relief, blend and op reach. + const float Radius = FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)) + * FMath::Clamp(GVoxelResearchBrickRoomReachScale, 0.0f, 1.0f); + const FVector R(Radius, Radius, Radius); + bool bHasBounds = false; + return VF_IncludeBrickBounds(OutBounds, Room.Center - R, Room.Center + R, bHasBounds); + } + + static bool VF_GetJoinBrickBounds( + int32, const FCachedRoomFloorJoin& Join, FVFBrickBounds& OutBounds) + { + const float Radius = FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)); + const FVector R(Radius, Radius, Radius); + bool bHasBounds = false; + return VF_IncludeBrickBounds(OutBounds, Join.BoundCenter - R, Join.BoundCenter + R, bHasBounds); + } + + static bool VF_GetTunnelBrickBounds( + int32, const FCachedTunnel& Tunnel, FVFBrickBounds& OutBounds) + { + // The final field evaluates both the warped SDF chain and the authored world chain. Keep + // both bound sets, including their finalized blend/detail/floor influence, in every bin. + bool bHasBounds = false; + const auto IncludeSphere = [&](const FVector& Center, float Radius) + { + return VF_IncludeBrickSphere(OutBounds, Center, Radius, bHasBounds); + }; + const auto IncludeAabb = [&](const FVector& Min, const FVector& Max, float Radius) + { + const FVector FloatMin( + static_cast(Min.X), static_cast(Min.Y), static_cast(Min.Z)); + const FVector FloatMax( + static_cast(Max.X), static_cast(Max.Y), static_cast(Max.Z)); + const float CoordinateScale = FMath::Max3( + 1.0f, + FMath::Max3(FMath::Abs(static_cast(Min.X)), FMath::Abs(static_cast(Max.X)), FMath::Abs(Radius)), + FMath::Max3(FMath::Abs(static_cast(Min.Y)), FMath::Abs(static_cast(Max.Y)), + FMath::Max(FMath::Abs(static_cast(Min.Z)), FMath::Abs(static_cast(Max.Z))))); + const float ConservativeRadius = FMath::Max(Radius, 0.0f) + + CoordinateScale * (32.0f * FLT_EPSILON); + const FVector R(ConservativeRadius, ConservativeRadius, ConservativeRadius); + return VF_IncludeBrickBounds(OutBounds, FloatMin - R, FloatMax + R, bHasBounds); + }; + return IncludeSphere(Tunnel.BoundCenter, FMath::Sqrt(FMath::Max(Tunnel.BoundRadiusSq, 0.0f))) + && IncludeSphere(Tunnel.WorldBoundCenter, FMath::Sqrt(FMath::Max(Tunnel.WorldBoundRadiusSq, 0.0f))) + && IncludeAabb(Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax, Tunnel.SDFInfluenceRadius) + && IncludeAabb(Tunnel.WorldCenterlineMin, Tunnel.WorldCenterlineMax, Tunnel.WorldInfluenceRadius) + && bHasBounds; + } + + static void VF_BuildResearchBrickIndices(FChunkSDFCache& Cache) + { + VF_BuildBrickIndex(Cache.RoomBrickIndex, Cache.Rooms.Num(), + [&Cache](int32 I, FVFBrickBounds& B) { return VF_GetRoomBrickBoundsSafe(I, Cache.Rooms[I], B); }); + VF_BuildBrickIndex(Cache.RoomFloorJoinBrickIndex, Cache.RoomFloorJoins.Num(), + [&Cache](int32 I, FVFBrickBounds& B) { return VF_GetJoinBrickBounds(I, Cache.RoomFloorJoins[I], B); }); + VF_BuildBrickIndex(Cache.TunnelBrickIndex, Cache.Tunnels.Num(), + [&Cache](int32 I, FVFBrickBounds& B) { return VF_GetTunnelBrickBounds(I, Cache.Tunnels[I], B); }); + } +#endif +} + +void FChunkSDFCache::Reset() +{ + // Keep the allocations: classifier windows can alternate between empty and + // populated regions on adjacent requests. + Rooms.Reset(); + RoomFloorJoins.Reset(); + for (FCachedTunnel& Tunnel : Tunnels) + { + Tunnel.FloorLedgeLevels.Reset(); + Tunnel.FloorProfiles.Reset(); + Tunnel.WorldFloorProfiles.Reset(); + } + Tunnels.Reset(); + Pits.Reset(); + Chimneys.Reset(); + Columns.Reset(); + RoomSpatialIndex.Reset(); + RoomFloorJoinSpatialIndex.Reset(); + TunnelSpatialIndex.Reset(); + PitSpatialIndex.Reset(); + ChimneySpatialIndex.Reset(); + ColumnSpatialIndex.Reset(); +#if WITH_EDITOR + RoomBrickIndex.Reset(); + RoomFloorJoinBrickIndex.Reset(); + TunnelBrickIndex.Reset(); +#endif + SDFBlendRadius = 0.0f; +} + +void FChunkSDFCache::Release() +{ + Rooms.Empty(); + RoomFloorJoins.Empty(); + Tunnels.Empty(); + Pits.Empty(); + Chimneys.Empty(); + Columns.Empty(); + RoomSpatialIndex.Release(); + RoomFloorJoinSpatialIndex.Release(); + TunnelSpatialIndex.Release(); + PitSpatialIndex.Release(); + ChimneySpatialIndex.Release(); + ColumnSpatialIndex.Release(); +#if WITH_EDITOR + RoomBrickIndex.Release(); + RoomFloorJoinBrickIndex.Release(); + TunnelBrickIndex.Release(); +#endif +} + +SIZE_T FChunkSDFCache::GetAllocatedSize() const +{ + return static_cast(GetAllocatedSizeBreakdown().TotalBytes()); +} + +VoxelDensityProfile::FCacheMemoryBreakdown FChunkSDFCache::GetAllocatedSizeBreakdown() const +{ + VoxelDensityProfile::FCacheMemoryBreakdown Breakdown; + auto ArrayBytes = [](const auto& Array) -> uint64 + { + return static_cast(Array.GetAllocatedSize()); + }; + + Breakdown.RoomsBytes = ArrayBytes(Rooms); + Breakdown.RoomFloorJoinsBytes = ArrayBytes(RoomFloorJoins); + Breakdown.TunnelsBytes = ArrayBytes(Tunnels); + Breakdown.PitsBytes = ArrayBytes(Pits); + Breakdown.ChimneysBytes = ArrayBytes(Chimneys); + Breakdown.ColumnsBytes = ArrayBytes(Columns); + Breakdown.SpatialIndexBytes = static_cast( + RoomSpatialIndex.GetAllocatedSize() + + RoomFloorJoinSpatialIndex.GetAllocatedSize() + + TunnelSpatialIndex.GetAllocatedSize() + + PitSpatialIndex.GetAllocatedSize() + + ChimneySpatialIndex.GetAllocatedSize() + + ColumnSpatialIndex.GetAllocatedSize()); +#if WITH_EDITOR + Breakdown.SpatialIndexBytes += static_cast( + RoomBrickIndex.GetAllocatedSize() + + RoomFloorJoinBrickIndex.GetAllocatedSize() + + TunnelBrickIndex.GetAllocatedSize()); +#endif + for (const FCachedTunnel& Tunnel : Tunnels) + { + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.ControlPoints); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.ControlRadii); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldControlPoints); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldControlRadii); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.FloorLedgeLevels); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.FloorProfiles); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldFloorProfiles); + } + + for (const FCachedRoom& Room : Rooms) + { + Breakdown.RoomsBytes += ArrayBytes(Room.MouthRises); + } + + return Breakdown; +} + +//============================================================================= +// INTERNAL: Full room data used during cache building only. +// The CellX/CellY fields are needed for tunnel pair hashing but NOT for +// per-voxel SDF evaluation, so they don't go into the cached FCachedRoom. +//============================================================================= +struct FBuildRoom +{ + FVector Center; // World position of the room center + float RadiusXY; // Horizontal radius + float RadiusZ; // Vertical radius + int32 CellX, CellY; // Grid cell (needed for pair hash during tunnel decisions) + uint32 Hash; // Cell hash (for shape selection + tunnel property derivation) + bool bIsOrigin; // True for the origin room at (0,0) + bool bStore; // True if this room can affect a voxel in THIS chunk + // (collected for connectivity decisions either way). + FVector PlayerFitPoint = FVector::ZeroVector; + bool bHasPlayerFitPoint = false; + bool bPlayerFitAttempted = false; +}; + +struct FBuildRoomMouthRise +{ + int32 RoomIndex = INDEX_NONE; + FVector Mouth = FVector::ZeroVector; + float TargetFloorZ = 0.0f; + float BlendRadius = 0.0f; + float Strength = 0.0f; +}; + +struct FVFRoomGraphReach +{ + float DirectRoomReach = 0.0f; + // Complete XY reach of a room body plus any authored terrain operation that can be selected + // for that room. This is also used by the STORE cull; leaving it out would drop a room whose + // pit/chimney/detail override extends beyond the base SDF body. + float TerrainOpReach = 0.0f; + float FloorReliefEnvelope = 0.0f; + float BlendEnvelope = 0.0f; + float MaxTunnelLength = 0.0f; + float PairAabbReach = 0.0f; + float PairZReach = 0.0f; + float CollectMargin = 0.0f; +}; + +static float VF_RoomShapeReachUpperBound(float RadiusXY, float RadiusZ) +{ + const float RXY = FMath::Abs(RadiusXY); + const float RZ = FMath::Abs(RadiusZ); + const float Ellipsoid = FMath::Max(RXY, RZ); + const FVector BoxExtent( + RXY * 0.8f + RXY * 0.25f, + RXY * 0.8f + RXY * 0.25f, + RZ * 0.8f + RXY * 0.25f); + const float RoundedBox = BoxExtent.Size(); + const float Capsule = RXY * 0.7f + FMath::Min(RXY * 0.6f, RZ); + return FMath::Max3(Ellipsoid, RoundedBox, Capsule); +} + +// Terrain operations are authored outside FStrateGenerationParams and are selected per room at +// cache-build time. The tile-window proof must therefore include both the transported params and +// every operation that the pool can select. The limit is deliberately finite: an authored feature +// beyond it uses the legacy worker window, while the fixed-margin proof remains valid for all +// assets that stay inside this envelope. +static constexpr float VF_MaxProvenTerrainOpExtent = 256.0f; + +static bool VF_ComputeTerrainOpEnvelope( + const FStrateGenerationParams& Params, + const TArray* TerrainOps, + float BaseRoomReach, + float AllRoomRadiusEnvelope, + float BlendEnvelope, + float& OutReach) +{ + float MaxReach = BaseRoomReach; + float MaxAuthoredExtent = 0.0f; + bool bHasActiveTerrainOperation = false; + + auto RecordAuthoredExtent = [&](float Extent) -> bool + { + if (!VoxelMath::IsFinite(Extent) || Extent < 0.0f) + { + return false; + } + bHasActiveTerrainOperation = true; + MaxAuthoredExtent = FMath::Max(MaxAuthoredExtent, Extent); + return MaxAuthoredExtent <= VF_MaxProvenTerrainOpExtent; + }; + auto RecordReach = [&](float Reach) -> bool + { + if (!VoxelMath::IsFinite(Reach) || Reach < 0.0f) + { + return false; + } + MaxReach = FMath::Max(MaxReach, Reach); + return VoxelMath::IsFinite(MaxReach); + }; + auto RecordDetail = [&](float EffectRange) -> bool + { + return RecordAuthoredExtent(EffectRange) + && RecordReach(BaseRoomReach + EffectRange); + }; + auto RecordStructural = [&](float EffectExtent, float FeatureReach) -> bool + { + return RecordAuthoredExtent(EffectExtent) + && RecordReach(FeatureReach); + }; + + auto AccumulateParams = [&](const FStrateGenerationParams& OpParams) -> bool + { + const float OperationValues[] = { + OpParams.TerraceStepHeight, + OpParams.TerraceHardness, + OpParams.TerraceNoiseDisplacement, + OpParams.LayerLineSpacing, + OpParams.LayerLineDepth, + OpParams.OverhangStrength, + OpParams.OverhangDepth, + OpParams.OverhangFrequency, + OpParams.RibbingSpacing, + OpParams.RibbingDepth, + OpParams.CliffStrength, + OpParams.ScallopStrength, + OpParams.ScallopFrequency, + OpParams.ArchDensity, + OpParams.ArchMinRadius, + OpParams.ArchMaxRadius, + OpParams.ColumnDensity, + OpParams.ColumnMinRadius, + OpParams.ColumnMaxRadius, + OpParams.PitDensity, + OpParams.PitMinRadius, + OpParams.PitMaxRadius, + OpParams.PitDepth, + OpParams.ChimneyDensity, + OpParams.ChimneyMinRadius, + OpParams.ChimneyMaxRadius, + OpParams.ChimneyHeight, + OpParams.DomeDensity, + OpParams.DomeMinRadius, + OpParams.DomeMaxRadius, + OpParams.DomeHeightRatio, + OpParams.PinchDensity, + OpParams.PinchStrength, + OpParams.PinchLength}; + for (const float Value : OperationValues) + { + if (!VoxelMath::IsFinite(Value)) + { + return false; + } + } + + if (OpParams.TerraceStepHeight > 0.0f) + { + if (OpParams.TerraceHardness < 0.0f || OpParams.TerraceHardness > 1.0f + || OpParams.TerraceNoiseDisplacement < 0.0f) + { + return false; + } + if (!RecordDetail( + OpParams.TerraceStepHeight * 3.0f + + OpParams.TerraceNoiseDisplacement + 4.0f)) + { + return false; + } + } + if (OpParams.LayerLineSpacing > 0.0f) + { + if (OpParams.LayerLineDepth < 0.0f + || !RecordDetail(OpParams.LayerLineSpacing + + OpParams.LayerLineDepth + 4.0f)) + { + return false; + } + } + if (OpParams.RibbingSpacing > 0.0f) + { + if (OpParams.RibbingDepth < 0.0f + || !RecordDetail(OpParams.RibbingSpacing + + OpParams.RibbingDepth + 4.0f)) + { + return false; + } + } + if (OpParams.CliffStrength > 0.0f) + { + if (!RecordDetail(OpParams.CliffStrength + 8.0f)) + { + return false; + } + } + if (OpParams.ScallopStrength > 0.0f) + { + if (OpParams.ScallopFrequency <= 0.0f + || !RecordDetail( + 4.0f / OpParams.ScallopFrequency + + OpParams.ScallopStrength + 4.0f)) + { + return false; + } + } + if (OpParams.OverhangStrength > 0.0f) + { + if (OpParams.OverhangDepth < 0.0f + || OpParams.OverhangFrequency <= 0.0f + || !RecordDetail(OpParams.OverhangDepth * 2.0f + 4.0f)) + { + return false; + } + } + if (OpParams.ArchDensity > 0.0f) + { + if (OpParams.ArchMinRadius < 0.0f || OpParams.ArchMaxRadius < 0.0f) + { + return false; + } + const float Radius = FMath::Max(OpParams.ArchMinRadius, OpParams.ArchMaxRadius); + if (!RecordStructural( + Radius + 4.0f, + AllRoomRadiusEnvelope * 1.2f + Radius + BlendEnvelope * 3.0f + 4.0f)) + { + return false; + } + } + if (OpParams.ColumnDensity > 0.0f) + { + if (OpParams.ColumnMinRadius < 0.0f || OpParams.ColumnMaxRadius < 0.0f) + { + return false; + } + const float Radius = FMath::Max(OpParams.ColumnMinRadius, OpParams.ColumnMaxRadius); + if (!RecordStructural( + Radius + 6.0f, + AllRoomRadiusEnvelope * 0.75f + Radius + 6.0f)) + { + return false; + } + } + if (OpParams.PitDensity > 0.0f) + { + if (OpParams.PitMinRadius < 0.0f || OpParams.PitMaxRadius < 0.0f + || OpParams.PitDepth < 0.0f) + { + return false; + } + const float Radius = FMath::Max(OpParams.PitMinRadius, OpParams.PitMaxRadius); + if (!RecordStructural( + 2.0f * Radius + OpParams.PitDepth + BlendEnvelope + 4.0f, + AllRoomRadiusEnvelope * 0.6f + 2.0f * Radius + + OpParams.PitDepth + BlendEnvelope + 4.0f)) + { + return false; + } + } + if (OpParams.ChimneyDensity > 0.0f) + { + if (OpParams.ChimneyMinRadius < 0.0f || OpParams.ChimneyMaxRadius < 0.0f + || OpParams.ChimneyHeight < 0.0f) + { + return false; + } + const float Radius = FMath::Max( + OpParams.ChimneyMinRadius, OpParams.ChimneyMaxRadius); + if (!RecordStructural( + 2.0f * Radius + OpParams.ChimneyHeight + BlendEnvelope + 4.0f, + AllRoomRadiusEnvelope * 0.6f + 2.0f * Radius + + OpParams.ChimneyHeight + BlendEnvelope + 4.0f)) + { + return false; + } + } + if (OpParams.DomeDensity > 0.0f) + { + if (OpParams.DomeMinRadius < 0.0f || OpParams.DomeMaxRadius < 0.0f + || OpParams.DomeHeightRatio < 0.0f) + { + return false; + } + const float Radius = FMath::Max(OpParams.DomeMinRadius, OpParams.DomeMaxRadius); + if (!RecordStructural( + Radius * (1.0f + OpParams.DomeHeightRatio) + 4.0f, + AllRoomRadiusEnvelope * 0.4f + Radius + * (1.0f + OpParams.DomeHeightRatio) + 4.0f)) + { + return false; + } + } + if (OpParams.PinchDensity > 0.0f) + { + if (OpParams.PinchStrength < 0.0f || OpParams.PinchLength < 0.0f) + { + return false; + } + const float Extent = FMath::Max(OpParams.PinchStrength, OpParams.PinchLength); + if (!RecordStructural( + Extent + 5.0f, + AllRoomRadiusEnvelope * 0.85f + Extent + 5.0f)) + { + return false; + } + } + return true; + }; + + if (!AccumulateParams(Params)) + { + return false; + } + + if (TerrainOps != nullptr) + { + for (const FStrateTerrainOpEntry& Entry : *TerrainOps) + { + if (!VoxelMath::IsFinite(Entry.Probability) + || Entry.Probability < 0.0f || Entry.Probability > 1.0f + || !VoxelMath::IsFinite(Entry.Weight) + || Entry.Weight < 0.0f || Entry.Weight > 3.0f) + { + return false; + } + if (Entry.Probability <= 0.0f || Entry.Weight <= 0.0f) + { + continue; + } + if (Entry.Operation.IsNull()) + { + continue; + } + const UVoxelTerrainOpDefinition* Operation = Entry.Operation.Get(); + if (Operation == nullptr) + { + // A referenced but unloaded asset is not a proof of absence. Keep the legacy + // window until the manager has a concrete definition to inspect. + return false; + } + switch (Operation->Type) + { + case EVoxelTerrainOpType::Terrace: + case EVoxelTerrainOpType::LayerLines: + case EVoxelTerrainOpType::Ribbing: + case EVoxelTerrainOpType::Cliff: + case EVoxelTerrainOpType::Scallop: + case EVoxelTerrainOpType::Overhang: + case EVoxelTerrainOpType::Arch: + case EVoxelTerrainOpType::Column: + case EVoxelTerrainOpType::Pit: + case EVoxelTerrainOpType::Chimney: + case EVoxelTerrainOpType::Dome: + case EVoxelTerrainOpType::Pinch: + break; + default: + return false; + } + + FStrateGenerationParams OperationParams{}; + Operation->ApplyTo(OperationParams, Entry.Weight); + if (!AccumulateParams(OperationParams)) + { + return false; + } + } + } + + // Keep the field at zero when the strate has no active terrain operation. The caller already + // has the base room envelope; using that same value as a second global room radius would make + // the legacy/no-op path store extra rooms and could change the canonical field. Active pools + // publish the wider envelope so the tile proof and STORE cull see pits/chimneys/detail reach. + OutReach = bHasActiveTerrainOperation ? MaxReach : 0.0f; + return VoxelMath::IsFinite(OutReach) + && VoxelMath::IsFinite(MaxAuthoredExtent) + && MaxAuthoredExtent <= VF_MaxProvenTerrainOpExtent; +} + +static bool VF_ComputeRoomGraphReach( + const FStrateGenerationParams& Params, + const TArray* TerrainOps, + FVFRoomGraphReach& OutReach) +{ + const float RelevantParams[] = { + Params.RoomDensity, + Params.RoomSpacing, + Params.MinRoomRadius, + Params.MaxRoomRadius, + Params.RoomHeightRatio, + Params.RoomFloorCutMin, + Params.RoomFloorCutMax, + Params.FloorReliefStrength, + Params.FloorReliefFrequency, + Params.RoomMouthRiseStrength, + Params.RoomMouthRiseBlendVoxels, + Params.OriginRoomRadius, + Params.TunnelMinRadius, + Params.TunnelMaxRadius, + Params.TunnelDensity, + Params.MaxTunnelLength, + Params.TunnelWarpStrength, + Params.TunnelHorizontalBias, + Params.TunnelEndpointZOffset, + Params.TunnelFloorTerraceStepHeight, + Params.TunnelFloorMaxLedgeHeight, + Params.TunnelFloorGentleSlopeThreshold, + Params.SDFBlendRadius, + Params.CaveWarpStrength, + Params.CaveWarpFrequency, + Params.VerticalScale, + Params.BoundarySealThickness, + Params.StrateTopWorldZ, + Params.StrateBottomWorldZ}; + for (const float Value : RelevantParams) + { + if (!VoxelMath::IsFinite(Value)) + { + return false; + } + } + + if (Params.RoomSpacing <= 0.0f + || Params.RoomDensity < 0.0f + || Params.MinRoomRadius < 0.0f + || Params.MaxRoomRadius < 0.0f + || Params.RoomHeightRatio < 0.0f + || Params.OriginRoomRadius < 0.0f + || Params.TunnelMinRadius < 0.0f + || Params.TunnelMaxRadius < 0.0f + || Params.SDFBlendRadius < 0.0f + || Params.TunnelFloorTerraceStepHeight < 0.0f + || Params.TunnelFloorMaxLedgeHeight < 0.0f + || Params.TunnelFloorGentleSlopeThreshold < 0.0f + || Params.TunnelFloorGentleSlopeThreshold + > VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees + || FMath::Abs(Params.FloorReliefStrength) > 1000000.0f + || FMath::Abs(Params.CaveWarpStrength) > 1000000.0f + || FMath::Abs(Params.RoomFloorCutMin) > 1000000.0f + || FMath::Abs(Params.RoomFloorCutMax) > 1000000.0f + || FMath::Max(Params.MaxTunnelLength, 0.0f) > 1000000.0f) + { + return false; + } + + // The cache's room centres and the world-space tunnel chain have separate + // Z conventions when VerticalScale != 1. A proof that ignores that + // conversion could under-estimate the chain length used for its lateral + // wander. Let the normal cache path handle those authored variants. + if (Params.VerticalScale > 0.0f + && FMath::Abs(Params.VerticalScale - 1.0f) > 1.0e-6f) + { + return false; + } + + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), + FMath::Abs(Params.MaxRoomRadius)); + const float AllRoomRadiusEnvelope = FMath::Max( + RoomRadiusEnvelope, FMath::Abs(Params.OriginRoomRadius)); + const float AllRoomHeightEnvelope = AllRoomRadiusEnvelope + * FMath::Abs(Params.RoomHeightRatio); + const float FloorReliefEnvelope = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float BlendEnvelope = FMath::Max(Params.SDFBlendRadius, 0.0f); + const float TunnelRadiusEnvelope = FMath::Max( + 0.5f, + FMath::Max( + FMath::Abs(Params.TunnelMinRadius), + FMath::Abs(Params.TunnelMaxRadius)) * 1.18f); + const float MaxTunnelLength = FMath::Max( + Params.MaxTunnelLength, 0.0f); + const float BaseDirectRoomReach = VF_RoomShapeReachUpperBound( + AllRoomRadiusEnvelope, AllRoomHeightEnvelope) + + FloorReliefEnvelope + BlendEnvelope * 3.0f; + float TerrainOpReach = BaseDirectRoomReach; + if (!VF_ComputeTerrainOpEnvelope( + Params, TerrainOps, BaseDirectRoomReach, + AllRoomRadiusEnvelope, BlendEnvelope, TerrainOpReach)) + { + return false; + } + const float DirectRoomReach = FMath::Max(BaseDirectRoomReach, TerrainOpReach); + const float TunnelInfluence = FMath::Abs(Params.TunnelWarpStrength) + + TunnelRadiusEnvelope + BlendEnvelope * 3.0f; + const float MaxInfluence = FMath::Max(DirectRoomReach, TunnelInfluence); + + // A fitted tunnel mouth may move by one bounded cave-warp displacement in + // each XY axis. The chain may also grow a deterministic switchback when a + // direct room-floor route exceeds the gentle-slope limit. Bound the + // endpoint's possible Z correction too, because it controls the required + // route length even though the preflight is XY-only. + const float CaveWarpRaw = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE; + const float CaveWarpEnvelope = CaveWarpRaw * 1.5f; + const float EndpointXY = FMath::Sqrt(2.0f) + * CaveWarpEnvelope; + const float FloorCutEnvelope = FMath::Max( + 1.0f, + FMath::Max( + FMath::Abs(Params.RoomFloorCutMin), + FMath::Abs(Params.RoomFloorCutMax))); + const float ReliefSearch = static_cast(FMath::Clamp( + FMath::CeilToInt(FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE + CaveWarpRaw) + 8, + 8, + 128)); + const float MaxStepHeight = FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float EndpointVertical = AllRoomHeightEnvelope * FloorCutEnvelope + + FloorReliefEnvelope + CaveWarpEnvelope + ReliefSearch + + MaxStepHeight + TunnelRadiusEnvelope + 2.0f; + const float EndpointOffset = FMath::Sqrt( + EndpointXY * EndpointXY + + EndpointVertical * EndpointVertical); + const float TunnelLengthBound = MaxTunnelLength + + 2.0f * EndpointOffset; + const float WorldFloorPad = FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, + BlendEnvelope); + const float TunnelTubeReach = TunnelRadiusEnvelope + + WorldFloorPad + BlendEnvelope * 3.0f + + CaveWarpEnvelope + 2.0f; + // A steep room-floor delta is now solved by a deterministic lateral route rather than by + // quantising the floor into small risers. Bound the route's possible lateral amplitude with + // the same finite endpoint envelope used above. This is intentionally conservative: the + // actual chain uses a handful of switchback waves and never exceeds this reach. + const float GentleSlopeThreshold = VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + Params.TunnelFloorGentleSlopeThreshold); + const float MaxFloorDeltaEnvelope = 2.0f * EndpointVertical; + const float RequiredRouteRunEnvelope = FMath::Min( + VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, + MaxFloorDeltaEnvelope / FMath::Max( + GentleSlopeThreshold, KINDA_SMALL_NUMBER)); + const float OrganicLateralEnvelope = FMath::Min( + FMath::Abs(Params.TunnelWarpStrength), TunnelLengthBound * 0.25f); + const float RouteLateralEnvelope = FMath::Max( + 8.0f, + OrganicLateralEnvelope + RequiredRouteRunEnvelope); + const float PairAabbReach = EndpointXY + + RouteLateralEnvelope + + FMath::Max(MaxInfluence, TunnelTubeReach); + const float PairZReach = EndpointVertical + TunnelTubeReach; + // A stored feature can depend on a nearest-neighbour candidate two graph hops away: A's + // candidate B can select C, and both decisions affect the edge emitted near the search box. + // PairAabbReach is the complete endpoint/chain/tube envelope; using it here closes the old + // proof gap where the collect margin only covered the smaller direct influence estimate. + const float CollectMargin = 2.0f * MaxTunnelLength + + FMath::Max(DirectRoomReach, PairAabbReach); + + if (!VoxelMath::IsFinite(DirectRoomReach) + || !VoxelMath::IsFinite(PairAabbReach) + || !VoxelMath::IsFinite(PairZReach) + || !VoxelMath::IsFinite(CollectMargin) + || DirectRoomReach < 0.0f + || PairAabbReach < 0.0f + || PairZReach < 0.0f + || CollectMargin < 0.0f) + { + return false; + } + + OutReach.DirectRoomReach = DirectRoomReach; + OutReach.TerrainOpReach = TerrainOpReach; + OutReach.FloorReliefEnvelope = FloorReliefEnvelope; + OutReach.BlendEnvelope = BlendEnvelope; + OutReach.MaxTunnelLength = MaxTunnelLength; + OutReach.PairAabbReach = PairAabbReach; + OutReach.PairZReach = PairZReach; + OutReach.CollectMargin = CollectMargin; + return true; +} + +template +static bool VF_MayHaveRoomGraphFeature( + const TArray& Rooms, + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 StrateSeed, + const FVFRoomGraphReach& Reach, + bool bUseZ = false, + float SearchMinZ = 0.0f, + float SearchMaxZ = 0.0f) +{ + auto AabbDistanceSquared = [&]( + float X, float Y, float MinX, float MinY, + float MaxX, float MaxY) -> float + { + const float DX = X < MinX ? MinX - X + : (X > MaxX ? X - MaxX : 0.0f); + const float DY = Y < MinY ? MinY - Y + : (Y > MaxY ? Y - MaxY : 0.0f); + return DX * DX + DY * DY; + }; + + for (const FBuildRoom& Room : Rooms) + { + if (!VoxelMath::IsFinite(Room.Center.X) + || !VoxelMath::IsFinite(Room.Center.Y) + || !VoxelMath::IsFinite(Room.Center.Z) + || !VoxelMath::IsFinite(Room.RadiusXY) + || !VoxelMath::IsFinite(Room.RadiusZ)) + { + return true; + } + const float BaseRoomReach = VF_RoomShapeReachUpperBound( + Room.RadiusXY, Room.RadiusZ) + + Reach.FloorReliefEnvelope + Reach.BlendEnvelope * 3.0f; + const float RoomReach = FMath::Max(BaseRoomReach, Reach.TerrainOpReach); + if (!VoxelMath::IsFinite(RoomReach) || RoomReach < 0.0f) + { + return true; + } + if (bUseZ + && (static_cast(Room.Center.Z) + RoomReach < SearchMinZ + || static_cast(Room.Center.Z) - RoomReach > SearchMaxZ)) + { + continue; + } + const float DistanceSquared = AabbDistanceSquared( + static_cast(Room.Center.X), + static_cast(Room.Center.Y), + SearchMinX - RoomReach, + SearchMinY - RoomReach, + SearchMaxX + RoomReach, + SearchMaxY + RoomReach); + if (!VoxelMath::IsFinite(DistanceSquared)) + { + return true; + } + if (DistanceSquared <= KINDA_SMALL_NUMBER) + { + return true; + } + } + + if (Reach.MaxTunnelLength <= 0.0f || Rooms.Num() < 2) + { + return false; + } + + // A pair whose broad centerline envelope can reach the query is enough to + // keep the full graph path. This deliberately treats every geometrically + // eligible pair as potential; the exact backbone/random decision remains + // owned by BuildChunkCache. It keeps this preflight linear in the common + // feature-present case and avoids player-fit work in the feature-free case. + for (int32 First = 0; First < Rooms.Num(); ++First) + { + for (int32 Second = First + 1; Second < Rooms.Num(); ++Second) + { + const FVector& FirstCenter = Rooms[First].Center; + const FVector& SecondCenter = Rooms[Second].Center; + if (bUseZ + && (FMath::Max( + static_cast(FirstCenter.Z), + static_cast(SecondCenter.Z)) + + Reach.PairZReach < SearchMinZ + || FMath::Min( + static_cast(FirstCenter.Z), + static_cast(SecondCenter.Z)) + - Reach.PairZReach > SearchMaxZ)) + { + continue; + } + const float EuclideanDistance = static_cast( + FVector::Dist(FirstCenter, SecondCenter)); + if (!VoxelMath::IsFinite(EuclideanDistance)) + { + return true; + } + if (EuclideanDistance > Reach.MaxTunnelLength) + { + continue; + } + const float PairMinX = FMath::Min( + static_cast(FirstCenter.X), + static_cast(SecondCenter.X)) + - Reach.PairAabbReach; + const float PairMinY = FMath::Min( + static_cast(FirstCenter.Y), + static_cast(SecondCenter.Y)) + - Reach.PairAabbReach; + const float PairMaxX = FMath::Max( + static_cast(FirstCenter.X), + static_cast(SecondCenter.X)) + + Reach.PairAabbReach; + const float PairMaxY = FMath::Max( + static_cast(FirstCenter.Y), + static_cast(SecondCenter.Y)) + + Reach.PairAabbReach; + if (!VoxelMath::IsFinite(PairMinX) || !VoxelMath::IsFinite(PairMinY) + || !VoxelMath::IsFinite(PairMaxX) || !VoxelMath::IsFinite(PairMaxY)) + { + return true; + } + if (PairMaxX >= SearchMinX && PairMaxY >= SearchMinY + && PairMinX <= SearchMaxX && PairMinY <= SearchMaxY) + { + return true; + } + } + } + return false; + +#if 0 + const float MaxTunnelLengthSquared = + Reach.MaxTunnelLength * Reach.MaxTunnelLength; + if (!VoxelMath::IsFinite(MaxTunnelLengthSquared)) + { + return true; + } + + TArray> NearestNeighbor; + NearestNeighbor.SetNumUninitialized(Rooms.Num()); + const bool bFlowToOrigin = Params.bTunnelsFlowTowardOrigin; + auto OriginKeySquared = [&](int32 Index) -> float + { + const FVector& Center = Rooms[Index].Center; + return static_cast( + Center.X * Center.X + Center.Y * Center.Y); + }; + auto LinkMetric = [&](int32 First, int32 Second) -> float + { + const float Distance = static_cast( + FVector::Dist(Rooms[First].Center, Rooms[Second].Center)); + const float VerticalSeparation = FMath::Abs( + static_cast( + Rooms[First].Center.Z - Rooms[Second].Center.Z)); + return Distance + + VerticalSeparation * Params.TunnelHorizontalBias * 5.0f; + }; + auto PickNeighbor = [&](int32 Index) -> int32 + { + const float MyKeySquared = OriginKeySquared(Index); + float BestInward = FLT_MAX; + int32 BestInwardIndex = -1; + float BestAny = FLT_MAX; + int32 BestAnyIndex = -1; + for (int32 Other = 0; Other < Rooms.Num(); ++Other) + { + if (Other == Index) + { + continue; + } + const float DistanceSquared = static_cast( + FVector::DistSquared( + Rooms[Index].Center, Rooms[Other].Center)); + if (!VoxelMath::IsFinite(DistanceSquared)) + { + return -2; + } + if (DistanceSquared > MaxTunnelLengthSquared) + { + continue; + } + const float Metric = LinkMetric(Index, Other); + if (!VoxelMath::IsFinite(Metric)) + { + return -2; + } + if (Metric < BestAny) + { + BestAny = Metric; + BestAnyIndex = Other; + } + if (bFlowToOrigin + && OriginKeySquared(Other) < MyKeySquared + && Metric < BestInward) + { + BestInward = Metric; + BestInwardIndex = Other; + } + } + return (bFlowToOrigin && BestInwardIndex != -1) + ? BestInwardIndex : BestAnyIndex; + }; + for (int32 Index = 0; Index < Rooms.Num(); ++Index) + { + NearestNeighbor[Index] = PickNeighbor(Index); + if (NearestNeighbor[Index] == -2) + { + return true; + } + } + + for (int32 First = 0; First < Rooms.Num(); ++First) + { + for (int32 Second = First + 1; Second < Rooms.Num(); ++Second) + { + const float EuclideanDistance = static_cast( + FVector::Dist(Rooms[First].Center, Rooms[Second].Center)); + if (!VoxelMath::IsFinite(EuclideanDistance)) + { + return true; + } + bool bBackbone = NearestNeighbor[First] == Second + || NearestNeighbor[Second] == First; + float CheckDistance = EuclideanDistance; + if (!bBackbone && Params.TunnelHorizontalBias > 0.0f) + { + const float VerticalSeparation = FMath::Abs( + static_cast( + Rooms[First].Center.Z - Rooms[Second].Center.Z)); + CheckDistance += VerticalSeparation + * Params.TunnelHorizontalBias * 5.0f; + } + if (!VoxelMath::IsFinite(CheckDistance) + || CheckDistance > Reach.MaxTunnelLength) + { + continue; + } + if (!bBackbone) + { + if (Params.TunnelDensity <= 0.0f) + { + continue; + } + const uint32 PairHash = VoxelHash::Pair( + Rooms[First].CellX, Rooms[First].CellY, + Rooms[Second].CellX, Rooms[Second].CellY, + StrateSeed); + if (VoxelHash::ToFloat01(PairHash) >= Params.TunnelDensity) + { + continue; + } + } + + const float PairMinX = FMath::Min( + static_cast(Rooms[First].Center.X), + static_cast(Rooms[Second].Center.X)) + - Reach.PairAabbReach; + const float PairMinY = FMath::Min( + static_cast(Rooms[First].Center.Y), + static_cast(Rooms[Second].Center.Y)) + - Reach.PairAabbReach; + const float PairMaxX = FMath::Max( + static_cast(Rooms[First].Center.X), + static_cast(Rooms[Second].Center.X)) + + Reach.PairAabbReach; + const float PairMaxY = FMath::Max( + static_cast(Rooms[First].Center.Y), + static_cast(Rooms[Second].Center.Y)) + + Reach.PairAabbReach; + if (!VoxelMath::IsFinite(PairMinX) || !VoxelMath::IsFinite(PairMinY) + || !VoxelMath::IsFinite(PairMaxX) || !VoxelMath::IsFinite(PairMaxY)) + { + return true; + } + if (PairMaxX >= SearchMinX && PairMaxY >= SearchMinY + && PairMinX <= SearchMaxX && PairMinY <= SearchMaxY) + { + // A backbone link is considered potential even if the origin + // connection cap later downgrades it. That only sacrifices a + // skip; it can never turn a real tunnel into an empty proof. + return true; + } + } + } + return false; +#endif +} + +//============================================================================= +// INTERNAL: Shared hash-placement skeleton for the per-room baked features +// (pits / chimneys / columns). Squelette commun de placement par hash — les +// trois boucles de bake étaient des copies quasi identiques de ce motif. +// +// Rolls EXACTLY the hash chain the hand-written loops used (bit-identical): +// H = Mix(RoomHash ^ (SaltBase + i * SaltStep)) → density gate +// H2 = Mix(H ^ Salt2) → XY offset (X: H2, Y: Mix(H2)) +// H3 = Mix(H2 ^ Salt3) → radius lerp [MinRadius, MaxRadius] +// Type-specific work (Z anchor, flare, bounds, struct fill) lives in the Emit +// lambda; it receives H3 so pits/chimneys can chain their 4th hash from it. +//============================================================================= +template +static void BakeRoomFeature( + const FCachedRoom& CR, + int32 MaxCount, float Density, + uint32 SaltBase, uint32 SaltStep, uint32 Salt2, uint32 Salt3, + float XYScale, float MinRadius, float MaxRadius, + FEmit&& Emit) // Emit(X, Y, Radius, H3) +{ + if (Density <= 0.0f) return; + + for (int32 i = 0; i < MaxCount; i++) + { + const uint32 H = VoxelHash::Mix(CR.Hash ^ (SaltBase + (uint32)i * SaltStep)); + if (VoxelHash::ToFloat01(H) > Density) continue; + + const uint32 H2 = VoxelHash::Mix(H ^ Salt2); + const uint32 H3 = VoxelHash::Mix(H2 ^ Salt3); + + const float X = CR.Center.X + VoxelHash::ToFloatSigned(H2) * CR.RadiusXY * XYScale; + const float Y = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(H2)) * CR.RadiusXY * XYScale; + const float R = FMath::Lerp(MinRadius, MaxRadius, VoxelHash::ToFloat01(H3)); + + Emit(X, Y, R, H3); + } +} + + static void VF_FinalizeTunnelBroadPhaseBounds(FCachedTunnel& Tunnel, float BlendK) + { + const float BlendPad = FMath::Max(BlendK, 0.0f); + // Detail modifiers run while CaveSDF is below 3*K, so the source + // broad phase must cover that full downstream reach. + const float SDFDetailPad = BlendPad * 3.0f; + const float WorldFloorPad = FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, BlendPad); + + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + Tunnel.SDFCenterlineMin = Tunnel.ControlPoints[0]; + Tunnel.SDFCenterlineMax = Tunnel.ControlPoints[0]; + float MaxRadius = 0.0f; + for (int32 Index = 0; Index < Tunnel.ControlPoints.Num(); ++Index) + { + const FVector& Point = Tunnel.ControlPoints[Index]; + Tunnel.SDFCenterlineMin.X = FMath::Min(Tunnel.SDFCenterlineMin.X, Point.X); + Tunnel.SDFCenterlineMin.Y = FMath::Min(Tunnel.SDFCenterlineMin.Y, Point.Y); + Tunnel.SDFCenterlineMin.Z = FMath::Min(Tunnel.SDFCenterlineMin.Z, Point.Z); + Tunnel.SDFCenterlineMax.X = FMath::Max(Tunnel.SDFCenterlineMax.X, Point.X); + Tunnel.SDFCenterlineMax.Y = FMath::Max(Tunnel.SDFCenterlineMax.Y, Point.Y); + Tunnel.SDFCenterlineMax.Z = FMath::Max(Tunnel.SDFCenterlineMax.Z, Point.Z); + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Tunnel.ControlRadii[Index])); + } + Tunnel.SDFInfluenceRadius = MaxRadius + SDFDetailPad; + } + else + { + Tunnel.SDFCenterlineMin = Tunnel.EndpointA; + Tunnel.SDFCenterlineMax = Tunnel.EndpointA; + Tunnel.SDFCenterlineMin.X = FMath::Min(Tunnel.SDFCenterlineMin.X, Tunnel.EndpointB.X); + Tunnel.SDFCenterlineMin.Y = FMath::Min(Tunnel.SDFCenterlineMin.Y, Tunnel.EndpointB.Y); + Tunnel.SDFCenterlineMin.Z = FMath::Min(Tunnel.SDFCenterlineMin.Z, Tunnel.EndpointB.Z); + Tunnel.SDFCenterlineMax.X = FMath::Max(Tunnel.SDFCenterlineMax.X, Tunnel.EndpointB.X); + Tunnel.SDFCenterlineMax.Y = FMath::Max(Tunnel.SDFCenterlineMax.Y, Tunnel.EndpointB.Y); + Tunnel.SDFCenterlineMax.Z = FMath::Max(Tunnel.SDFCenterlineMax.Z, Tunnel.EndpointB.Z); + float MaxRadius = FMath::Max( + FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); + if (Tunnel.bHasMidpoint) + { + Tunnel.SDFCenterlineMin.X = FMath::Min(Tunnel.SDFCenterlineMin.X, Tunnel.Midpoint.X); + Tunnel.SDFCenterlineMin.Y = FMath::Min(Tunnel.SDFCenterlineMin.Y, Tunnel.Midpoint.Y); + Tunnel.SDFCenterlineMin.Z = FMath::Min(Tunnel.SDFCenterlineMin.Z, Tunnel.Midpoint.Z); + Tunnel.SDFCenterlineMax.X = FMath::Max(Tunnel.SDFCenterlineMax.X, Tunnel.Midpoint.X); + Tunnel.SDFCenterlineMax.Y = FMath::Max(Tunnel.SDFCenterlineMax.Y, Tunnel.Midpoint.Y); + Tunnel.SDFCenterlineMax.Z = FMath::Max(Tunnel.SDFCenterlineMax.Z, Tunnel.Midpoint.Z); + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Tunnel.RadiusMid)); + } + Tunnel.SDFInfluenceRadius = MaxRadius + SDFDetailPad; + } + + if (Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num()) + { + Tunnel.WorldCenterlineMin = Tunnel.WorldControlPoints[0]; + Tunnel.WorldCenterlineMax = Tunnel.WorldControlPoints[0]; + float MaxRadius = 0.0f; + for (int32 Index = 0; Index < Tunnel.WorldControlPoints.Num(); ++Index) + { + const FVector& Point = Tunnel.WorldControlPoints[Index]; + Tunnel.WorldCenterlineMin.X = FMath::Min(Tunnel.WorldCenterlineMin.X, Point.X); + Tunnel.WorldCenterlineMin.Y = FMath::Min(Tunnel.WorldCenterlineMin.Y, Point.Y); + Tunnel.WorldCenterlineMin.Z = FMath::Min(Tunnel.WorldCenterlineMin.Z, Point.Z); + Tunnel.WorldCenterlineMax.X = FMath::Max(Tunnel.WorldCenterlineMax.X, Point.X); + Tunnel.WorldCenterlineMax.Y = FMath::Max(Tunnel.WorldCenterlineMax.Y, Point.Y); + Tunnel.WorldCenterlineMax.Z = FMath::Max(Tunnel.WorldCenterlineMax.Z, Point.Z); + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Tunnel.WorldControlRadii[Index])); + } + Tunnel.WorldInfluenceRadius = MaxRadius + WorldFloorPad; + } + else + { + Tunnel.WorldCenterlineMin = Tunnel.SDFCenterlineMin; + Tunnel.WorldCenterlineMax = Tunnel.SDFCenterlineMax; + Tunnel.WorldInfluenceRadius = Tunnel.SDFInfluenceRadius + - SDFDetailPad + WorldFloorPad; + } + } + + static void VF_FinalizeCacheBroadPhaseBounds( + FChunkSDFCache& Cache, + float BlendK) + { + for (int32 Index = 0; Index < Cache.Tunnels.Num(); ++Index) + { + FCachedTunnel& Tunnel = Cache.Tunnels[Index]; + VF_FinalizeTunnelBroadPhaseBounds(Tunnel, BlendK); + } + + VF_BuildSpatialIndex( + Cache.RoomSpatialIndex, Cache.Rooms.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetRoomSpatialBounds(Index, Cache.Rooms[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.RoomFloorJoinSpatialIndex, Cache.RoomFloorJoins.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetJoinSpatialBounds(Index, Cache.RoomFloorJoins[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetTunnelSpatialBounds(Index, Cache.Tunnels[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.PitSpatialIndex, Cache.Pits.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetPitSpatialBounds(Index, Cache.Pits[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.ChimneySpatialIndex, Cache.Chimneys.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetChimneySpatialBounds(Index, Cache.Chimneys[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.ColumnSpatialIndex, Cache.Columns.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetColumnSpatialBounds(Index, Cache.Columns[Index], Bounds); + }); +#if WITH_EDITOR + if (VoxelCaveMorphology::IsResearchBrickBinningEnabled()) + { + VF_BuildResearchBrickIndices(Cache); + } +#endif + } +namespace +{ + // Runtime landing queries use the same pinned capsule dimensions and support rules as the + // player-fit measurement, but they cannot call the editor-only flood fill. Keep this local + // evaluator deliberately source-only: the callback is pure density math supplied by the + // archetype query, never a generator, manager, cache, or layout lookup. + struct FVFPlayerCapsuleStencilRow + { + int32 RelativeZ = 0; + TArray HorizontalOffsets; + }; + + bool VF_IsPointInsidePlayerCapsule( + float RelativeZ, + int32 OffsetX, + int32 OffsetY, + float Radius, + float HalfHeight) + { + const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius); + const float DistanceToAxis = FMath::Max( + FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f); + const float DistanceSquared = static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + + DistanceToAxis * DistanceToAxis; + return DistanceSquared <= Radius * Radius + KINDA_SMALL_NUMBER; + } + + bool VF_BuildPlayerFitStencil( + const FVoxelStrateMeasureSettings& Settings, + TArray& OutRows, + TArray& OutSupportOffsets, + int32& OutCentreSupportIndex, + int32& OutRequiredSupportCount) + { + OutRows.Reset(); + OutSupportOffsets.Reset(); + OutCentreSupportIndex = INDEX_NONE; + OutRequiredSupportCount = 0; + + if (!VoxelMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels) + || Settings.PlayerCapsuleRadiusVoxels <= 0.0f + || !VoxelMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels) + || Settings.PlayerCapsuleHalfHeightVoxels <= 0.0f + || !VoxelMath::IsFinite(Settings.PlayerMaxStepHeightMeters) + || Settings.PlayerMaxStepHeightMeters < 0.0f + || !VoxelMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) + || Settings.PlayerWalkableFloorAngleDegrees < 0.0f + || Settings.PlayerWalkableFloorAngleDegrees > 90.0f + || !VoxelMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) + || Settings.PlayerSupportPatchMinCoverageFraction <= 0.0f + || Settings.PlayerSupportPatchMinCoverageFraction > 1.0f) + { + return false; + } + + const int32 HeightCells = FMath::CeilToInt( + 2.0f * Settings.PlayerCapsuleHalfHeightVoxels); + const int32 MaxHorizontalOffset = FMath::CeilToInt( + Settings.PlayerCapsuleRadiusVoxels); + if (HeightCells <= 0 || HeightCells > 4096 + || MaxHorizontalOffset < 0 || MaxHorizontalOffset > 4096) + { + return false; + } + + int64 NumStencilOffsets = 0; + for (int32 RelativeZ = 0; RelativeZ < HeightCells; ++RelativeZ) + { + FVFPlayerCapsuleStencilRow& Row = OutRows.AddDefaulted_GetRef(); + Row.RelativeZ = RelativeZ; + const float CapsuleRelativeZ = static_cast(RelativeZ) + 0.5f + - Settings.PlayerCapsuleHalfHeightVoxels; + for (int32 OffsetY = -MaxHorizontalOffset; + OffsetY <= MaxHorizontalOffset; + ++OffsetY) + { + for (int32 OffsetX = -MaxHorizontalOffset; + OffsetX <= MaxHorizontalOffset; + ++OffsetX) + { + if (!VF_IsPointInsidePlayerCapsule( + CapsuleRelativeZ, OffsetX, OffsetY, + Settings.PlayerCapsuleRadiusVoxels, + Settings.PlayerCapsuleHalfHeightVoxels)) + { + continue; + } + if (NumStencilOffsets >= (1ll << 20)) + { + return false; + } + Row.HorizontalOffsets.Add(FIntPoint(OffsetX, OffsetY)); + ++NumStencilOffsets; + } + } + + // Preserve the top partial row exactly as the measurement does. A row with no + // rounded-cap offsets still needs a centre sample for full-height clearance. + if (Row.HorizontalOffsets.IsEmpty()) + { + Row.HorizontalOffsets.Add(FIntPoint::ZeroValue); + } + } + + const int32 MaxSupportOffset = FMath::CeilToInt( + Settings.PlayerCapsuleRadiusVoxels); + for (int32 OffsetY = -MaxSupportOffset; + OffsetY <= MaxSupportOffset; + ++OffsetY) + { + for (int32 OffsetX = -MaxSupportOffset; + OffsetX <= MaxSupportOffset; + ++OffsetX) + { + if (static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + > FMath::Square(Settings.PlayerCapsuleRadiusVoxels) + + KINDA_SMALL_NUMBER) + { + continue; + } + if (OffsetX == 0 && OffsetY == 0) + { + OutCentreSupportIndex = OutSupportOffsets.Num(); + } + OutSupportOffsets.Add(FIntPoint(OffsetX, OffsetY)); + } + } + + if (OutRows.IsEmpty() || OutSupportOffsets.IsEmpty() + || OutCentreSupportIndex == INDEX_NONE) + { + return false; + } + OutRequiredSupportCount = FMath::Clamp( + FMath::CeilToInt( + Settings.PlayerSupportPatchMinCoverageFraction + * static_cast(OutSupportOffsets.Num())), + 1, + OutSupportOffsets.Num()); + return true; + } + + bool VF_GetPlayerFitInteriorBounds( + const FVoxelStrateMeasureSettings& Settings, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + float& OutInnerTop, + float& OutInnerBottom) + { + if (!VoxelMath::IsFinite(StrateTopZ) + || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(BoundarySealThickness) + || BoundarySealThickness < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + int32 InteriorMarginVoxels = Settings.InteriorMarginVoxels; + if (InteriorMarginVoxels < 0) + { + const double DerivedMargin = 2.0 + * static_cast(BoundarySealThickness); + if (!VoxelMath::IsFinite(DerivedMargin) + || DerivedMargin > static_cast(INT32_MAX)) + { + return false; + } + InteriorMarginVoxels = FMath::CeilToInt(static_cast(DerivedMargin)); + } + if (InteriorMarginVoxels < 0 || InteriorMarginVoxels > 4096) + { + return false; + } + + OutInnerBottom = StrateBottomZ + static_cast(InteriorMarginVoxels); + OutInnerTop = StrateTopZ - static_cast(InteriorMarginVoxels); + return VoxelMath::IsFinite(OutInnerTop) + && VoxelMath::IsFinite(OutInnerBottom) + && OutInnerTop > OutInnerBottom; + } + + bool VF_ValidatePlayerFitPose( + const FVoxelStrateMeasureSettings& Settings, + const FVector& CandidateFeetPoint, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + FVFPlayerDensitySampler SampleDensity, + FVector& OutPoint) + { + OutPoint = FVector::ZeroVector; + if (!VoxelMath::IsFinite(CandidateFeetPoint.X) + || !VoxelMath::IsFinite(CandidateFeetPoint.Y) + || !VoxelMath::IsFinite(CandidateFeetPoint.Z) + || !VoxelMath::IsFinite(StrateTopZ) + || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(BoundarySealThickness) + || BoundarySealThickness < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + TArray StencilRows; + TArray SupportOffsets; + int32 CentreSupportIndex = INDEX_NONE; + int32 RequiredSupportCount = 0; + if (!VF_BuildPlayerFitStencil( + Settings, StencilRows, SupportOffsets, + CentreSupportIndex, RequiredSupportCount)) + { + return false; + } + + const double MaxStepHeightVoxelsReal = + static_cast(Settings.PlayerMaxStepHeightMeters) + / static_cast(FVoxelPlayerCapsuleConstants::VoxelSizeMeters); + if (!VoxelMath::IsFinite(MaxStepHeightVoxelsReal) + || MaxStepHeightVoxelsReal < 0.0 + || MaxStepHeightVoxelsReal > 4096.0) + { + return false; + } + const int32 MaxDownwardSearchCells = FMath::CeilToInt( + static_cast(MaxStepHeightVoxelsReal)); + const float MaxStepHeightVoxels = static_cast(MaxStepHeightVoxelsReal); + const float MinimumWalkableNormalZ = VoxelMath::DetCos(FMath::DegreesToRadians( + Settings.PlayerWalkableFloorAngleDegrees)); + + TArray SupportHeights; + SupportHeights.SetNumUninitialized(SupportOffsets.Num()); + TArray bSupported; + bSupported.Init(0u, SupportOffsets.Num()); + int32 NumSupported = 0; + for (int32 SupportIndex = 0; SupportIndex < SupportOffsets.Num(); ++SupportIndex) + { + const FIntPoint& Offset = SupportOffsets[SupportIndex]; + for (int32 DownwardCell = 0; + DownwardCell <= MaxDownwardSearchCells; + ++DownwardCell) + { + const float UpperZ = CandidateFeetPoint.Z + - static_cast(DownwardCell); + const float LowerZ = UpperZ - 1.0f; + const float LowerDensity = SampleDensity( + CandidateFeetPoint.X + static_cast(Offset.X), + CandidateFeetPoint.Y + static_cast(Offset.Y), + LowerZ); + const float UpperDensity = SampleDensity( + CandidateFeetPoint.X + static_cast(Offset.X), + CandidateFeetPoint.Y + static_cast(Offset.Y), + UpperZ); + if (!VoxelMath::IsFinite(LowerDensity) + || !VoxelMath::IsFinite(UpperDensity) + || !(LowerDensity <= 0.0f) + || !(UpperDensity > 0.0f)) + { + continue; + } + + const float Denominator = UpperDensity - LowerDensity; + if (!VoxelMath::IsFinite(Denominator) || Denominator <= 0.0f) + { + continue; + } + const float Fraction = FMath::Clamp( + -LowerDensity / Denominator, 0.0f, 1.0f); + const float SurfaceHeight = LowerZ + Fraction; + const float CandidateBottom = CandidateFeetPoint.Z - 0.5f; + if (!VoxelMath::IsFinite(SurfaceHeight) + || SurfaceHeight > CandidateBottom + KINDA_SMALL_NUMBER + || SurfaceHeight < CandidateBottom - MaxStepHeightVoxels + - KINDA_SMALL_NUMBER) + { + continue; + } + + bSupported[SupportIndex] = 1u; + SupportHeights[SupportIndex] = SurfaceHeight; + ++NumSupported; + break; + } + } + + if (NumSupported < RequiredSupportCount + || bSupported[CentreSupportIndex] == 0u) + { + return false; + } + + float SupportHeight = -FLT_MAX; + int32 SupportCount = 0; + for (int32 Index = 0; Index < SupportOffsets.Num(); ++Index) + { + if (bSupported[Index] == 0u) continue; + ++SupportCount; + SupportHeight = FMath::Max(SupportHeight, SupportHeights[Index]); + } + if (SupportCount < RequiredSupportCount || !VoxelMath::IsFinite(SupportHeight)) + { + return false; + } + + float MaximumGradient = 0.0f; + for (int32 First = 0; First < SupportOffsets.Num(); ++First) + { + if (bSupported[First] == 0u) continue; + for (int32 Second = First + 1; Second < SupportOffsets.Num(); ++Second) + { + if (bSupported[Second] == 0u) continue; + const float DX = static_cast( + SupportOffsets[Second].X - SupportOffsets[First].X); + const float DY = static_cast( + SupportOffsets[Second].Y - SupportOffsets[First].Y); + const float HorizontalDistance = FMath::Sqrt(DX * DX + DY * DY); + if (HorizontalDistance <= KINDA_SMALL_NUMBER) continue; + MaximumGradient = FMath::Max( + MaximumGradient, + FMath::Abs(SupportHeights[Second] - SupportHeights[First]) + / HorizontalDistance); + } + } + const float SupportNormalZ = 1.0f / FMath::Sqrt( + 1.0f + MaximumGradient * MaximumGradient); + if (!VoxelMath::IsFinite(SupportNormalZ) + || SupportNormalZ + KINDA_SMALL_NUMBER < MinimumWalkableNormalZ) + { + return false; + } + + // Re-evaluate every occupied stencil sample at the chosen support, so an overhang cannot + // masquerade as a floor. The returned point is the free centre immediately above that + // resolved support, which is the stable standing/footing point used by passage mouths. + for (const FVFPlayerCapsuleStencilRow& Row : StencilRows) + { + const float SampleZ = SupportHeight + + static_cast(Row.RelativeZ) + 0.5f; + for (const FIntPoint& Offset : Row.HorizontalOffsets) + { + const float Density = SampleDensity( + CandidateFeetPoint.X + static_cast(Offset.X), + CandidateFeetPoint.Y + static_cast(Offset.Y), + SampleZ); + if (!VoxelMath::IsFinite(Density) || !(Density > 0.0f)) + { + return false; + } + } + } + + float InnerTop = 0.0f; + float InnerBottom = 0.0f; + if (!VF_GetPlayerFitInteriorBounds( + Settings, StrateTopZ, StrateBottomZ, BoundarySealThickness, + InnerTop, InnerBottom)) + { + return false; + } + const float OutputZ = SupportHeight + 0.5f; + const float OccupiedTop = SupportHeight + + 2.0f * Settings.PlayerCapsuleHalfHeightVoxels; + if (!VoxelMath::IsFinite(InnerBottom) || !VoxelMath::IsFinite(InnerTop) + || !VoxelMath::IsFinite(OutputZ) || !VoxelMath::IsFinite(OccupiedTop) + || OutputZ <= InnerBottom + || OccupiedTop >= InnerTop) + { + return false; + } + + OutPoint = FVector(CandidateFeetPoint.X, CandidateFeetPoint.Y, OutputZ); + return !OutPoint.ContainsNaN() + && VoxelMath::IsFinite(OutPoint.X) + && VoxelMath::IsFinite(OutPoint.Y) + && VoxelMath::IsFinite(OutPoint.Z); + } + + struct FVFRoomLandingSite + { + FVector Center = FVector::ZeroVector; + float RadiusXY = 0.0f; + float RadiusZ = 0.0f; + uint32 Hash = 0; + bool bOrigin = false; + }; + + // The mouth searches ask the same room question dozens of times while only the query point + // changes. Keep the room constants in a small build-local descriptor so the search pays the + // hash/shape/trig/floor setup once per endpoint, exactly as the per-voxel cache already does + // for FCachedRoom. This is deliberately separate from FCachedRoom: BuildRoomMouth runs before + // the emitted cache exists. + struct FVFPreparedRoomLanding + { + FVector Center = FVector::ZeroVector; + FVector ShapeA = FVector::ZeroVector; + FVector ShapeB = FVector::ZeroVector; + float ShapeR = 0.0f; + uint8 ShapeType = 0; + + bool bHasFloorCut = false; + float FloorCutZ = -FLT_MAX; + float FloorBlend = 0.0f; + float FloorReliefStrength = 0.0f; + float FloorReliefFrequency = 0.0f; + uint32 FloorSeed = 0; + int32 FloorFeatureIndex = INDEX_NONE; + float FloorReliefBound = 0.0f; + bool bHasFloorReliefBound = false; + }; + + static bool VF_PrepareRoomLanding( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + FVFPreparedRoomLanding& OutQuery) + { + if (!VoxelMath::IsFinite(Site.Center.X) + || !VoxelMath::IsFinite(Site.Center.Y) + || !VoxelMath::IsFinite(Site.Center.Z) + || !VoxelMath::IsFinite(Site.RadiusXY) + || !VoxelMath::IsFinite(Site.RadiusZ) + || !VoxelMath::IsFinite(Params.RoomShapeVariety) + || !VoxelMath::IsFinite(Params.RoomFloorCutMin) + || !VoxelMath::IsFinite(Params.RoomFloorCutMax) + || !VoxelMath::IsFinite(Params.SDFBlendRadius) + || !VoxelMath::IsFinite(Params.FloorReliefStrength) + || !VoxelMath::IsFinite(Params.FloorReliefFrequency)) + { + return false; + } + + OutQuery = FVFPreparedRoomLanding(); + OutQuery.Center = Site.Center; + + const uint32 ShapeHash = VoxelHash::Mix(Site.Hash ^ 0xDEADBEEFu); + const float ShapeRoll = Site.bOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); + const float BoxThreshold = 1.0f - Params.RoomShapeVariety * 0.5f; + const float CapsuleThreshold = 1.0f - Params.RoomShapeVariety * 0.2f; + if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + { + OutQuery.ShapeType = 1; + OutQuery.ShapeA = FVector( + Site.RadiusXY * 0.8f, + Site.RadiusXY * 0.8f, + Site.RadiusZ * 0.8f); + OutQuery.ShapeR = Site.RadiusXY * 0.25f; + } + else if (ShapeRoll >= CapsuleThreshold) + { + OutQuery.ShapeType = 2; + const float DirectionAngle = VoxelHash::ToFloat01( + VoxelHash::Mix(Site.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; + const float StretchDistance = Site.RadiusXY * 0.7f; + float SinDirection = 0.0f, CosDirection = 0.0f; + VoxelMath::DetSinCos(SinDirection, CosDirection, DirectionAngle); + const FVector Direction(CosDirection, SinDirection, 0.0f); + OutQuery.ShapeA = Site.Center + Direction * StretchDistance; + OutQuery.ShapeB = Site.Center - Direction * StretchDistance; + OutQuery.ShapeR = FMath::Min(Site.RadiusXY * 0.6f, Site.RadiusZ); + } + else + { + OutQuery.ShapeType = 0; + OutQuery.ShapeA = FVector(Site.RadiusXY, Site.RadiusXY, Site.RadiusZ); + } + + const float FloorRoll = VoxelHash::ToFloat01( + VoxelHash::Mix(Site.Hash ^ 0xF100F2u)); + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FloorRoll); + if (FloorCut < 1.0f) + { + OutQuery.bHasFloorCut = true; + OutQuery.FloorCutZ = Site.Center.Z - Site.RadiusZ * FloorCut; + OutQuery.FloorBlend = Params.SDFBlendRadius * 0.35f; + OutQuery.FloorReliefStrength = Params.FloorReliefStrength; + OutQuery.FloorReliefFrequency = Params.FloorReliefFrequency; + OutQuery.FloorSeed = VoxelHash::Mix(Site.Hash ^ 0xF100F1u); + OutQuery.FloorFeatureIndex = static_cast(Site.Hash); + OutQuery.bHasFloorReliefBound = VF_GetFloorReliefBound( + OutQuery.FloorReliefStrength, 1.0f, 1.0f, + OutQuery.FloorReliefFrequency, OutQuery.FloorReliefBound); + } + return true; + } + + FORCEINLINE float VF_EvaluatePreparedRoomShapeSDF( + const FVFPreparedRoomLanding& Query, const FVector& Position) + { + switch (Query.ShapeType) + { + case 1: + return VoxelSDF::RoundedBox( + Position, Query.Center, Query.ShapeA, Query.ShapeR); + case 2: + return VoxelSDF::Capsule( + Position, Query.ShapeA, Query.ShapeB, Query.ShapeR); + default: + return VoxelSDF::Ellipsoid( + Position, Query.Center, Query.ShapeA); + } + } + + FVector VF_ApplyCaveWarp( + const FVector& WorldPoint, + const FStrateGenerationParams& Params, + uint32 Seed) + { + if (VoxelDensityAblation::IsCaveWarpOff()) + { + return WorldPoint; + } + float EffectiveZ = WorldPoint.Z; + if (Params.VerticalScale > 0.0f && Params.VerticalScale != 1.0f) + { + EffectiveZ = WorldPoint.Z / Params.VerticalScale; + } + + FVector Warped(WorldPoint.X, WorldPoint.Y, EffectiveZ); + if (Params.CaveWarpStrength <= 0.0f) + { + return Warped; + } + + const float Frequency = Params.CaveWarpFrequency; + const float Strength = Params.CaveWarpStrength; + float WarpX, WarpY, WarpZ; + VoxelNoise::Perlin3D_x3( + WorldPoint.X * Frequency + VoxelHash::SeedOffset(Seed, 0.37f), + WorldPoint.Y * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f, + WorldPoint.X * Frequency + 7.1f, + WorldPoint.Y * Frequency + VoxelHash::SeedOffset(Seed, 0.59f), + EffectiveZ * Frequency + 2.3f, + WorldPoint.X * Frequency + 11.3f, + WorldPoint.Y * Frequency + 9.7f, + EffectiveZ * Frequency + VoxelHash::SeedOffset(Seed, 0.41f), + WarpX, WarpY, WarpZ); + Warped.X += WarpX * VOXEL_NOISE_SCALE * Strength; + Warped.Y += WarpY * VOXEL_NOISE_SCALE * Strength; + Warped.Z += WarpZ * VOXEL_NOISE_SCALE * Strength; + return Warped; + } + + FVector VF_UnwarpCavePoint( + const FVector& TargetSDFPoint, + const FStrateGenerationParams& Params, + uint32 Seed) + { + FVector WorldPoint = TargetSDFPoint; + const float ZScale = Params.VerticalScale > 0.0f + ? Params.VerticalScale : 1.0f; + if (ZScale != 1.0f) + { + WorldPoint.Z *= ZScale; + } + if (Params.CaveWarpStrength <= 0.0f) + { + return WorldPoint; + } + + // The default warp is a contraction at its authored frequency. A fixed eight-step + // correction is enough to put a world landing back on the cached SDF point while keeping + // the construction path allocation-free and deterministic. + for (int32 Iteration = 0; Iteration < 8; ++Iteration) + { + const FVector Mapped = VF_ApplyCaveWarp(WorldPoint, Params, Seed); + const FVector Error = TargetSDFPoint - Mapped; + if (!Error.ContainsNaN() + && VoxelMath::IsFinite(Error.X) + && VoxelMath::IsFinite(Error.Y) + && VoxelMath::IsFinite(Error.Z)) + { + WorldPoint.X += Error.X; + WorldPoint.Y += Error.Y; + WorldPoint.Z += Error.Z * ZScale; + } + } + return WorldPoint; + } + + float VF_RoomFloorZFromParts( + const FVector& RoomCenter, + float RoomRadiusXY, + float RoomRadiusZ, + uint32 RoomHash, + uint32 WarpSeed, + const FStrateGenerationParams& Params) + { + const float Roll = VoxelHash::ToFloat01(VoxelHash::Mix(RoomHash ^ 0xF100F2u)); + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + Roll); + float FloorZ = FloorCut < 1.0f + ? RoomCenter.Z - RoomRadiusZ * FloorCut + : RoomCenter.Z - RoomRadiusZ; + if (FloorCut < 1.0f && Params.FloorReliefStrength > 0.0f) + { + // Match the evaluator's domain-warped XY input at the room centre. The local landing + // query still searches a bounded vertical band for the support patch around this + // estimate because the capsule covers neighbouring XY samples too. + const float EffectiveZ = Params.VerticalScale > 0.0f + ? RoomCenter.Z / Params.VerticalScale : RoomCenter.Z; + float SampleX = RoomCenter.X; + float SampleY = RoomCenter.Y; + if (Params.CaveWarpStrength > 0.0f) + { + const float Frequency = Params.CaveWarpFrequency; + const float Strength = Params.CaveWarpStrength; + SampleX += VoxelNoise::Perlin3D( + RoomCenter.X * Frequency + VoxelHash::SeedOffset(WarpSeed, 0.37f), + RoomCenter.Y * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f) + * VOXEL_NOISE_SCALE * Strength; + SampleY += VoxelNoise::Perlin3D( + RoomCenter.X * Frequency + 7.1f, + RoomCenter.Y * Frequency + VoxelHash::SeedOffset(WarpSeed, 0.59f), + EffectiveZ * Frequency + 2.3f) + * VOXEL_NOISE_SCALE * Strength; + } + + const float Frequency = Params.FloorReliefFrequency; + const uint32 FloorSeed = VoxelHash::Mix(RoomHash ^ 0xF100F1u); + const FVFFloorReliefColumnKey ReliefColumn = + VF_MakeFloorReliefColumnKey(SampleX, SampleY); + const float Noise = VF_FloorReliefNoiseFloat( + SampleX, SampleY, FloorSeed, Frequency, + nullptr, EVFFloorReliefFeature::LandingFloat, + static_cast(RoomHash), ReliefColumn); + FloorZ += Noise * VOXEL_NOISE_SCALE * Params.FloorReliefStrength; + } + // Keep this in the same (unwarped SDF) coordinate space as FCachedRoom::FloorCutZ. The + // production caller warps the query position before EvaluateSDFCached, and the exact fit + // predicate below samples that same warped field. This value is only a bounded search + // anchor; it must not pre-apply the Z warp or the room would be shifted twice. + return FloorZ; + } + + float VF_RoomFloorZ( + const FVFRoomLandingSite& Site, + uint32 WarpSeed, + const FStrateGenerationParams& Params) + { + return VF_RoomFloorZFromParts( + Site.Center, Site.RadiusXY, Site.RadiusZ, Site.Hash, WarpSeed, Params); + } + + float VF_EvaluateRoomLandingDensity( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float WorldZ) + { + const FVector Position = VF_ApplyCaveWarp( + FVector(WorldX, WorldY, WorldZ), Params, static_cast(Seed)); + + const uint32 ShapeHash = VoxelHash::Mix(Site.Hash ^ 0xDEADBEEFu); + const float ShapeRoll = Site.bOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); + const float BoxThreshold = 1.0f - Params.RoomShapeVariety * 0.5f; + const float CapsuleThreshold = 1.0f - Params.RoomShapeVariety * 0.2f; + float RoomSDF = 0.0f; + if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + { + RoomSDF = VoxelSDF::RoundedBox( + Position, + Site.Center, + FVector(Site.RadiusXY * 0.8f, Site.RadiusXY * 0.8f, Site.RadiusZ * 0.8f), + Site.RadiusXY * 0.25f); + } + else if (ShapeRoll >= CapsuleThreshold) + { + const float DirectionAngle = VoxelHash::ToFloat01( + VoxelHash::Mix(Site.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; + const float StretchDistance = Site.RadiusXY * 0.7f; + float SinDirection = 0.0f, CosDirection = 0.0f; + VoxelMath::DetSinCos(SinDirection, CosDirection, DirectionAngle); + const FVector Direction(CosDirection, SinDirection, 0.0f); + RoomSDF = VoxelSDF::Capsule( + Position, + Site.Center + Direction * StretchDistance, + Site.Center - Direction * StretchDistance, + FMath::Min(Site.RadiusXY * 0.6f, Site.RadiusZ)); + } + else + { + RoomSDF = VoxelSDF::Ellipsoid( + Position, Site.Center, + FVector(Site.RadiusXY, Site.RadiusXY, Site.RadiusZ)); + } + + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + VoxelHash::ToFloat01(VoxelHash::Mix(Site.Hash ^ 0xF100F2u))); + if (FloorCut < 1.0f) + { + float FloorZ = Site.Center.Z - Site.RadiusZ * FloorCut; + bool bFloorCutIsIdentityForDensity = false; + if (Params.FloorReliefStrength > 0.0f) + { + const float Frequency = Params.FloorReliefFrequency; + const uint32 FloorSeed = VoxelHash::Mix(Site.Hash ^ 0xF100F1u); + float ReliefBound = 0.0f; + const float FloorBlend = Params.SDFBlendRadius * 0.35f; + const float BaseFloorSDF = static_cast( + FloorZ - Position.Z); + const float BaseCutSDF = VoxelSDF::SmoothMax( + RoomSDF, BaseFloorSDF, FloorBlend); + if (VF_GetFloorReliefBound( + Params.FloorReliefStrength, 1.0f, 1.0f, + Frequency, ReliefBound) + && FMath::Abs(BaseCutSDF) > ReliefBound) + { + // This helper exposes only the sign of the room density. SmoothMax is + // one-Lipschitz in its floor argument, so the relief cannot change that + // sign when the unrelieved result is farther from zero than its bound. + RoomSDF = BaseCutSDF; + bFloorCutIsIdentityForDensity = true; + } + else + { + const FVFFloorReliefColumnKey ReliefColumn = + VF_MakeFloorReliefColumnKey(Position.X, Position.Y); + const float Noise = VF_FloorReliefNoiseDouble( + Position.X, Position.Y, + FloorSeed, Frequency, + nullptr, EVFFloorReliefFeature::LandingDouble, + static_cast(Site.Hash), ReliefColumn); + FloorZ += Noise * VOXEL_NOISE_SCALE * Params.FloorReliefStrength; + } + } + if (!bFloorCutIsIdentityForDensity) + { + RoomSDF = VoxelSDF::SmoothMax( + RoomSDF, FloorZ - Position.Z, Params.SDFBlendRadius * 0.35f); + } + } + + float InternalDensity = RoomSDF < 0.0f ? -1.0f : 1.0f; + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, StrateTopZ, StrateBottomZ, + Params.BoundarySealThickness, 1.0f); + // Query-facing density uses the same MC polarity as the measurement: positive is air. + return -InternalDensity; + } + + static float VF_EvaluatePreparedRoomLandingDensity( + const FVFPreparedRoomLanding& Query, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float WorldZ) + { + const FVector Position = VF_ApplyCaveWarp( + FVector(WorldX, WorldY, WorldZ), Params, Seed); + float RoomSDF = VF_EvaluatePreparedRoomShapeSDF(Query, Position); + + if (Query.bHasFloorCut) + { + bool bFloorCutIsIdentityForDensity = false; + if (Query.FloorReliefStrength > 0.0f) + { + const float BaseFloorSDF = Query.FloorCutZ - Position.Z; + const float BaseCutSDF = VoxelSDF::SmoothMax( + RoomSDF, BaseFloorSDF, Query.FloorBlend); + if (Query.bHasFloorReliefBound + && FMath::Abs(BaseCutSDF) > Query.FloorReliefBound) + { + // SmoothMax is one-Lipschitz in its floor argument. The same bound used by + // the scalar reference proves that relief cannot change this sign, so avoid + // the noise call while retaining the exact sign decision. + RoomSDF = BaseCutSDF; + bFloorCutIsIdentityForDensity = true; + } + else + { + const FVFFloorReliefColumnKey ReliefColumn = + VF_MakeFloorReliefColumnKey(Position.X, Position.Y); + const float Noise = VF_FloorReliefNoiseDouble( + Position.X, Position.Y, + Query.FloorSeed, Query.FloorReliefFrequency, + nullptr, EVFFloorReliefFeature::LandingDouble, + Query.FloorFeatureIndex, ReliefColumn); + const float FloorZ = Query.FloorCutZ + + Noise * VOXEL_NOISE_SCALE * Query.FloorReliefStrength; + RoomSDF = VoxelSDF::SmoothMax( + RoomSDF, FloorZ - Position.Z, Query.FloorBlend); + bFloorCutIsIdentityForDensity = true; + } + } + if (!bFloorCutIsIdentityForDensity) + { + RoomSDF = VoxelSDF::SmoothMax( + RoomSDF, Query.FloorCutZ - Position.Z, Query.FloorBlend); + } + } + + float InternalDensity = RoomSDF < 0.0f ? -1.0f : 1.0f; + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, StrateTopZ, StrateBottomZ, + Params.BoundarySealThickness, 1.0f); + return -InternalDensity; + } + + static bool VF_FindRoomFloorCrossingWorld( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + const FVector& WorldMouthXY, + float& OutFloorZ) + { + OutFloorZ = -FLT_MAX; + if (!VoxelMath::IsFinite(WorldMouthXY.X) + || !VoxelMath::IsFinite(WorldMouthXY.Y) + || !VoxelMath::IsFinite(Site.Center.Z) + || !VoxelMath::IsFinite(Site.RadiusZ)) + { + return false; + } + + // BuildRoomMouth receives a world-space point from the boundary search. Find the + // crossing in that same space instead of reusing the SDF-space floor estimate. This is + // deliberately a vertical room query: the room owns the floor height at the mouth, and + // the tunnel endpoint is then placed one tunnel radius above that exact crossing. + const float ReliefBound = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float MinZ = Site.Center.Z - FMath::Abs(Site.RadiusZ) + - ReliefBound - 16.0f; + const float MaxZ = Site.Center.Z + FMath::Abs(Site.RadiusZ) + + ReliefBound + 16.0f; + constexpr float SearchStep = 1.0f; + if (!VoxelMath::IsFinite(MinZ) || !VoxelMath::IsFinite(MaxZ) || MaxZ <= MinZ) + { + return false; + } + + FVFPreparedRoomLanding PreparedRoom; + const bool bPreparedRoom = VF_PrepareRoomLanding( + Site, Params, PreparedRoom); + auto Evaluate = [&](float Z) -> float + { + return bPreparedRoom + ? VF_EvaluatePreparedRoomLandingDensity( + PreparedRoom, Params, Seed, StrateTopZ, StrateBottomZ, + WorldMouthXY.X, WorldMouthXY.Y, Z) + : VF_EvaluateRoomLandingDensity( + Site, Params, Seed, StrateTopZ, StrateBottomZ, + WorldMouthXY.X, WorldMouthXY.Y, Z); + }; + + float Low = MinZ; + float LowValue = Evaluate(Low); + if (!VoxelMath::IsFinite(LowValue)) + { + return false; + } + + // Once the lower and upper ends bracket the room, the authored room/floor field is + // monotone through this narrow vertical landing band for the finite parameter envelope + // used by the generator. Find the one-voxel bucket with a logarithmic search, then run + // the exact bisection in that bucket. The latter is kept byte-for-byte in its arithmetic + // and iteration count so the endpoint is identical to the one-voxel walk below. + // If the bracket is not monotone (or the local bucket check cannot reproduce the crossing), + // fall back to that one-voxel walk; malformed/unsupported inputs therefore keep its + // result. + auto BisectBracket = [&](float BracketLow, float BracketHigh, + float BracketLowValue) -> bool + { + for (int32 Iteration = 0; Iteration < 24; ++Iteration) + { + const float Mid = (BracketLow + BracketHigh) * 0.5f; + const float MidValue = Evaluate(Mid); + if (!VoxelMath::IsFinite(MidValue)) + { + return false; + } + if (FMath::Abs(MidValue) <= 1.0e-5f) + { + BracketLow = Mid; + BracketHigh = Mid; + break; + } + if (BracketLowValue <= 0.0f && MidValue < 0.0f) + { + BracketLow = Mid; + BracketLowValue = MidValue; + } + else + { + BracketHigh = Mid; + } + } + OutFloorZ = (BracketLow + BracketHigh) * 0.5f; + return VoxelMath::IsFinite(OutFloorZ); + }; + + const int32 Steps = FMath::CeilToInt((MaxZ - MinZ) / SearchStep); + const float MaxValue = Evaluate(MaxZ); + if (VoxelMath::IsFinite(MaxValue) + && LowValue <= 0.0f + && MaxValue >= 0.0f + && Steps > 0) + { + float BracketLow = Low; + float BracketHigh = MaxZ; + float BracketLowValue = LowValue; + for (int32 Iteration = 0; Iteration < 12; ++Iteration) + { + const float Mid = (BracketLow + BracketHigh) * 0.5f; + const float MidValue = Evaluate(Mid); + if (!VoxelMath::IsFinite(MidValue)) + { + BracketLow = Low; + BracketHigh = Low; + break; + } + if (BracketLowValue <= 0.0f && MidValue < 0.0f) + { + BracketLow = Mid; + BracketLowValue = MidValue; + } + else + { + BracketHigh = Mid; + } + } + + if (BracketHigh > BracketLow) + { + const float ApproximateCrossing = (BracketLow + BracketHigh) * 0.5f; + const float RelativeCrossing = + (ApproximateCrossing - MinZ) / SearchStep; + const int32 GuessIndex = FMath::Clamp( + FMath::FloorToInt(RelativeCrossing), 0, Steps - 1); + for (int32 Offset = -2; Offset <= 2; ++Offset) + { + const int32 CandidateIndex = GuessIndex + Offset; + if (CandidateIndex < 0 || CandidateIndex >= Steps) + { + continue; + } + const float CandidateLow = MinZ + + static_cast(CandidateIndex) * SearchStep; + const float CandidateHigh = FMath::Min( + MaxZ, MinZ + + static_cast(CandidateIndex + 1) * SearchStep); + const float CandidateLowValue = Evaluate(CandidateLow); + const float CandidateHighValue = Evaluate(CandidateHigh); + if (VoxelMath::IsFinite(CandidateLowValue) + && VoxelMath::IsFinite(CandidateHighValue) + && CandidateLowValue <= 0.0f + && CandidateHighValue >= 0.0f) + { + return BisectBracket( + CandidateLow, CandidateHigh, CandidateLowValue); + } + } + } + } + + for (int32 StepIndex = 1; StepIndex <= Steps; ++StepIndex) + { + const float High = FMath::Min( + MaxZ, MinZ + static_cast(StepIndex) * SearchStep); + const float HighValue = Evaluate(High); + if (!VoxelMath::IsFinite(HighValue)) + { + Low = High; + LowValue = HighValue; + continue; + } + if (FMath::Abs(LowValue) <= KINDA_SMALL_NUMBER) + { + OutFloorZ = Low; + return true; + } + if (LowValue <= 0.0f && HighValue >= 0.0f) + { + return BisectBracket(Low, High, LowValue); + } + Low = High; + LowValue = HighValue; + } + return false; + } + + static bool VF_FindRoomMouthBoundary( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + const FVector& Anchor, + const FVector& Direction, + float TunnelRadius, + float BlendRadius, + FVector& OutMouthXY, + float& OutBoundaryDistance) + { + FVector DirectionXY(Direction.X, Direction.Y, 0.0f); + if (!DirectionXY.Normalize() + || !VoxelMath::IsFinite(Anchor.X) + || !VoxelMath::IsFinite(Anchor.Y) + || !VoxelMath::IsFinite(Site.Center.Z)) + { + return false; + } + + // Probe both the room mid-height and the tunnel-centre height. The latter is important + // for vertically compressed/ellipsoidal rooms: their wall can be closer at the floor + // than at mid-height. Both probes use the exact hashed room shape and cave warp used by + // density evaluation. Positive landing density means air/inside. + const float RoomFloorZ = VF_RoomFloorZFromParts( + Site.Center, Site.RadiusXY, Site.RadiusZ, Site.Hash, Seed, Params); + const float LowerProbeZ = VoxelMath::IsFinite(RoomFloorZ) + ? RoomFloorZ + FMath::Abs(TunnelRadius) + : Site.Center.Z; + const float UpperProbeZ = Site.Center.Z; + FVFPreparedRoomLanding PreparedRoom; + const bool bPreparedRoom = VF_PrepareRoomLanding( + Site, Params, PreparedRoom); + const auto IsInside = [&](float Distance) -> bool + { + const FVector Probe = Anchor + DirectionXY * Distance; + const float LowerDensity = bPreparedRoom + ? VF_EvaluatePreparedRoomLandingDensity( + PreparedRoom, Params, Seed, StrateTopZ, StrateBottomZ, + static_cast(Probe.X), static_cast(Probe.Y), LowerProbeZ) + : VF_EvaluateRoomLandingDensity( + Site, Params, Seed, StrateTopZ, StrateBottomZ, + static_cast(Probe.X), static_cast(Probe.Y), LowerProbeZ); + const float UpperDensity = bPreparedRoom + ? VF_EvaluatePreparedRoomLandingDensity( + PreparedRoom, Params, Seed, StrateTopZ, StrateBottomZ, + static_cast(Probe.X), static_cast(Probe.Y), UpperProbeZ) + : VF_EvaluateRoomLandingDensity( + Site, Params, Seed, StrateTopZ, StrateBottomZ, + static_cast(Probe.X), static_cast(Probe.Y), UpperProbeZ); + return VoxelMath::IsFinite(LowerDensity) + && LowerDensity > 0.0f + && VoxelMath::IsFinite(UpperDensity) + && UpperDensity > 0.0f; + }; + + if (!IsInside(0.0f)) + { + return false; + } + + const float ShapeReach = VF_RoomShapeReachUpperBound( + Site.RadiusXY, Site.RadiusZ); + const float WarpReach = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 2.0f; + float High = ShapeReach + WarpReach + + FMath::Max(8.0f, FMath::Abs(TunnelRadius) + FMath::Abs(BlendRadius)); + if (!VoxelMath::IsFinite(High) || High <= 0.0f) + { + return false; + } + + bool bOutside = !IsInside(High); + for (int32 Expansion = 0; !bOutside && Expansion < 4; ++Expansion) + { + High = FMath::Min(High * 2.0f, 4096.0f); + bOutside = !IsInside(High); + } + if (!bOutside) + { + return false; + } + + float Low = 0.0f; + for (int32 Iteration = 0; Iteration < 24; ++Iteration) + { + const float Mid = (Low + High) * 0.5f; + if (IsInside(Mid)) + { + Low = Mid; + } + else + { + High = Mid; + } + } + + const float MouthInset = VF_TunnelMouthInset(TunnelRadius, BlendRadius); + OutBoundaryDistance = Low; + const float MouthDistance = FMath::Max(0.0f, Low - MouthInset); + OutMouthXY = Anchor + DirectionXY * MouthDistance; + OutMouthXY.Z = Site.Center.Z; + return VoxelMath::IsFinite(OutMouthXY.X) + && VoxelMath::IsFinite(OutMouthXY.Y) + && VoxelMath::IsFinite(OutBoundaryDistance); + } + + bool VF_FindPlayerFitPointForRoom( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + float StrateTopZ, + float StrateBottomZ, + uint32 WarpSeed, + FVector& OutPoint) + { + OutPoint = FVector::ZeroVector; + const float FloorZ = VF_RoomFloorZ(Site, WarpSeed, Params); + if (!VoxelMath::IsFinite(FloorZ)) + { + return false; + } + + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float FloorReliefBound = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float CaveWarpBound = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE; + const int32 ReliefSearch = FMath::Clamp( + FMath::CeilToInt(FloorReliefBound + CaveWarpBound) + 8, + 8, + 128); + FVoxelStrateMeasureSettings FitSettings; + const auto SampleRoom = [&](float X, float Y, float Z) + { + return VF_EvaluateRoomLandingDensity( + Site, Params, WarpSeed, + StrateTopZ, StrateBottomZ, X, Y, Z); + }; + + const int32 LocalExtent = 0; + TArray> LocalProbes; + for (int32 LocalY = -LocalExtent; LocalY <= LocalExtent; LocalY += 2) + { + for (int32 LocalX = -LocalExtent; LocalX <= LocalExtent; LocalX += 2) + { + if (static_cast(LocalX * LocalX + LocalY * LocalY) + > FMath::Square(Site.RadiusXY * 0.75f)) + { + continue; + } + LocalProbes.Add(FIntPoint(LocalX, LocalY)); + } + } + if ((LocalExtent & 1) != 0) + { + LocalProbes.Add(FIntPoint::ZeroValue); + } + LocalProbes.Sort([](const FIntPoint& A, const FIntPoint& B) + { + const int32 ADistanceSq = A.X * A.X + A.Y * A.Y; + const int32 BDistanceSq = B.X * B.X + B.Y * B.Y; + if (ADistanceSq != BDistanceSq) + { + return ADistanceSq < BDistanceSq; + } + return A.X != B.X ? A.X < B.X : A.Y < B.Y; + }); + + for (const FIntPoint& LocalProbe : LocalProbes) + { + const FVector CandidateFeetBase = VF_UnwarpCavePoint( + FVector( + Site.Center.X + static_cast(LocalProbe.X), + Site.Center.Y + static_cast(LocalProbe.Y), + FloorZ + MaxStepHeightVoxels + 0.5f), + Params, + WarpSeed); + for (int32 OffsetIndex = 0; + OffsetIndex <= ReliefSearch * 2; + ++OffsetIndex) + { + const int32 SignedOffset = OffsetIndex == 0 + ? 0 + : ((OffsetIndex & 1) != 0 + ? -((OffsetIndex + 1) / 2) + : OffsetIndex / 2); + if (FMath::Abs(SignedOffset) > ReliefSearch) + { + continue; + } + const FVector CandidateFeet = CandidateFeetBase + + FVector(0.0f, 0.0f, static_cast(SignedOffset)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleRoom, + OutPoint)) + { + return true; + } + } + } + return false; + } + + // The room-fit result is pure with respect to the complete parameter value, room site, Z + // bounds, and warp seed. Keep the complete key explicit instead of hashing the USTRUCT's + // object representation: the latter would include padding and would become unsafe if a + // pointer/TArray field were ever added. +#define VF_PLAYER_FIT_PARAM_COUNT_LERP(Name) +1 +#define VF_PLAYER_FIT_PARAM_COUNT_SNAP(Name) +1 + static constexpr int32 VF_PlayerFitParamWordCount = + 0 VF_STRATE_PARAM_FIELDS( + VF_PLAYER_FIT_PARAM_COUNT_LERP, + VF_PLAYER_FIT_PARAM_COUNT_SNAP); +#undef VF_PLAYER_FIT_PARAM_COUNT_LERP +#undef VF_PLAYER_FIT_PARAM_COUNT_SNAP + + static constexpr int32 VF_PlayerFitVectorWordCount = + static_cast(sizeof(FVector::FReal) / sizeof(uint32)); + static_assert( + VF_PlayerFitVectorWordCount == 1 || VF_PlayerFitVectorWordCount == 2, + "player-fit memo expects float or double FVector components"); + static constexpr int32 VF_PlayerFitExtraKeyWordCount = + 3 * VF_PlayerFitVectorWordCount + 8; + static constexpr int32 VF_PlayerFitKeyWordCount = + VF_PlayerFitParamWordCount + VF_PlayerFitExtraKeyWordCount; + static constexpr uint32 VF_PlayerFitMemoKeyVersion = 0x00010001u; + + struct FVFPlayerFitMemoKey + { + uint32 Words[VF_PlayerFitKeyWordCount] = {}; + }; + + template + FORCEINLINE void VF_AppendPlayerFitMemoBits( + TKey& Key, + int32& InOutWordIndex, + const T& Value) + { + static_assert( + sizeof(T) <= sizeof(uint64), + "player-fit memo key fields must be scalar values no wider than uint64"); + constexpr int32 WordCount = + static_cast((sizeof(T) + sizeof(uint32) - 1) / sizeof(uint32)); + for (int32 WordIndex = 0; WordIndex < WordCount; ++WordIndex) + { + Key.Words[InOutWordIndex + WordIndex] = 0u; + } + FMemory::Memcpy( + Key.Words + InOutWordIndex, + &Value, + sizeof(T)); + InOutWordIndex += WordCount; + } + + static FVFPlayerFitMemoKey VF_MakePlayerFitMemoKey( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + float StrateTopZ, + float StrateBottomZ, + uint32 WarpSeed) + { + FVFPlayerFitMemoKey Key; + int32 WordIndex = 0; + +#define VF_PLAYER_FIT_KEY_APPEND_LERP(Name) \ + VF_AppendPlayerFitMemoBits(Key, WordIndex, Params.Name); +#define VF_PLAYER_FIT_KEY_APPEND_SNAP(Name) \ + VF_AppendPlayerFitMemoBits(Key, WordIndex, Params.Name); + VF_STRATE_PARAM_FIELDS( + VF_PLAYER_FIT_KEY_APPEND_LERP, + VF_PLAYER_FIT_KEY_APPEND_SNAP) +#undef VF_PLAYER_FIT_KEY_APPEND_LERP +#undef VF_PLAYER_FIT_KEY_APPEND_SNAP + + // FVector is double-valued in the UE5 build used by this plugin. Append each component + // as raw words so the site key preserves its exact input bits on either float/double build. + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.X); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.Y); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.Z); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.RadiusXY); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.RadiusZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Hash); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.bOrigin); + VF_AppendPlayerFitMemoBits(Key, WordIndex, StrateTopZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, StrateBottomZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, WarpSeed); + VF_AppendPlayerFitMemoBits(Key, WordIndex, VF_PlayerFitMemoKeyVersion); + check(WordIndex == VF_PlayerFitKeyWordCount); + return Key; + } + + FORCEINLINE uint32 VF_HashPlayerFitMemoKey(const FVFPlayerFitMemoKey& Key) + { + uint32 Hash = 0xD14C4B7Du; + for (int32 WordIndex = 0; WordIndex < VF_PlayerFitKeyWordCount; ++WordIndex) + { + Hash ^= Key.Words[WordIndex] + + 0x9e3779b9u + + (Hash << 6) + + (Hash >> 2); + } + return VoxelHash::Mix(Hash); + } + + // A slot has an atomic reader/writer state, but its key/result payload stays ordinary data. + // Readers increment the low counter before touching the payload; writers set the high bit, + // wait for existing readers, replace the payload, then publish the valid bit. This avoids + // a seqlock data race and avoids the ABA window of a finite sequence number. + struct FVFPlayerFitMemoSlot + { + static constexpr uint32 ReaderMask = (1u << 30) - 1u; + static constexpr uint32 ValidBit = 1u << 30; + static constexpr uint32 WriterBit = 1u << 31; + + std::atomic State; + std::atomic KeyHash; + FVFPlayerFitMemoKey Key; + FVector Result = FVector::ZeroVector; + bool bFound = false; + + FVFPlayerFitMemoSlot() + : State(0u) + , KeyHash(0u) + { + } + + bool TryAcquireRead() + { + uint32 Current = State.load(std::memory_order_acquire); + for (;;) + { + if ((Current & ValidBit) == 0u + || (Current & WriterBit) != 0u + || (Current & ReaderMask) == ReaderMask) + { + return false; + } + if (State.compare_exchange_weak( + Current, + Current + 1u, + std::memory_order_acquire, + std::memory_order_relaxed)) + { + return true; + } + } + } + + void ReleaseRead() + { + State.fetch_sub(1u, std::memory_order_release); + } + + bool AcquireWrite() + { + uint32 Current = State.load(std::memory_order_acquire); + for (;;) + { + if ((Current & WriterBit) != 0u) + { + FPlatformProcess::YieldThread(); + Current = State.load(std::memory_order_acquire); + continue; + } + + const bool bWasValid = (Current & ValidBit) != 0u; + if (State.compare_exchange_weak( + Current, + Current | WriterBit, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + while ((State.load(std::memory_order_acquire) & ReaderMask) != 0u) + { + FPlatformProcess::YieldThread(); + } + return bWasValid; + } + } + } + + }; + + struct FVFPlayerFitMemoSetLock + { + std::atomic bLocked; + + FVFPlayerFitMemoSetLock() + : bLocked(false) + { + } + + void Lock() + { + bool bExpected = false; + while (!bLocked.compare_exchange_weak( + bExpected, + true, + std::memory_order_acquire, + std::memory_order_relaxed)) + { + bExpected = false; + FPlatformProcess::YieldThread(); + } + } + + void Unlock() + { + bLocked.store(false, std::memory_order_release); + } + }; + + struct FVFPlayerFitMemo + { + static constexpr int32 NumSets = 4096; + static constexpr int32 NumWays = 4; + static constexpr int32 Capacity = NumSets * NumWays; + static_assert((NumSets & (NumSets - 1)) == 0, "player-fit memo sets must be a power of two"); + static_assert((NumWays & (NumWays - 1)) == 0, "player-fit memo ways must be a power of two"); + + FVFPlayerFitMemoSlot Slots[Capacity]; + FVFPlayerFitMemoSetLock SetLocks[NumSets]; + std::atomic ReplacementCursor[NumSets]; + + std::atomic Requests{0}; + std::atomic Hits{0}; + std::atomic Misses{0}; + std::atomic Searches{0}; + std::atomic Evictions{0}; + std::atomic Bypasses{0}; + std::atomic OccupiedEntries{0}; + + FVFPlayerFitMemo() + { + for (int32 SetIndex = 0; SetIndex < NumSets; ++SetIndex) + { + ReplacementCursor[SetIndex].store(0u, std::memory_order_relaxed); + } + } + + static FORCEINLINE int32 SlotIndex(uint32 KeyHash, int32 Way) + { + const int32 SetIndex = static_cast(KeyHash & (NumSets - 1)); + return SetIndex * NumWays + Way; + } + + bool TryFind( + const FVFPlayerFitMemoKey& InKey, + uint32 InKeyHash, + FVector& OutPoint, + bool& OutFound) + { + for (int32 Way = 0; Way < NumWays; ++Way) + { + FVFPlayerFitMemoSlot& Slot = Slots[SlotIndex(InKeyHash, Way)]; + // A stale hash can only cause a miss. Exact key comparison below is still + // mandatory before returning the cached result. + if (Slot.KeyHash.load(std::memory_order_relaxed) != InKeyHash + || !Slot.TryAcquireRead()) + { + continue; + } + + bool bMatches = true; + for (int32 WordIndex = 0; + WordIndex < VF_PlayerFitKeyWordCount; + ++WordIndex) + { + if (Slot.Key.Words[WordIndex] != InKey.Words[WordIndex]) + { + bMatches = false; + break; + } + } + + if (bMatches) + { + OutPoint = Slot.Result; + OutFound = Slot.bFound; + Slot.ReleaseRead(); + return true; + } + Slot.ReleaseRead(); + } + return false; + } + + void Publish( + int32 SetIndex, + const FVFPlayerFitMemoKey& InKey, + uint32 InKeyHash, + bool bFound, + const FVector& Point) + { + int32 SelectedWay = INDEX_NONE; + for (int32 Way = 0; Way < NumWays; ++Way) + { + const FVFPlayerFitMemoSlot& Slot = Slots[SlotIndex(InKeyHash, Way)]; + if ((Slot.State.load(std::memory_order_acquire) + & FVFPlayerFitMemoSlot::ValidBit) == 0u) + { + SelectedWay = Way; + break; + } + } + if (SelectedWay == INDEX_NONE) + { + SelectedWay = static_cast( + ReplacementCursor[SetIndex].fetch_add( + 1u, std::memory_order_relaxed) + & (NumWays - 1)); + } + + FVFPlayerFitMemoSlot& Slot = Slots[SlotIndex(InKeyHash, SelectedWay)]; + const bool bWasValid = Slot.AcquireWrite(); + Slot.Key = InKey; + Slot.Result = Point; + Slot.bFound = bFound; + Slot.KeyHash.store(InKeyHash, std::memory_order_relaxed); + Slot.State.store(FVFPlayerFitMemoSlot::ValidBit, std::memory_order_release); + if (bWasValid) + { + Evictions.fetch_add(1u, std::memory_order_relaxed); + } + else + { + OccupiedEntries.fetch_add(1u, std::memory_order_relaxed); + } + } + + template + bool FindOrCompute( + const FVFPlayerFitMemoKey& InKey, + FVector& OutPoint, + ComputeFn&& Compute) + { + const uint32 KeyHash = VF_HashPlayerFitMemoKey(InKey); + Requests.fetch_add(1u, std::memory_order_relaxed); + + bool bFound = false; + FVector CachedPoint = FVector::ZeroVector; + if (TryFind(InKey, KeyHash, CachedPoint, bFound)) + { + OutPoint = CachedPoint; + Hits.fetch_add(1u, std::memory_order_relaxed); + return bFound; + } + + const int32 SetIndex = static_cast(KeyHash & (NumSets - 1)); + SetLocks[SetIndex].Lock(); + if (TryFind(InKey, KeyHash, CachedPoint, bFound)) + { + SetLocks[SetIndex].Unlock(); + OutPoint = CachedPoint; + Hits.fetch_add(1u, std::memory_order_relaxed); + return bFound; + } + + FVector ComputedPoint = FVector::ZeroVector; + bFound = Compute(ComputedPoint); + Searches.fetch_add(1u, std::memory_order_relaxed); + Misses.fetch_add(1u, std::memory_order_relaxed); + Publish(SetIndex, InKey, KeyHash, bFound, ComputedPoint); + SetLocks[SetIndex].Unlock(); + + OutPoint = ComputedPoint; + return bFound; + } + + void ResetStats() + { + Requests.store(0u, std::memory_order_relaxed); + Hits.store(0u, std::memory_order_relaxed); + Misses.store(0u, std::memory_order_relaxed); + Searches.store(0u, std::memory_order_relaxed); + Evictions.store(0u, std::memory_order_relaxed); + Bypasses.store(0u, std::memory_order_relaxed); + } + }; + + // Room-mouth construction is pure for the same generation parameters, room site, approach + // direction, and tunnel radius. BuildChunkCache is intentionally rebuilt for every touched + // tile, so doing the warped wall/floor search in every cache threw away the same result over + // and over again. Keep a small process-wide set-associative memo, using the same guarded + // payload protocol as the player-fit memo above. This changes no field arithmetic; it only + // moves the deterministic search to the first cache that needs a given mouth. + static constexpr int32 VF_RoomMouthKeyWordCount = + VF_PlayerFitParamWordCount + + 3 * VF_PlayerFitVectorWordCount + + 2 + 1 + 1 + + 2 * VF_PlayerFitVectorWordCount + + 2 * VF_PlayerFitVectorWordCount + + 4; + static constexpr uint32 VF_RoomMouthMemoKeyVersion = 0x00010001u; + + struct FVFRoomMouthMemoKey + { + uint32 Words[VF_RoomMouthKeyWordCount] = {}; + }; + + static FVFRoomMouthMemoKey VF_MakeRoomMouthMemoKey( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + const FVector& Anchor, + const FVector& Direction, + float TunnelRadius, + float BlendRadius, + uint32 WarpSeed) + { + FVFRoomMouthMemoKey Key; + int32 WordIndex = 0; + +#define VF_ROOM_MOUTH_KEY_APPEND_LERP(Name) \ + VF_AppendPlayerFitMemoBits(Key, WordIndex, Params.Name); +#define VF_ROOM_MOUTH_KEY_APPEND_SNAP(Name) \ + VF_AppendPlayerFitMemoBits(Key, WordIndex, Params.Name); + VF_STRATE_PARAM_FIELDS( + VF_ROOM_MOUTH_KEY_APPEND_LERP, + VF_ROOM_MOUTH_KEY_APPEND_SNAP) +#undef VF_ROOM_MOUTH_KEY_APPEND_LERP +#undef VF_ROOM_MOUTH_KEY_APPEND_SNAP + + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.X); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.Y); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.Z); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.RadiusXY); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.RadiusZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Hash); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.bOrigin); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Anchor.X); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Anchor.Y); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Direction.X); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Direction.Y); + VF_AppendPlayerFitMemoBits(Key, WordIndex, TunnelRadius); + VF_AppendPlayerFitMemoBits(Key, WordIndex, BlendRadius); + VF_AppendPlayerFitMemoBits(Key, WordIndex, WarpSeed); + VF_AppendPlayerFitMemoBits(Key, WordIndex, VF_RoomMouthMemoKeyVersion); + check(WordIndex == VF_RoomMouthKeyWordCount); + return Key; + } + + FORCEINLINE uint32 VF_HashRoomMouthMemoKey( + const FVFRoomMouthMemoKey& Key) + { + uint32 Hash = 0x6D6F7574u; + for (int32 WordIndex = 0; + WordIndex < VF_RoomMouthKeyWordCount; + ++WordIndex) + { + Hash ^= Key.Words[WordIndex] + + 0x9e3779b9u + + (Hash << 6) + + (Hash >> 2); + } + return VoxelHash::Mix(Hash); + } + + struct FVFRoomMouthMemoSlot + { + static constexpr uint32 ReaderMask = (1u << 30) - 1u; + static constexpr uint32 ValidBit = 1u << 30; + static constexpr uint32 WriterBit = 1u << 31; + + std::atomic State; + std::atomic KeyHash; + FVFRoomMouthMemoKey Key; + FVector Result = FVector::ZeroVector; + bool bFound = false; + + FVFRoomMouthMemoSlot() + : State(0u) + , KeyHash(0u) + { + } + + bool TryAcquireRead() + { + uint32 Current = State.load(std::memory_order_acquire); + for (;;) + { + if ((Current & ValidBit) == 0u + || (Current & WriterBit) != 0u + || (Current & ReaderMask) == ReaderMask) + { + return false; + } + if (State.compare_exchange_weak( + Current, + Current + 1u, + std::memory_order_acquire, + std::memory_order_relaxed)) + { + return true; + } + } + } + + void ReleaseRead() + { + State.fetch_sub(1u, std::memory_order_release); + } + + bool AcquireWrite() + { + uint32 Current = State.load(std::memory_order_acquire); + for (;;) + { + if ((Current & WriterBit) != 0u) + { + FPlatformProcess::YieldThread(); + Current = State.load(std::memory_order_acquire); + continue; + } + + const bool bWasValid = (Current & ValidBit) != 0u; + if (State.compare_exchange_weak( + Current, + Current | WriterBit, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + while ((State.load(std::memory_order_acquire) & ReaderMask) != 0u) + { + FPlatformProcess::YieldThread(); + } + return bWasValid; + } + } + } + }; + + struct FVFRoomMouthMemo + { + static constexpr int32 NumSets = 4096; + static constexpr int32 NumWays = 4; + static constexpr int32 Capacity = NumSets * NumWays; + static_assert((NumSets & (NumSets - 1)) == 0, + "room-mouth memo sets must be a power of two"); + static_assert((NumWays & (NumWays - 1)) == 0, + "room-mouth memo ways must be a power of two"); + + FVFRoomMouthMemoSlot Slots[Capacity]; + FVFPlayerFitMemoSetLock SetLocks[NumSets]; + std::atomic ReplacementCursor[NumSets]; + + std::atomic Requests{0}; + std::atomic Hits{0}; + std::atomic Misses{0}; + std::atomic Searches{0}; + std::atomic Evictions{0}; + std::atomic OccupiedEntries{0}; + + FVFRoomMouthMemo() + { + for (int32 SetIndex = 0; SetIndex < NumSets; ++SetIndex) + { + ReplacementCursor[SetIndex].store(0u, std::memory_order_relaxed); + } + } + + static FORCEINLINE int32 SlotIndex(uint32 KeyHash, int32 Way) + { + const int32 SetIndex = static_cast(KeyHash & (NumSets - 1)); + return SetIndex * NumWays + Way; + } + + bool TryFind( + const FVFRoomMouthMemoKey& InKey, + uint32 InKeyHash, + FVector& OutPoint, + bool& OutFound) + { + for (int32 Way = 0; Way < NumWays; ++Way) + { + FVFRoomMouthMemoSlot& Slot = Slots[SlotIndex(InKeyHash, Way)]; + if (Slot.KeyHash.load(std::memory_order_relaxed) != InKeyHash + || !Slot.TryAcquireRead()) + { + continue; + } + + bool bMatches = true; + for (int32 WordIndex = 0; + WordIndex < VF_RoomMouthKeyWordCount; + ++WordIndex) + { + if (Slot.Key.Words[WordIndex] != InKey.Words[WordIndex]) + { + bMatches = false; + break; + } + } + + if (bMatches) + { + OutPoint = Slot.Result; + OutFound = Slot.bFound; + Slot.ReleaseRead(); + return true; + } + Slot.ReleaseRead(); + } + return false; + } + + void Publish( + int32 SetIndex, + const FVFRoomMouthMemoKey& InKey, + uint32 InKeyHash, + bool bFound, + const FVector& Point) + { + int32 SelectedWay = INDEX_NONE; + for (int32 Way = 0; Way < NumWays; ++Way) + { + const FVFRoomMouthMemoSlot& Slot = Slots[SlotIndex(InKeyHash, Way)]; + if ((Slot.State.load(std::memory_order_acquire) + & FVFRoomMouthMemoSlot::ValidBit) == 0u) + { + SelectedWay = Way; + break; + } + } + if (SelectedWay == INDEX_NONE) + { + SelectedWay = static_cast( + ReplacementCursor[SetIndex].fetch_add( + 1u, std::memory_order_relaxed) + & (NumWays - 1)); + } + + FVFRoomMouthMemoSlot& Slot = Slots[SlotIndex(InKeyHash, SelectedWay)]; + const bool bWasValid = Slot.AcquireWrite(); + Slot.Key = InKey; + Slot.Result = Point; + Slot.bFound = bFound; + Slot.KeyHash.store(InKeyHash, std::memory_order_relaxed); + Slot.State.store(FVFRoomMouthMemoSlot::ValidBit, std::memory_order_release); + if (bWasValid) + { + Evictions.fetch_add(1u, std::memory_order_relaxed); + } + else + { + OccupiedEntries.fetch_add(1u, std::memory_order_relaxed); + } + } + + template + bool FindOrCompute( + const FVFRoomMouthMemoKey& InKey, + FVector& OutPoint, + ComputeFn&& Compute) + { + const uint32 KeyHash = VF_HashRoomMouthMemoKey(InKey); + Requests.fetch_add(1u, std::memory_order_relaxed); + + bool bFound = false; + FVector CachedPoint = FVector::ZeroVector; + if (TryFind(InKey, KeyHash, CachedPoint, bFound)) + { + OutPoint = CachedPoint; + Hits.fetch_add(1u, std::memory_order_relaxed); + return bFound; + } + + const int32 SetIndex = static_cast(KeyHash & (NumSets - 1)); + SetLocks[SetIndex].Lock(); + if (TryFind(InKey, KeyHash, CachedPoint, bFound)) + { + SetLocks[SetIndex].Unlock(); + OutPoint = CachedPoint; + Hits.fetch_add(1u, std::memory_order_relaxed); + return bFound; + } + + FVector ComputedPoint = FVector::ZeroVector; + bFound = Compute(ComputedPoint); + Searches.fetch_add(1u, std::memory_order_relaxed); + Misses.fetch_add(1u, std::memory_order_relaxed); + Publish(SetIndex, InKey, KeyHash, bFound, ComputedPoint); + SetLocks[SetIndex].Unlock(); + + OutPoint = ComputedPoint; + return bFound; + } + }; + + static FVFPlayerFitMemo GPlayerFitMemo; + static FVFRoomMouthMemo GRoomMouthMemo; + static TAutoConsoleVariable CVarVoxelForgePlayerFitMemo( + TEXT("voxel.PlayerFitMemo"), + 1, + TEXT("Memoize deterministic room player-fit points; 0 disables the memo."), + ECVF_Default); + bool VF_FindPlayerFitPointForRoomMemoized( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + float StrateTopZ, + float StrateBottomZ, + uint32 WarpSeed, + FVector& OutPoint) + { + if (CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread() == 0) + { + GPlayerFitMemo.Bypasses.fetch_add(1u, std::memory_order_relaxed); + return VF_FindPlayerFitPointForRoom( + Params, Site, StrateTopZ, StrateBottomZ, WarpSeed, OutPoint); + } + + const FVFPlayerFitMemoKey Key = VF_MakePlayerFitMemoKey( + Params, Site, StrateTopZ, StrateBottomZ, WarpSeed); + return GPlayerFitMemo.FindOrCompute( + Key, + OutPoint, + [&](FVector& ComputedPoint) + { + return VF_FindPlayerFitPointForRoom( + Params, + Site, + StrateTopZ, + StrateBottomZ, + WarpSeed, + ComputedPoint); + }); + } + + float VF_EvaluateSlabLandingDensity( + const FSlabGenerationParams& Params, + uint32 Seed, + float WorldX, + float WorldY, + float WorldZ) + { + const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + if (!(StrateHeight > 0.0f)) return -1.0f; + + const uint32 SeedU = Seed; + const float FloorZ = Params.StrateBottomWorldZ + + StrateHeight * Params.FloorRelativeHeight; + float FloorNoise = 0.0f; + if (Params.FloorRoughness > 0.0f) + { + FloorNoise = VoxelNoise::FBM( + WorldX * Params.FloorRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * Params.FloorRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 11.1f), + 0.0f, 3) * VOXEL_NOISE_SCALE * Params.FloorRoughness; + } + const float FloorSurface = FloorZ + FloorNoise; + + const float CeilZ = Params.StrateBottomWorldZ + + StrateHeight * Params.CeilingRelativeHeight; + float CeilNoise = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + const float RawNoise = VoxelNoise::FBM( + WorldX * Params.CeilingRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * Params.CeilingRoughnessFrequency + + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, + 3000.0f, 3) * VOXEL_NOISE_SCALE; + CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness; + } + const float CeilSurface = FMath::Max( + CeilZ - CeilNoise, FloorSurface + 2.0f); + float InternalDensity = -FMath::Min( + WorldZ - FloorSurface, CeilSurface - WorldZ); + + if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f) + { + float ColumnSDF = FLT_MAX; + const float Spacing = Params.ColumnSpacing; + const int32 CellX = FMath::FloorToInt(WorldX / Spacing); + const int32 CellY = FMath::FloorToInt(WorldY / Spacing); + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 ColumnX = CellX + DX; + const int32 ColumnY = CellY + DY; + const uint32 H = VoxelHash::Cell( + ColumnX, ColumnY, SeedU ^ 0xC01C01u); + if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; + const float JX = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float CentreX = (ColumnX + 0.15f + JX * 0.7f) * Spacing; + const float CentreY = (ColumnY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp( + Params.ColumnMinRadius, Params.ColumnMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + ColumnSDF = FMath::Min( + ColumnSDF, + FMath::Sqrt(FMath::Square(WorldX - CentreX) + + FMath::Square(WorldY - CentreY)) - Radius); + } + } + if (ColumnSDF < 2.0f && ColumnSDF < FLT_MAX) + { + float Fill = FMath::Clamp( + (2.0f - ColumnSDF) / 4.0f, 0.0f, 1.0f); + InternalDensity += SmoothStep01(Fill) + * Params.BaseDensity * 1.5f; + } + } + + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, Params.BoundarySealThickness, + Params.BaseDensity); + return -InternalDensity; + } + + float VF_EvaluateMazeLandingDensity( + const FMazeGenerationParams& Params, + uint32 Seed, + float WorldX, + float WorldY, + float WorldZ) + { + const float CellSize = FMath::Max(Params.CellSize, 1.0f); + const FVector Position(WorldX, WorldY, WorldZ); + const uint32 SeedU = Seed ^ 0x4D617A65u; + const int32 CellX = FMath::FloorToInt(WorldX / CellSize); + const int32 CellY = FMath::FloorToInt(WorldY / CellSize); + const int32 CellZ = FMath::FloorToInt(WorldZ / CellSize); + auto NodeCenter = [CellSize](int32 X, int32 Y, int32 Z) + { + return FVector( + (X + 0.5f) * CellSize, + (Y + 0.5f) * CellSize, + (Z + 0.5f) * CellSize); + }; + const float Radius = FMath::Max(Params.CorridorRadius, 0.5f); + float MazeSDF = FLT_MAX; + for (int32 DZ = -1; DZ <= 0; ++DZ) + { + for (int32 DY = -1; DY <= 0; ++DY) + { + for (int32 DX = -1; DX <= 0; ++DX) + { + const int32 X = CellX + DX; + const int32 Y = CellY + DY; + const int32 Z = CellZ + DZ; + const FVector A = NodeCenter(X, Y, Z); + if (VoxelMazeTopology::IsOpenEdge( + X, Y, Z, VoxelMazeTopology::EAxis::X, + SeedU, Params.BranchProbability, Params.Verticality)) + { + MazeSDF = FMath::Min( + MazeSDF, + VoxelSDF::Capsule( + Position, A, NodeCenter(X + 1, Y, Z), Radius)); + } + if (VoxelMazeTopology::IsOpenEdge( + X, Y, Z, VoxelMazeTopology::EAxis::Y, + SeedU, Params.BranchProbability, Params.Verticality)) + { + MazeSDF = FMath::Min( + MazeSDF, + VoxelSDF::Capsule( + Position, A, NodeCenter(X, Y + 1, Z), Radius)); + } + if (VoxelMazeTopology::IsOpenEdge( + X, Y, Z, VoxelMazeTopology::EAxis::Z, + SeedU, Params.BranchProbability, Params.Verticality)) + { + MazeSDF = FMath::Min( + MazeSDF, + VoxelSDF::Capsule( + Position, A, NodeCenter(X, Y, Z + 1), Radius)); + } + } + } + } + + if (Params.SurfaceRoughness > 0.0f + && MazeSDF < Radius + Params.SurfaceRoughness + 2.0f) + { + MazeSDF += VoxelNoise::FBM( + WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f, 3) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + + float InternalDensity = Params.BaseDensity; + if (MazeSDF < 2.0f) + { + float Carve = FMath::Clamp( + (2.0f - MazeSDF) / 4.0f, 0.0f, 1.0f); + InternalDensity -= SmoothStep01(Carve) + * Params.BaseDensity * 2.0f; + } + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, Params.BoundarySealThickness, + Params.BaseDensity); + return -InternalDensity; + } + + float VF_EvaluateShaftLandingDensity( + const FVerticalShaftParams& Params, + uint32 Seed, + float WorldX, + float WorldY, + float WorldZ) + { + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const int32 BaseCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 BaseCellY = FMath::FloorToInt(WorldY / Spacing); + const uint32 SeedU = Seed ^ 0x53686674u; + float CaveSDF = FLT_MAX; + FVector NearestCentre = FVector::ZeroVector; + float NearestDistanceSq = FLT_MAX; + float NearestRadius = 0.0f; + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 CellX = BaseCellX + DX; + const int32 CellY = BaseCellY + DY; + const uint32 H = VoxelHash::Cell(CellX, CellY, SeedU); + if (VoxelHash::ToFloat01(H) > Params.ShaftDensity) continue; + const float JX = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01( + VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float CentreX = (CellX + 0.15f + JX * 0.7f) * Spacing; + const float CentreY = (CellY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp( + Params.ShaftMinRadius, Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + const float DistanceSq = FMath::Square(WorldX - CentreX) + + FMath::Square(WorldY - CentreY); + if (DistanceSq < NearestDistanceSq) + { + NearestDistanceSq = DistanceSq; + NearestCentre = FVector(CentreX, CentreY, 0.0f); + NearestRadius = Radius; + } + CaveSDF = FMath::Min( + CaveSDF, + FMath::Sqrt(DistanceSq) - Radius); + } + } + + float InternalDensity = Params.BaseDensity; + if (Params.SurfaceRoughness > 0.0f + && CaveSDF < Params.SurfaceRoughness + 4.0f) + { + CaveSDF += VoxelNoise::FBM( + WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f, 3) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + if (CaveSDF < 2.0f) + { + float Carve = FMath::Clamp( + (2.0f - CaveSDF) / 4.0f, 0.0f, 1.0f); + InternalDensity -= SmoothStep01(Carve) + * Params.BaseDensity * 2.0f; + } + + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f + && CaveSDF < 0.0f && NearestDistanceSq < FLT_MAX) + { + const float Phase = FMath::Frac( + (WorldZ - Params.StrateBottomWorldZ) / Params.LedgeSpacing); + const float BandT = FMath::Min(Phase, 1.0f - Phase) + * Params.LedgeSpacing; + if (BandT < Params.LedgeDepth + && (WorldX - NearestCentre.X) + (WorldY - NearestCentre.Y) > 1.0e-3f) + { + const float Shelf = 1.0f - SmoothStep01( + BandT / Params.LedgeDepth); + InternalDensity = FMath::Max( + InternalDensity, Shelf * Params.BaseDensity); + } + } + VF_ApplyBoundarySeal( + InternalDensity, WorldZ, Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, Params.BoundarySealThickness, + Params.BaseDensity); + return -InternalDensity; + } + + // Find the nearest hash room's feature core without constructing a cache. The search is + // deliberately bounded: an empty neighbourhood is an honest "no answer", not a guessed point. + // The returned XY is the selected room centre; only a room core is a trustworthy sparse target. + // Cherche le centre vertical de la salle hachée la plus proche sans construire de cache. La + // recherche est bornée : un voisinage vide signifie "pas de réponse", jamais un point inventé. + bool VF_FindNearestHashRoomLandingPoint( + const FStrateGenerationParams& Params, + int32 RoomSeed, + int32 WarpSeed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float MaxLateralSnap, + FVector& OutPoint) + { + if (!VoxelMath::IsFinite(StrateTopZ) || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(MaxLateralSnap) || MaxLateralSnap < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + const float CellSize = Params.RoomSpacing; + const float RadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius); + if (!VoxelMath::IsFinite(CellSize) || !VoxelMath::IsFinite(Params.RoomDensity) + || !VoxelMath::IsFinite(Params.MinRoomRadius) || !VoxelMath::IsFinite(Params.MaxRoomRadius) + || !VoxelMath::IsFinite(Params.RoomHeightRatio) + || !VoxelMath::IsFinite(Params.BoundarySealThickness) + || !VoxelMath::IsFinite(Params.MaxTunnelLength) + || CellSize <= 0.0f || Params.RoomDensity <= 0.0f + || Params.MaxTunnelLength <= 0.0f + || RadiusEnvelope <= 0.0f || Params.RoomHeightRatio <= 0.0f + || Params.BoundarySealThickness < 0.0f) + { + return false; + } + + // PLACEMENT CONTRACT: this envelope MUST remain identical to the room-center placement + // code in VoxelCaveMorphology::BuildChunkCache below. The pure query deliberately does + // not call that per-chunk cache builder during Initialize, so this is intentionally a + // second copy. + // VoxelForge.Determinism.PassageLandsInOpenSpace is the fixed ring test that catches drift. + // The extra room-height buffer keeps the generated room body away from both seal bands. + const float RoomZBuffer = RadiusEnvelope * Params.RoomHeightRatio; + const float StrateMinZ = StrateBottomZ + Params.BoundarySealThickness + RoomZBuffer; + const float StrateMaxZ = StrateTopZ - Params.BoundarySealThickness - RoomZBuffer; + const float StrateRangeZ = StrateMaxZ - StrateMinZ; + if (!VoxelMath::IsFinite(StrateRangeZ) || StrateRangeZ <= 0.0f) + { + return false; + } + + // A passage is placed within the configured reach from the spine. Start the search a little + // farther than that reach, then expand deterministic rings until the nearest candidate is + // proven; cap pathological asset values so Initialize cannot become an unbounded grid scan. + const float SearchDistance = FMath::Max(CellSize * 2.0f, + FMath::Max(Params.MaxTunnelLength, 0.0f)); + const float SearchCells = SearchDistance / CellSize; + const int32 InitialSearchRadius = FMath::Min( + 32, + FMath::Max(2, FMath::CeilToInt(SearchCells) + 1)); + constexpr int32 MaxSearchRadius = 32; + + const int32 BaseCellX = FMath::FloorToInt(WorldX / CellSize); + const int32 BaseCellY = FMath::FloorToInt(WorldY / CellSize); + + float BestDistSq = FLT_MAX; + float BestZ = 0.0f; + float BestRoomX = 0.0f; + float BestRoomY = 0.0f; + float BestRadiusXY = 0.0f; + float BestRadiusZ = 0.0f; + uint32 BestHash = 0u; + int32 BestCellX = 0; + int32 BestCellY = 0; + bool bFound = false; + + struct FRoomCandidate + { + FVFRoomLandingSite Site; + float DistSq = FLT_MAX; + int32 CellX = 0; + int32 CellY = 0; + }; + TArray> Candidates; + + auto ConsiderCell = [&](int32 CellX, int32 CellY) + { + // Keep this roll byte-for-byte aligned with BuildChunkCache's room placement. + const uint32 CellHash = VoxelHash::Cell(CellX, CellY, (uint32)RoomSeed); + if (VoxelHash::ToFloat01(CellHash) >= Params.RoomDensity) return; + + const float JitterX = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x12345678u)); + const float JitterY = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u)); + const float JitterZ = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x55AA55AAu)); + + const float RoomX = (CellX + 0.15f + JitterX * 0.7f) * CellSize; + const float RoomY = (CellY + 0.15f + JitterY * 0.7f) * CellSize; + const float RoomZ = StrateMinZ + JitterZ * FMath::Max(StrateRangeZ, 1.0f); + const float DX = RoomX - WorldX; + const float DY = RoomY - WorldY; + const float DistSq = DX * DX + DY * DY; + + FRoomCandidate Candidate; + Candidate.Site = FVFRoomLandingSite{ + FVector(RoomX, RoomY, RoomZ), + 0.0f, + 0.0f, + CellHash, + false }; + const float CandidateSizeFactor = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + Candidate.Site.RadiusXY = FMath::Lerp( + FMath::Min(Params.MinRoomRadius, Params.MaxRoomRadius), + FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius), + CandidateSizeFactor); + Candidate.Site.RadiusZ = Candidate.Site.RadiusXY * Params.RoomHeightRatio; + Candidate.DistSq = DistSq; + Candidate.CellX = CellX; + Candidate.CellY = CellY; + Candidates.Add(Candidate); + + const bool bCloser = DistSq < BestDistSq; + const bool bTie = DistSq == BestDistSq + && (CellX < BestCellX || (CellX == BestCellX && CellY < BestCellY)); + if (!bCloser && !bTie) return; + + BestDistSq = DistSq; + BestZ = RoomZ; + BestRoomX = RoomX; + BestRoomY = RoomY; + BestRadiusXY = RadiusEnvelope; + const float SizeFactor = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + BestRadiusXY = FMath::Lerp( + FMath::Min(Params.MinRoomRadius, Params.MaxRoomRadius), + FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius), + SizeFactor); + BestRadiusZ = BestRadiusXY * Params.RoomHeightRatio; + BestHash = CellHash; + BestCellX = CellX; + BestCellY = CellY; + bFound = true; + }; + + auto ScanRing = [&](int32 Radius) + { + for (int32 CellY = BaseCellY - Radius; CellY <= BaseCellY + Radius; ++CellY) + { + for (int32 CellX = BaseCellX - Radius; CellX <= BaseCellX + Radius; ++CellX) + { + if (FMath::Max(FMath::Abs(CellX - BaseCellX), FMath::Abs(CellY - BaseCellY)) != Radius) + { + continue; + } + ConsiderCell(CellX, CellY); + } + } + }; + + // The nearest possible room centre in any cell outside this square is at least this far + // from the query point (the placement jitter is bounded to [0.15, 0.85] cell). + auto IsNearestProven = [&](int32 Radius) + { + if (!bFound) return false; + const float OutsideDistance = ((float)Radius + 0.15f) * CellSize; + return BestDistSq < OutsideDistance * OutsideDistance; + }; + + for (int32 CellY = BaseCellY - InitialSearchRadius; + CellY <= BaseCellY + InitialSearchRadius; + ++CellY) + { + for (int32 CellX = BaseCellX - InitialSearchRadius; + CellX <= BaseCellX + InitialSearchRadius; + ++CellX) + { + ConsiderCell(CellX, CellY); + } + } + + bool bNearestProven = IsNearestProven(InitialSearchRadius); + for (int32 Radius = InitialSearchRadius + 1; + Radius <= MaxSearchRadius && !bNearestProven; + ++Radius) + { + ScanRing(Radius); + bNearestProven = IsNearestProven(Radius); + } + + if (!bFound || !bNearestProven) return false; + + // The placement envelope above normally makes this automatic. Keep the explicit check so + // malformed but finite parameters never turn a boundary value into an open-point claim. + const float InnerTop = StrateTopZ - Params.BoundarySealThickness; + const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; + if (!VoxelMath::IsFinite(BestZ) || BestZ <= InnerBottom || BestZ >= InnerTop) + { + return false; + } + + // ⭐ Renvoyer le CENTRE de la salle, pas le XY demandé. + // + // La version précédente renvoyait FVector(WorldX, WorldY, BestZ) : le Z de la salle la plus + // proche, au XY de l'APPELANT. Un balayage d'un million de seeds a montré que ce point tombe + // HORS de la salle sélectionnée 92,8 % du temps — la requête trouvait une salle puis visait + // à côté. Le centre est intérieur PAR CONSTRUCTION, et c'est aussi le point qui garde la + // plus grande marge face au warp de production (qui déplace le champ d'environ 1,5 voxel : + // décisif au bord d'une salle, négligeable en son centre). + // + // Return the ROOM CENTRE, not the requested XY. The previous version returned the nearest + // room's Z at the CALLER's XY; a million-seed sweep put that point OUTSIDE the selected room + // 92.8% of the time — it found a room and then aimed beside it. The centre is inside by + // construction and holds the largest margin against the production cave warp (~1.5 voxels of + // displacement: decisive at a room's edge, negligible at its centre). + // + // ⚠️ Le budget est borné volontairement. Les passages sont placés à une distance délibérée + // de la spine (0,0) pour qu'un joueur perdu puisse les retrouver ; un snap non borné + // dissoudrait cette propriété en silence. Hors budget ⇒ on décline, et l'appelant garde son + // ancienne portée aléatoire. + // The budget is bounded on purpose: passages sit at a deliberate distance from the (0,0) + // spine so a lost player can find them, and an unbounded snap would dissolve that silently. + // Over budget => decline, and the caller keeps its old random reach. + const float MaxLateralSnapSq = MaxLateralSnap * MaxLateralSnap; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + // The room-centre estimate is analytic, but production evaluates the room after the + // bounded XYZ cave warp. A support stencil must be allowed to descend through that + // displacement as well as the authored floor relief; otherwise a valid room can be + // incorrectly downgraded to a random mouth whenever its shape bottom is more than one + // player step below the nominal cut. + const float FloorReliefBound = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float CaveWarpBound = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE; + const int32 ReliefSearch = FMath::Clamp( + FMath::CeilToInt(FloorReliefBound + CaveWarpBound) + 8, + 8, + 128); + FVoxelStrateMeasureSettings FitSettings; + + // The nearest room is preferred, but it is not allowed to turn a local relief/shape + // quirk into a random landing. Test every deterministic candidate inside the lateral + // budget, nearest first, and return only after the full source fit stencil succeeds. + Candidates.Sort([](const FRoomCandidate& A, const FRoomCandidate& B) + { + if (A.DistSq != B.DistSq) + { + return A.DistSq < B.DistSq; + } + return A.CellX != B.CellX ? A.CellX < B.CellX : A.CellY < B.CellY; + }); + + for (const FRoomCandidate& Candidate : Candidates) + { + if (Candidate.DistSq > MaxLateralSnapSq) + { + continue; + } + + const FVFRoomLandingSite& Site = Candidate.Site; + const float FloorZ = VF_RoomFloorZ(Site, static_cast(WarpSeed), Params); + const auto SampleRoom = [&](float X, float Y, float Z) + { + return VF_EvaluateRoomLandingDensity( + Site, Params, static_cast(WarpSeed), + StrateTopZ, StrateBottomZ, X, Y, Z); + }; + // Cave warp can move the unwarped room centre close to a wall. Probe a deterministic + // coarse disk around that centre, nearest first, while leaving the final decision to + // the exact capsule/stencil test. The disk stays well inside the authored room so it + // cannot silently turn an adjacent room into this room's landing. + const int32 LocalExtent = FMath::Max( + 2, + FMath::FloorToInt(FMath::Min( + Site.RadiusXY * 0.75f, + FMath::Max(CaveWarpBound + 4.0f, 8.0f)))); + TArray> LocalProbes; + for (int32 LocalY = -LocalExtent; LocalY <= LocalExtent; LocalY += 2) + { + for (int32 LocalX = -LocalExtent; LocalX <= LocalExtent; LocalX += 2) + { + if (static_cast(LocalX * LocalX + LocalY * LocalY) + > FMath::Square(Site.RadiusXY * 0.75f)) + { + continue; + } + LocalProbes.Add(FIntPoint(LocalX, LocalY)); + } + } + if ((LocalExtent & 1) != 0) + { + LocalProbes.Add(FIntPoint::ZeroValue); + } + LocalProbes.Sort([](const FIntPoint& A, const FIntPoint& B) + { + const int32 ADistanceSq = A.X * A.X + A.Y * A.Y; + const int32 BDistanceSq = B.X * B.X + B.Y * B.Y; + if (ADistanceSq != BDistanceSq) + { + return ADistanceSq < BDistanceSq; + } + return A.X != B.X ? A.X < B.X : A.Y < B.Y; + }); + + for (const FIntPoint& LocalProbe : LocalProbes) + { + const FVector CandidateFeetBase = VF_UnwarpCavePoint( + FVector( + Site.Center.X + static_cast(LocalProbe.X), + Site.Center.Y + static_cast(LocalProbe.Y), + FloorZ + MaxStepHeightVoxels + 0.5f), + Params, + static_cast(WarpSeed)); + + // The production room floor includes bounded hash relief and the remaining local + // warp variation. Search a deterministic vertical band around the analytic anchor + // so valid rooms are never downgraded merely because the centre estimate is off by + // a few voxels. + for (int32 OffsetIndex = 0; + OffsetIndex <= ReliefSearch * 2; + ++OffsetIndex) + { + const int32 SignedOffset = OffsetIndex == 0 + ? 0 + : ((OffsetIndex & 1) != 0 + ? -((OffsetIndex + 1) / 2) + : OffsetIndex / 2); + if (FMath::Abs(SignedOffset) > ReliefSearch) + { + continue; + } + const FVector CandidateFeet = CandidateFeetBase + + FVector(0.0f, 0.0f, static_cast(SignedOffset)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleRoom, + OutPoint)) + { + return true; + } + } + } + } + return false; + } + + // The slab source is an XY height band. Evaluate the same two pure height fields as + // FSlabVoidSource, then choose their midpoint after intersecting the seal-free interior. + // La source slab est une bande de hauteurs XY : on recalcule les deux champs purs comme + // FSlabVoidSource, puis on prend leur milieu après intersection avec l'intérieur sans seal. + bool VF_SuggestSlabLandingPoint( + const FSlabGenerationParams& Params, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float MaxLateralSnap, + FVector& OutPoint) + { + if (!VoxelMath::IsFinite(StrateTopZ) || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(MaxLateralSnap) || MaxLateralSnap < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + if (!VoxelMath::IsFinite(Params.FloorRelativeHeight) + || !VoxelMath::IsFinite(Params.CeilingRelativeHeight) + || !VoxelMath::IsFinite(Params.FloorRoughness) + || !VoxelMath::IsFinite(Params.FloorRoughnessFrequency) + || !VoxelMath::IsFinite(Params.CeilingRoughness) + || !VoxelMath::IsFinite(Params.CeilingRoughnessFrequency) + || !VoxelMath::IsFinite(Params.ColumnDensity) + || !VoxelMath::IsFinite(Params.ColumnMinRadius) + || !VoxelMath::IsFinite(Params.ColumnMaxRadius) + || !VoxelMath::IsFinite(Params.ColumnSpacing) + || !VoxelMath::IsFinite(Params.BoundarySealThickness) + || !VoxelMath::IsFinite(Params.BaseDensity) + || Params.BoundarySealThickness < 0.0f) + { + return false; + } + + const float StrateHeight = StrateTopZ - StrateBottomZ; + const uint32 SeedU = (uint32)Seed; + + const float FloorZ = StrateBottomZ + StrateHeight * Params.FloorRelativeHeight; + float FloorNoise = 0.0f; + if (Params.FloorRoughness > 0.0f) + { + const float FF = Params.FloorRoughnessFrequency; + FloorNoise = VoxelNoise::FBM( + WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), + 0.0f, 3 + ) * VOXEL_NOISE_SCALE * Params.FloorRoughness; + } + const float FloorSurface = FloorZ + FloorNoise; + + const float CeilZ = StrateBottomZ + StrateHeight * Params.CeilingRelativeHeight; + float CeilNoise = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + const float CF = Params.CeilingRoughnessFrequency; + const float RawNoise = VoxelNoise::FBM( + WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, + 3000.0f, 3 + ) * VOXEL_NOISE_SCALE; + CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness; + } + const float CeilSurface = FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f); + + // VF_ApplyBoundarySeal's bands are [Top-Thickness, Top) and + // (Bottom, Bottom+Thickness]. The midpoint below stays strictly inside both limits. + const float InnerTop = StrateTopZ - Params.BoundarySealThickness; + const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; + const float OpenBottom = FMath::Max(FloorSurface, InnerBottom); + const float OpenTop = FMath::Min(CeilSurface, InnerTop); + if (!VoxelMath::IsFinite(OpenBottom) || !VoxelMath::IsFinite(OpenTop) || OpenTop <= OpenBottom) + { + return false; + } + + // Slab columns are infinite-height. If one reaches this XY, no Z in the slab void is a + // confident pre-passage landing point, so report "unanswerable" instead of guessing. + if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f) + { + const float Spacing = Params.ColumnSpacing; + const int32 ColumnCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 ColumnCellY = FMath::FloorToInt(WorldY / Spacing); + + for (int32 DY = -1; DY <= 1; ++DY) + { + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 CellX = ColumnCellX + DX; + const int32 CellY = ColumnCellY + DY; + const uint32 H = VoxelHash::Cell(CellX, CellY, SeedU ^ 0xC01C01u); + if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float ColumnX = (CellX + 0.15f + JX * 0.7f) * Spacing; + const float ColumnY = (CellY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + const float ColumnDX = WorldX - ColumnX; + const float ColumnDY = WorldY - ColumnY; + if (FMath::Sqrt(ColumnDX * ColumnDX + ColumnDY * ColumnDY) - Radius < 2.0f) + { + return false; + } + } + } + } + + // The old query returned the centre of the void band. That is open air, but it is not a + // player pose: the body must stand on the floor. Re-run the pure slab source through the + // same support/capsule stencil used by the measurement and return the resolved feet row. + FSlabGenerationParams SourceParams = Params; + SourceParams.StrateTopWorldZ = StrateTopZ; + SourceParams.StrateBottomWorldZ = StrateBottomZ; + FVoxelStrateMeasureSettings FitSettings; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const FVector CandidateFeet( + WorldX, WorldY, OpenBottom + MaxStepHeightVoxels + 0.5f); + const auto SampleSlab = [&](float X, float Y, float Z) + { + return VF_EvaluateSlabLandingDensity( + SourceParams, static_cast(Seed), X, Y, Z); + }; + return VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + SourceParams.BoundarySealThickness, + SampleSlab, + OutPoint); + } + + // Maze corridors are lattice edges. Only a horizontal edge is a useful landing source: a + // vertical edge can prove air, but cannot prove a place to stand. The edge set and Z levels + // below use the same origin-directed tree + loop contract as FLatticeCorridorSource, with no + // cache. + bool VF_SuggestMazeLandingPoint( + const FMazeGenerationParams& Params, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float MaxLateralSnap, + FVector& OutPoint) + { + if (!VoxelMath::IsFinite(StrateTopZ) || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(MaxLateralSnap) || MaxLateralSnap < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + if (!VoxelMath::IsFinite(Params.CellSize) + || !VoxelMath::IsFinite(Params.CorridorRadius) + || !VoxelMath::IsFinite(Params.BranchProbability) + || !VoxelMath::IsFinite(Params.Verticality) + || !VoxelMath::IsFinite(Params.SurfaceRoughness) + || !VoxelMath::IsFinite(Params.BoundarySealThickness) + || !VoxelMath::IsFinite(Params.BaseDensity) + || Params.CellSize <= 0.0f + || Params.CorridorRadius <= 0.0f + || Params.BranchProbability < 0.0f || Params.BranchProbability > 1.0f + || Params.Verticality < 0.0f || Params.Verticality > 1.0f + || Params.SurfaceRoughness < 0.0f + || Params.BoundarySealThickness < 0.0f + || Params.BaseDensity <= 0.0f) + { + return false; + } + + const float CellSize = FMath::Max(Params.CellSize, 1.0f); + const float Radius = FMath::Max(Params.CorridorRadius, 0.5f); + // VoxelNoise::FBM has the proven absolute bound 1.5. This is the displacement that a + // corridor radius must survive; using the nominal [-1,1] label here would certify a + // landing inside a wall at the extremum. + const float RoughnessBound = Params.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f; + + // A strict interior of the tube is needed. At the MC zero surface the source's smooth + // carve is only half applied, so stopping at the nominal radius would be an air guess. + const float SafeRadius = Radius - RoughnessBound - 0.25f; + if (!VoxelMath::IsFinite(SafeRadius) || SafeRadius <= 0.0f) + { + return false; + } + + const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; + const float InnerTop = StrateTopZ - Params.BoundarySealThickness; + if (!VoxelMath::IsFinite(InnerBottom) || !VoxelMath::IsFinite(InnerTop) + || InnerBottom >= InnerTop) + { + return false; + } + + // A node centre must leave the corridor radius above the lower interior boundary. The + // explicit checks in the loop keep ceil/floor edge cases strict after float rounding. + const int32 MinNodeZ = FMath::CeilToInt( + (InnerBottom + Radius) / CellSize - 0.5f); + const int32 MaxNodeZ = FMath::FloorToInt( + InnerTop / CellSize - 0.5f); + constexpr int32 MaxNodeLevels = 256; + if (MaxNodeZ < MinNodeZ || MaxNodeZ - MinNodeZ >= MaxNodeLevels) + { + return false; + } + + const int32 BaseCellX = FMath::FloorToInt(WorldX / CellSize); + const int32 BaseCellY = FMath::FloorToInt(WorldY / CellSize); + const uint32 SeedU = (uint32)Seed ^ 0x4D617A65u; // 'Maze' + const float MaxSnapSq = MaxLateralSnap * MaxLateralSnap; + + float BestDistSq = FLT_MAX; + float BestX = 0.0f; + float BestY = 0.0f; + float BestZ = 0.0f; + int32 BestCellX = INT32_MAX; + int32 BestCellY = INT32_MAX; + int32 BestNodeZ = INT32_MAX; + int32 BestAxis = INT32_MAX; + bool bFound = false; + + auto ConsiderEdge = [&](int32 CellX, int32 CellY, int32 CellZ, int32 Axis) + { + const VoxelMazeTopology::EAxis EdgeAxis = + static_cast(Axis); + if (!VoxelMazeTopology::IsOpenEdge( + CellX, CellY, CellZ, EdgeAxis, SeedU, + Params.BranchProbability, Params.Verticality)) + { + return; + } + + const float NodeX = (CellX + 0.5f) * CellSize; + const float NodeY = (CellY + 0.5f) * CellSize; + const float NodeZWorld = (CellZ + 0.5f) * CellSize; + if (NodeZWorld - Radius <= InnerBottom || NodeZWorld >= InnerTop) + { + return; + } + + float ClosestX = NodeX; + float ClosestY = NodeY; + if (Axis == 0) + { + ClosestX = FMath::Clamp(WorldX, NodeX, NodeX + CellSize); + } + else + { + ClosestY = FMath::Clamp(WorldY, NodeY, NodeY + CellSize); + } + + const float DX = WorldX - ClosestX; + const float DY = WorldY - ClosestY; + const float DistSq = DX * DX + DY * DY; + const bool bCloser = DistSq < BestDistSq; + const bool bTie = DistSq == BestDistSq + && (CellZ < BestNodeZ + || (CellZ == BestNodeZ + && (CellX < BestCellX + || (CellX == BestCellX + && (CellY < BestCellY + || (CellY == BestCellY && Axis < BestAxis)))))); + if (!bCloser && !bTie) return; + + BestDistSq = DistSq; + BestX = ClosestX; + BestY = ClosestY; + BestZ = NodeZWorld; + BestCellX = CellX; + BestCellY = CellY; + BestNodeZ = CellZ; + BestAxis = Axis; + bFound = true; + }; + + // A candidate edge can be one cell beyond the requested point when the point is near a + // cell boundary. The two-cell guard band covers the segment endpoint and the one-cell + // lateral budget without turning this into an unbounded lattice scan. + const int32 SearchRadius = FMath::CeilToInt(MaxLateralSnap / CellSize) + 2; + for (int32 CellZ = MinNodeZ; CellZ <= MaxNodeZ; ++CellZ) + { + for (int32 CellY = BaseCellY - SearchRadius; CellY <= BaseCellY + SearchRadius; ++CellY) + { + for (int32 CellX = BaseCellX - SearchRadius; CellX <= BaseCellX + SearchRadius; ++CellX) + { + ConsiderEdge(CellX, CellY, CellZ, 0); + ConsiderEdge(CellX, CellY, CellZ, 1); + } + } + } + + if (!bFound || BestDistSq > MaxSnapSq || !VoxelMath::IsFinite(BestX) + || !VoxelMath::IsFinite(BestY) || !VoxelMath::IsFinite(BestZ)) + { + return false; + } + + FMazeGenerationParams SourceParams = Params; + SourceParams.StrateTopWorldZ = StrateTopZ; + SourceParams.StrateBottomWorldZ = StrateBottomZ; + FVoxelStrateMeasureSettings FitSettings; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const auto SampleMaze = [&](float X, float Y, float Z) + { + return VF_EvaluateMazeLandingDensity( + SourceParams, static_cast(Seed), X, Y, Z); + }; + + // The nearest point on a lattice edge can be its spherical node cap, where the support + // patch is narrower than the same edge's middle. Try a few deterministic points along + // the selected horizontal edge, then a small vertical anchor band. Every trial remains + // inside the original lateral budget and is checked by the exact player stencil. + const float NodeX = (BestCellX + 0.5f) * CellSize; + const float NodeY = (BestCellY + 0.5f) * CellSize; + constexpr int32 EdgeSamples = 9; + const int32 VerticalOffsets[] = { 0, 1, -1, 2, -2, 3, -3, 4, 5, 6 }; + for (int32 EdgeSample = 0; EdgeSample < EdgeSamples; ++EdgeSample) + { + const float T = static_cast(EdgeSample) / (EdgeSamples - 1); + const float CandidateX = BestAxis == 0 ? NodeX + T * CellSize : NodeX; + const float CandidateY = BestAxis == 1 ? NodeY + T * CellSize : NodeY; + const float LateralDX = CandidateX - WorldX; + const float LateralDY = CandidateY - WorldY; + if (LateralDX * LateralDX + LateralDY * LateralDY > MaxSnapSq) + { + continue; + } + + for (const int32 VerticalOffset : VerticalOffsets) + { + const FVector CandidateFeet( + CandidateX, + CandidateY, + BestZ - Radius + RoughnessBound + + MaxStepHeightVoxels + 0.5f + + static_cast(VerticalOffset)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + SourceParams.BoundarySealThickness, + SampleMaze, + OutPoint)) + { + return true; + } + } + } + return false; + } + + // VerticalShafts has a guaranteed floor only when its lower boundary seal exists. Select a + // point above that seal and outside every ledge band; an infinite shaft by itself is not a + // standing point. The 3x3 roll is deliberately the same local source neighbourhood as the + // production density function. + bool VF_SuggestVerticalShaftLandingPoint( + const FVerticalShaftParams& Params, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float MaxLateralSnap, + FVector& OutPoint) + { + if (!VoxelMath::IsFinite(StrateTopZ) || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(MaxLateralSnap) || MaxLateralSnap < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + if (!VoxelMath::IsFinite(Params.ShaftSpacing) + || !VoxelMath::IsFinite(Params.ShaftDensity) + || !VoxelMath::IsFinite(Params.ShaftMinRadius) + || !VoxelMath::IsFinite(Params.ShaftMaxRadius) + || !VoxelMath::IsFinite(Params.CrossConnectChance) + || !VoxelMath::IsFinite(Params.ConnectorRadius) + || !VoxelMath::IsFinite(Params.LedgeSpacing) + || !VoxelMath::IsFinite(Params.LedgeDepth) + || !VoxelMath::IsFinite(Params.SurfaceRoughness) + || !VoxelMath::IsFinite(Params.BoundarySealThickness) + || !VoxelMath::IsFinite(Params.BaseDensity) + || Params.ShaftSpacing <= 0.0f + || Params.ShaftDensity <= 0.0f || Params.ShaftDensity > 1.0f + || Params.ShaftMinRadius <= 0.0f + || Params.ShaftMaxRadius < Params.ShaftMinRadius + || Params.CrossConnectChance < 0.0f || Params.CrossConnectChance > 1.0f + || Params.ConnectorRadius <= 0.0f + || Params.LedgeSpacing < 0.0f || Params.LedgeDepth < 0.0f + || Params.SurfaceRoughness < 0.0f + || Params.BoundarySealThickness <= 0.0f + || Params.BaseDensity <= 0.0f) + { + return false; + } + + const float RoughnessBound = Params.SurfaceRoughness * VOXEL_NOISE_SCALE; + const float ShaftInteriorMargin = RoughnessBound + 0.25f; + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const int32 BaseCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 BaseCellY = FMath::FloorToInt(WorldY / Spacing); + const uint32 SeedU = (uint32)Seed ^ 0x53686674u; // 'Shft' + + const float MaxSnapSq = MaxLateralSnap * MaxLateralSnap; + const int32 SearchRadius = FMath::CeilToInt( + (MaxLateralSnap + FMath::Max(Params.ShaftMaxRadius, 1.0f)) / Spacing) + 2; + + float BestDistSq = FLT_MAX; + float BestAxisX = 0.0f; + float BestAxisY = 0.0f; + float BestSafeRadius = 0.0f; + bool bFound = false; + int32 BestCellX = INT32_MAX; + int32 BestCellY = INT32_MAX; + for (int32 DY = -SearchRadius; DY <= SearchRadius; ++DY) + { + for (int32 DX = -SearchRadius; DX <= SearchRadius; ++DX) + { + const int32 CellX = BaseCellX + DX; + const int32 CellY = BaseCellY + DY; + const uint32 H = VoxelHash::Cell(CellX, CellY, SeedU); + if (VoxelHash::ToFloat01(H) > Params.ShaftDensity) continue; + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + const float ShaftX = (CellX + 0.15f + JX * 0.7f) * Spacing; + const float ShaftY = (CellY + 0.15f + JY * 0.7f) * Spacing; + const float Radius = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + const float SafeRadius = Radius - ShaftInteriorMargin; + if (!VoxelMath::IsFinite(SafeRadius) || SafeRadius <= 0.0f) + { + continue; + } + + const float DXWorld = WorldX - ShaftX; + const float DYWorld = WorldY - ShaftY; + const float Distance = FMath::Sqrt(DXWorld * DXWorld + DYWorld * DYWorld); + const float SnapDistance = FMath::Max(Distance - SafeRadius, 0.0f); + const float DistSq = SnapDistance * SnapDistance; + const bool bCloser = DistSq < BestDistSq; + const bool bTie = DistSq == BestDistSq + && (CellX < BestCellX || (CellX == BestCellX && CellY < BestCellY)); + if (!bCloser && !bTie) + { + continue; + } + + // A VerticalShafts landing must identify the topology, not merely an open point + // inside its radius. The safe-radius distance still chooses the nearest confident + // site under the existing snap budget. The candidate offsets below stay inside + // this same feature core while allowing the generated ledge to provide a real + // walkable support patch; the exact shaft axis is an open cylinder and has no + // support surface by itself. + BestAxisX = ShaftX; + BestAxisY = ShaftY; + BestSafeRadius = SafeRadius; + BestDistSq = DistSq; + BestCellX = CellX; + BestCellY = CellY; + bFound = true; + } + } + + if (!bFound || BestDistSq > MaxSnapSq || !VoxelMath::IsFinite(BestAxisX) + || !VoxelMath::IsFinite(BestAxisY)) + { + return false; + } + + FVerticalShaftParams SourceParams = Params; + SourceParams.StrateTopWorldZ = StrateTopZ; + SourceParams.StrateBottomWorldZ = StrateBottomZ; + + FVoxelStrateMeasureSettings FitSettings; + float InnerTop = 0.0f; + float InnerBottom = 0.0f; + if (!VF_GetPlayerFitInteriorBounds( + FitSettings, StrateTopZ, StrateBottomZ, + Params.BoundarySealThickness, InnerTop, InnerBottom)) + { + return false; + } + const auto SampleShaft = [&](float X, float Y, float Z) + { + return VF_EvaluateShaftLandingDensity( + SourceParams, static_cast(Seed), X, Y, Z); + }; + + // The query's default interior margin is the same 2x-seal margin used by the exact + // player-fit measurement. This prevents a lower boundary seal from being reported as a + // standing floor that the measured window intentionally excludes. + const float FirstCandidateZ = InnerBottom + 0.5f; + const float LastCandidateZ = InnerTop + - 2.0f * FitSettings.PlayerCapsuleHalfHeightVoxels - 0.5f; + if (!VoxelMath::IsFinite(FirstCandidateZ) || !VoxelMath::IsFinite(LastCandidateZ) + || LastCandidateZ < FirstCandidateZ) + { + return false; + } + + const int32 NumCandidateSteps = FMath::Min( + 4096, + FMath::Max(0, FMath::CeilToInt(LastCandidateZ - FirstCandidateZ))); + + const FVector2D CandidateOffsets[] = { + FVector2D(0.0f, 0.0f), + FVector2D(0.20f, 0.20f), + FVector2D(0.35f, 0.35f), + FVector2D(0.50f, 0.15f), + FVector2D(0.15f, 0.50f), + FVector2D(0.55f, 0.35f), + FVector2D(0.35f, 0.55f), + }; + for (const FVector2D& NormalizedOffset : CandidateOffsets) + { + const float CandidateX = BestAxisX + + NormalizedOffset.X * BestSafeRadius; + const float CandidateY = BestAxisY + + NormalizedOffset.Y * BestSafeRadius; + const float OffsetSq = FMath::Square(CandidateX - BestAxisX) + + FMath::Square(CandidateY - BestAxisY); + const float LateralDX = CandidateX - WorldX; + const float LateralDY = CandidateY - WorldY; + const float LateralDistanceSq = FMath::Square(LateralDX) + + FMath::Square(LateralDY); + if (!VoxelMath::IsFinite(CandidateX) || !VoxelMath::IsFinite(CandidateY) + || !VoxelMath::IsFinite(LateralDistanceSq) + || OffsetSq > FMath::Square(BestSafeRadius) + || LateralDistanceSq > MaxSnapSq + KINDA_SMALL_NUMBER) + { + continue; + } + + for (int32 CandidateStep = 0; + CandidateStep <= NumCandidateSteps; + ++CandidateStep) + { + const FVector CandidateFeet( + CandidateX, CandidateY, + FirstCandidateZ + static_cast(CandidateStep)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleShaft, + OutPoint)) + { + return true; + } + } + } + return false; + } + + struct FVFIslandSite + { + int32 CellX, CellY; + float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; + }; + + // Evaluate only the island source, in the same internal-density convention as + // FIslandBlobSource (positive = solid, negative = void). This is intentionally a + // local pure evaluator: it lets the query bracket the actual blob top instead of treating a + // nominal island centre as footing. Full-octave noise is used because this query has no LOD + // state by design and GeneratePassages runs before a tile's LOD is selected. + float VF_EvaluateIslandInteriorDensity( + const FFloatingIslandParams& Params, + uint32 Seed, + const FVFIslandSite* Islands, + int32 NumIslands, + float WorldX, + float WorldY, + float WorldZ) + { + const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f; + const FVector WarpXPosition( + WorldX * 0.04f + VoxelHash::SeedOffset(Seed, 0.0007f), + WorldY * 0.04f, + WorldZ * 0.012f); + const FVector WarpYPosition( + WorldX * 0.04f + 31.0f, + WorldY * 0.04f + 7.0f, + WorldZ * 0.012f); + const float WX = WorldX + VoxelNoise::FBM( + (float)WarpXPosition.X, (float)WarpXPosition.Y, (float)WarpXPosition.Z, 3) + * VOXEL_NOISE_SCALE * WarpAmp; + const float WY = WorldY + VoxelNoise::FBM( + (float)WarpYPosition.X, (float)WarpYPosition.Y, (float)WarpYPosition.Z, 3) + * VOXEL_NOISE_SCALE * WarpAmp; + + const float BlendK = FMath::Max(Params.SDFBlendRadius, 0.01f); + const float Spacing = FMath::Max(Params.IslandSpacing, 1.0f); + const int32 BaseCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 BaseCellY = FMath::FloorToInt(WorldY / Spacing); + float IslandSDF = FLT_MAX; + for (int32 IslandIndex = 0; IslandIndex < NumIslands; ++IslandIndex) + { + const FVFIslandSite& Island = Islands[IslandIndex]; + // Production FIslandBlobSource evaluates exactly this 3x3 cell neighbourhood + // for the queried XY. The outer search may collect more candidates so a lateral snap + // can be found, but those distant blobs must not influence the returned footing. + if (FMath::Abs(Island.CellX - BaseCellX) > 1 + || FMath::Abs(Island.CellY - BaseCellY) > 1) + { + continue; + } + const float DX = WX - Island.X; + const float DY = WY - Island.Y; + const float DistXY = FMath::Sqrt(DX * DX + DY * DY); + const float Height = FMath::Clamp( + (WorldZ - Island.BotZ) / FMath::Max(Island.TopZ - Island.BotZ, 1.0f), + 0.0f, 1.0f); + const float Taper = SmoothStep01(FMath::Clamp( + Height / Island.TaperEnd, 0.0f, 1.0f)); + const float Envelope = Island.Rxy * Taper; + + float TopSurface = Island.TopZ; + if (Params.TopFlatten < 1.0f) + { + const float Edge = FMath::Clamp( + DistXY / FMath::Max(Island.Rxy, 1.0f), 0.0f, 1.0f); + TopSurface = Island.TopZ + - (1.0f - Params.TopFlatten) * Island.TopHalf * 2.0f * Edge * Edge; + } + + const float SDF = FMath::Max(DistXY - Envelope, WorldZ - TopSurface); + IslandSDF = VoxelSDF::SmoothMin(IslandSDF, SDF, BlendK); + } + + if (Params.SurfaceRoughness > 0.0f + && IslandSDF < Params.SurfaceRoughness + BlendK + 2.0f) + { + const FVector RoughnessPosition(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f); + IslandSDF += VoxelNoise::FBM( + (float)RoughnessPosition.X, (float)RoughnessPosition.Y, + (float)RoughnessPosition.Z, 4) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + + float Density = -Params.BaseDensity; + if (IslandSDF < BlendK) + { + float Fill = FMath::Clamp( + (BlendK - IslandSDF) / (BlendK * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + Density += Fill * Params.BaseDensity * 2.0f; + } + return Density; + } + + bool VF_SuggestFloatingIslandLandingPoint( + const FFloatingIslandParams& Params, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float WorldX, + float WorldY, + float MaxLateralSnap, + FVector& OutPoint) + { + if (!VoxelMath::IsFinite(StrateTopZ) || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(MaxLateralSnap) || MaxLateralSnap < 0.0f + || StrateTopZ <= StrateBottomZ) + { + return false; + } + + if (!VoxelMath::IsFinite(Params.IslandSpacing) + || !VoxelMath::IsFinite(Params.IslandDensity) + || !VoxelMath::IsFinite(Params.IslandMinRadius) + || !VoxelMath::IsFinite(Params.IslandMaxRadius) + || !VoxelMath::IsFinite(Params.ThicknessRatio) + || !VoxelMath::IsFinite(Params.VerticalJitter) + || !VoxelMath::IsFinite(Params.TopFlatten) + || !VoxelMath::IsFinite(Params.SurfaceRoughness) + || !VoxelMath::IsFinite(Params.SDFBlendRadius) + || !VoxelMath::IsFinite(Params.BoundarySealThickness) + || !VoxelMath::IsFinite(Params.BaseDensity) + || Params.IslandSpacing <= 0.0f + || Params.IslandDensity <= 0.0f || Params.IslandDensity > 1.0f + || Params.IslandMinRadius <= 0.0f + || Params.IslandMaxRadius < Params.IslandMinRadius + || Params.ThicknessRatio < 0.0f + || Params.VerticalJitter < 0.0f + || Params.SurfaceRoughness < 0.0f + || Params.BoundarySealThickness < 0.0f + || Params.BaseDensity <= 0.0f) + { + return false; + } + + const float H = StrateTopZ - StrateBottomZ; + const float Spacing = FMath::Max(Params.IslandSpacing, 1.0f); + const uint32 SeedU = (uint32)Seed ^ 0x49736C64u; // 'Isld' + const float MidZ = (StrateTopZ + StrateBottomZ) * 0.5f; + + const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f; + const float WarpBound = WarpAmp * VOXEL_NOISE_SCALE; + const float MaxIslandRadius = FMath::Max(Params.IslandMaxRadius, 1.0f); + const float SearchReach = MaxLateralSnap + MaxIslandRadius + WarpBound + 2.0f; + const int32 SearchRadius = FMath::CeilToInt(SearchReach / Spacing) + 2; + const int32 BaseCellX = FMath::FloorToInt(WorldX / Spacing); + const int32 BaseCellY = FMath::FloorToInt(WorldY / Spacing); + + TArray> Islands; + Islands.Reserve((2 * SearchRadius + 1) * (2 * SearchRadius + 1)); + float MinBotZ = FLT_MAX; + float MaxTopZ = -FLT_MAX; + for (int32 DY = -SearchRadius; DY <= SearchRadius; ++DY) + { + for (int32 DX = -SearchRadius; DX <= SearchRadius; ++DX) + { + const int32 CellX = BaseCellX + DX; + const int32 CellY = BaseCellY + DY; + const uint32 Hh = VoxelHash::Cell(CellX, CellY, SeedU); + if (VoxelHash::ToFloat01(Hh) > Params.IslandDensity) continue; + + FVFIslandSite& Island = Islands.AddDefaulted_GetRef(); + Island.CellX = CellX; + Island.CellY = CellY; + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + Island.X = (CellX + 0.15f + JX * 0.7f) * Spacing; + Island.Y = (CellY + 0.15f + JY * 0.7f) * Spacing; + Island.Rxy = FMath::Lerp(Params.IslandMinRadius, Params.IslandMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au))); + Island.TopHalf = Island.Rxy * 0.20f; + const float UnderDepth = Island.Rxy * FMath::Max(Params.ThicknessRatio, 0.25f); + const float SpreadZ = FMath::Max( + H * 0.5f - FMath::Max(Island.TopHalf, UnderDepth) + - Params.BoundarySealThickness, 0.0f) * Params.VerticalJitter; + const float CenterZ = MidZ + + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ; + Island.TopZ = CenterZ + Island.TopHalf; + Island.BotZ = CenterZ - UnderDepth; + Island.TaperEnd = FMath::Lerp(0.45f, 0.7f, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu))); + + MinBotZ = FMath::Min(MinBotZ, Island.BotZ); + MaxTopZ = FMath::Max(MaxTopZ, Island.TopZ); + } + } + + if (Islands.Num() == 0 || !VoxelMath::IsFinite(MinBotZ) || !VoxelMath::IsFinite(MaxTopZ)) + { + return false; + } + + const float RoughnessBound = Params.SurfaceRoughness * VOXEL_NOISE_SCALE; + const float BlendK = FMath::Max(Params.SDFBlendRadius, 0.01f); + const float SearchPad = RoughnessBound + BlendK + 3.0f; + const float SearchTop = MaxTopZ + SearchPad; + const float SearchBottom = MinBotZ - SearchPad; + constexpr float ScanStep = 0.5f; + constexpr int32 MaxVerticalSamples = 2048; + if (!VoxelMath::IsFinite(SearchTop) || !VoxelMath::IsFinite(SearchBottom) + || SearchTop <= SearchBottom + || (SearchTop - SearchBottom) / ScanStep > (float)MaxVerticalSamples) + { + return false; + } + + const float MaxSnapSq = MaxLateralSnap * MaxLateralSnap; + float BestDistSq = FLT_MAX; + FVector BestPoint = FVector::ZeroVector; + int32 BestIslandIndex = INDEX_NONE; + FVoxelStrateMeasureSettings FitSettings; + + auto FindLandingZ = [&](float CandidateX, float CandidateY, float& OutLandingZ) -> bool + { + float UpperZ = SearchTop; + float UpperDensity = VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), CandidateX, CandidateY, UpperZ); + if (!VoxelMath::IsFinite(UpperDensity) || UpperDensity > 0.0f) + { + return false; + } + + for (int32 Sample = 0; Sample < MaxVerticalSamples && UpperZ > SearchBottom; ++Sample) + { + const float LowerZ = FMath::Max(SearchBottom, UpperZ - ScanStep); + const float LowerDensity = VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), CandidateX, CandidateY, LowerZ); + if (!VoxelMath::IsFinite(LowerDensity)) return false; + + // Descending from guaranteed air, the first solid bracket is the highest actual + // blob top at this XY. That is the footing surface; no island-centre guess is + // involved. + if (UpperDensity <= 0.0f && LowerDensity > 0.0f) + { + float SolidZ = LowerZ; + float AirZ = UpperZ; + for (int32 Iteration = 0; Iteration < 12; ++Iteration) + { + const float Mid = (SolidZ + AirZ) * 0.5f; + const float MidDensity = VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), CandidateX, CandidateY, Mid); + if (!VoxelMath::IsFinite(MidDensity)) return false; + if (MidDensity > 0.0f) SolidZ = Mid; + else AirZ = Mid; + } + + const float InnerBottom = StrateBottomZ + Params.BoundarySealThickness; + const float InnerTop = StrateTopZ - Params.BoundarySealThickness; + const float LandingZ = AirZ + 0.5f; + if (!VoxelMath::IsFinite(LandingZ) + || LandingZ <= InnerBottom || LandingZ >= InnerTop + || VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), CandidateX, CandidateY, LandingZ) > 0.0f + || VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), CandidateX, CandidateY, SolidZ) <= 0.0f + || VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), CandidateX, CandidateY, LandingZ - 1.0f) <= 0.0f) + { + return false; + } + + OutLandingZ = LandingZ; + return VoxelMath::IsFinite(OutLandingZ); + } + + UpperZ = LowerZ; + UpperDensity = LowerDensity; + } + + return false; + }; + + const auto SampleIsland = [&](float X, float Y, float Z) + { + // Island density uses the opposite convention internally (positive = solid). + return -VF_EvaluateIslandInteriorDensity( + Params, SeedU, Islands.GetData(), Islands.Num(), X, Y, Z); + }; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const FIntPoint CoreOffsets[] = { + FIntPoint(0, 0), + FIntPoint(1, 0), FIntPoint(-1, 0), + FIntPoint(0, 1), FIntPoint(0, -1), + FIntPoint(2, 0), FIntPoint(-2, 0), + FIntPoint(0, 2), FIntPoint(0, -2), + FIntPoint(1, 1), FIntPoint(1, -1), + FIntPoint(-1, 1), FIntPoint(-1, -1), + FIntPoint(3, 0), FIntPoint(-3, 0), + FIntPoint(0, 3), FIntPoint(0, -3) + }; + const float FeetOffsets[] = { 0.0f, -1.0f, 1.0f, -2.0f, 2.0f, 3.0f }; + + for (int32 IslandIndex = 0; IslandIndex < Islands.Num(); ++IslandIndex) + { + const FVFIslandSite& Island = Islands[IslandIndex]; + // Reserve a conservative interior disk. WarpBound protects the source's lobed frame; + // the one-voxel margin keeps a returned top away from the nominal radial edge. If no + // such disk is within MaxLateralSnap, declining is preferable to an arbitrary jump. + const float SafeRadius = Island.Rxy - WarpBound - 1.0f; + if (!VoxelMath::IsFinite(SafeRadius) || SafeRadius <= 0.0f) continue; + + const float DX = WorldX - Island.X; + const float DY = WorldY - Island.Y; + const float Distance = FMath::Sqrt(DX * DX + DY * DY); + const float SnapDistance = FMath::Max(Distance - SafeRadius, 0.0f); + const float DistSq = SnapDistance * SnapDistance; + if (DistSq > MaxSnapSq) continue; + + float CandidateX = WorldX; + float CandidateY = WorldY; + // Prefer the feature core whenever it fits the lateral budget. A point near the + // nominal blob rim can be open air yet fail the support patch; the deterministic + // centre is the island's large, stable landing feature. + if (Distance <= MaxLateralSnap) + { + CandidateX = Island.X; + CandidateY = Island.Y; + } + else if (Distance > SafeRadius && Distance > KINDA_SMALL_NUMBER) + { + const float Scale = SafeRadius / Distance; + CandidateX = Island.X + DX * Scale; + CandidateY = Island.Y + DY * Scale; + } + + const float CandidateDX = CandidateX - WorldX; + const float CandidateDY = CandidateY - WorldY; + const float CandidateDistSq = + CandidateDX * CandidateDX + CandidateDY * CandidateDY; + if (CandidateDistSq > MaxSnapSq) + { + continue; + } + + // A top surface is only a landing site if the complete player stencil can occupy the + // air above it and the support patch is walkable. The core offsets are a bounded, + // deterministic local search for a broad patch; they are not a component query. + for (const FIntPoint& CoreOffset : CoreOffsets) + { + const float TrialX = CandidateX + static_cast(CoreOffset.X); + const float TrialY = CandidateY + static_cast(CoreOffset.Y); + const float TrialDX = TrialX - Island.X; + const float TrialDY = TrialY - Island.Y; + if (TrialDX * TrialDX + TrialDY * TrialDY + > FMath::Square(SafeRadius) + KINDA_SMALL_NUMBER) + { + continue; + } + const float LateralDX = TrialX - WorldX; + const float LateralDY = TrialY - WorldY; + const float TrialDistSq = LateralDX * LateralDX + LateralDY * LateralDY; + if (TrialDistSq > MaxSnapSq) continue; + + float CandidateZ = 0.0f; + if (!FindLandingZ(TrialX, TrialY, CandidateZ)) continue; + for (const float FeetOffset : FeetOffsets) + { + FVector FitPoint = FVector::ZeroVector; + if (!VF_ValidatePlayerFitPose( + FitSettings, + FVector(TrialX, TrialY, + CandidateZ + FeetOffset + MaxStepHeightVoxels), + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleIsland, + FitPoint)) + { + continue; + } + + const bool bCloser = TrialDistSq < BestDistSq; + const bool bTie = TrialDistSq == BestDistSq + && IslandIndex < BestIslandIndex; + if (!bCloser && !bTie) continue; + + BestDistSq = TrialDistSq; + BestPoint = FitPoint; + BestIslandIndex = IslandIndex; + } + } + } + + if (BestIslandIndex == INDEX_NONE || BestDistSq > MaxSnapSq || BestPoint.ContainsNaN()) + { + return false; + } + + OutPoint = BestPoint; + return VoxelMath::IsFinite(OutPoint.X) && VoxelMath::IsFinite(OutPoint.Y) + && VoxelMath::IsFinite(OutPoint.Z); + } +} + +namespace +{ + constexpr float VF_LandingCarveSafetyMargin = + VoxelPassageGeometry::SealSafetyMarginVoxels; +} + +#if WITH_EDITOR +void VoxelCaveMorphology::SetResearchBrickBinningEnabled(bool bEnabled) +{ + GVoxelResearchBrickBinningEnabled = bEnabled; +} + +bool VoxelCaveMorphology::IsResearchBrickBinningEnabled() +{ + return GVoxelResearchBrickBinningEnabled; +} + +void VoxelCaveMorphology::SetResearchBrickRoomReachScale(float Scale) +{ + GVoxelResearchBrickRoomReachScale = VoxelMath::IsFinite(Scale) + ? FMath::Clamp(Scale, 0.0f, 1.0f) : 1.0f; +} +#endif + +FVector VoxelCaveMorphology::ApplyCaveWarp( + const FVector& WorldPoint, + const FStrateGenerationParams& Params, + uint32 Seed) +{ + return VF_ApplyCaveWarp(WorldPoint, Params, Seed); +} + +void VoxelCaveMorphology::ConfigurePlayerFitMemoFromCommandLine() +{ + int32 CommandLineValue = CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread(); + const bool bCommandLineValue = FParse::Value( + FCommandLine::Get(), + TEXT("voxel.PlayerFitMemo="), + CommandLineValue); + if (bCommandLineValue) + { + CVarVoxelForgePlayerFitMemo->Set( + CommandLineValue != 0 ? 1 : 0, + ECVF_SetByCommandline); + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelPlayerFitMemo] configured enabled=%d source=%s sets=%d ways=%d ") + TEXT("capacity=%d key_words=%d entry_bytes=%llu table_bytes=%llu"), + CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread() != 0 ? 1 : 0, + bCommandLineValue ? TEXT("commandline") : TEXT("cvar/default"), + FVFPlayerFitMemo::NumSets, + FVFPlayerFitMemo::NumWays, + FVFPlayerFitMemo::Capacity, + VF_PlayerFitKeyWordCount, + static_cast(sizeof(FVFPlayerFitMemoSlot)), + static_cast(sizeof(FVFPlayerFitMemo))); +} + +void VoxelCaveMorphology::ResetPlayerFitMemoStats() +{ + GPlayerFitMemo.ResetStats(); +} + +void VoxelCaveMorphology::LogPlayerFitMemoStats() +{ + const uint64 Requests = GPlayerFitMemo.Requests.load(std::memory_order_relaxed); + const uint64 Hits = GPlayerFitMemo.Hits.load(std::memory_order_relaxed); + const uint64 Misses = GPlayerFitMemo.Misses.load(std::memory_order_relaxed); + const double HitRate = Requests > 0 + ? static_cast(Hits) / static_cast(Requests) + : 0.0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelPlayerFitMemo] enabled=%d requests=%llu hits=%llu misses=%llu ") + TEXT("hit_rate=%.6f searches=%llu bypasses=%llu entries=%llu/%d ") + TEXT("bytes=%llu evictions=%llu"), + CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread() != 0 ? 1 : 0, + static_cast(Requests), + static_cast(Hits), + static_cast(Misses), + HitRate, + static_cast(GPlayerFitMemo.Searches.load(std::memory_order_relaxed)), + static_cast(GPlayerFitMemo.Bypasses.load(std::memory_order_relaxed)), + static_cast(GPlayerFitMemo.OccupiedEntries.load(std::memory_order_relaxed)), + FVFPlayerFitMemo::Capacity, + static_cast(sizeof(FVFPlayerFitMemo)), + static_cast(GPlayerFitMemo.Evictions.load(std::memory_order_relaxed))); + + UE_LOG(LogTemp, Display, + TEXT("[VoxelRoomMouthMemo] requests=%llu hits=%llu misses=%llu searches=%llu " + "entries=%llu/%d bytes=%llu evictions=%llu"), + static_cast(GRoomMouthMemo.Requests.load(std::memory_order_relaxed)), + static_cast(GRoomMouthMemo.Hits.load(std::memory_order_relaxed)), + static_cast(GRoomMouthMemo.Misses.load(std::memory_order_relaxed)), + static_cast(GRoomMouthMemo.Searches.load(std::memory_order_relaxed)), + static_cast(GRoomMouthMemo.OccupiedEntries.load(std::memory_order_relaxed)), + FVFRoomMouthMemo::Capacity, + static_cast(sizeof(GRoomMouthMemo)), + static_cast(GRoomMouthMemo.Evictions.load(std::memory_order_relaxed))); +} + +FVoxelPassageLanding VF_BuildPassageLanding( + const FVector& InStandingPoint, + float MouthRadius, + const FVector& InDoorDirection, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + bool bSourcePlayerFit) +{ + FVoxelPassageLanding Landing; + + // Body-derived dimensions at the authored 12.5 cm voxel scale are shared with the origin + // landing. In particular, the 0.68 m player diameter, 3 m turning patch, 1.76 m capsule + // height and 1 m headroom become the common §6.5 formulas in VoxelPassageGeometry. + const float SafeMouthRadius = FMath::Max(FMath::Abs(MouthRadius), + VoxelPassageGeometry::MetersToVoxels(0.25f)); + const float DesiredHalfWidth = VoxelPassageGeometry::LandingHalfWidthForRadius(MouthRadius); + const float DesiredHeight = VoxelPassageGeometry::LandingHeightForRadius(MouthRadius); + const float SafeSeal = FMath::Max(BoundarySealThickness, 0.0f); + + FVector Direction(InDoorDirection.X, InDoorDirection.Y, 0.0f); + if (!Direction.Normalize()) + { + Direction = FVector(1.0f, 0.0f, 0.0f); + } + + Landing.StandingPoint = InStandingPoint; + Landing.FloorZ = InStandingPoint.Z + - VoxelPassageGeometry::MetersToVoxels(0.125f); + Landing.HalfWidth = DesiredHalfWidth; + Landing.CeilingZ = Landing.FloorZ + DesiredHeight; + Landing.FloorThickness = VoxelPassageGeometry::LandingFloorThicknessVoxels; + Landing.DoorDirection = Direction; + Landing.bSourcePlayerFit = bSourcePlayerFit; + + // Keep the complete room and its floor strictly inside the vertical seal. Normal authored + // strates have far more room than this bound; the clamp is a fail-safe for a source pose near + // a seal and never moves a normal interior pose. + const float InnerBottom = StrateBottomZ + SafeSeal; + const float InnerTop = StrateTopZ - SafeSeal; + if (VoxelMath::IsFinite(InnerBottom) && VoxelMath::IsFinite(InnerTop) + && InnerTop > InnerBottom) + { + // Passage carving is intentionally ordered after the vertical seal and fades over its + // four-voxel blend radius. Keep both the underside of the support slab and the top of the + // room beyond that radius from the seal, so the landing cannot use the passage exception + // to open a strate boundary. + const float MinFloor = InnerBottom + Landing.FloorThickness + + VF_LandingCarveSafetyMargin; + const float MaxFloor = InnerTop - DesiredHeight - VF_LandingCarveSafetyMargin; + if (MaxFloor >= MinFloor) + { + Landing.FloorZ = FMath::Clamp(Landing.FloorZ, MinFloor, MaxFloor); + Landing.StandingPoint.Z = Landing.FloorZ + + VoxelPassageGeometry::MetersToVoxels(0.125f); + Landing.CeilingZ = Landing.FloorZ + DesiredHeight; + } + else + { + // A degenerate strate cannot satisfy the full body/headroom contract. Fail closed + // instead of emitting a partial chamber whose blend would approach a seal. + Landing.HalfWidth = 0.0f; + Landing.CeilingZ = Landing.FloorZ; + Landing.bSourcePlayerFit = false; + return Landing; + } + } + + // The tube centreline is tangent to the floor: the tube bottom is exactly FloorZ. The door + // sits 0.25 m inside the room so the room/tube smooth union cannot leave a lip. + Landing.DoorPoint = Landing.StandingPoint + + Direction * FMath::Max(0.0f, Landing.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f)); + Landing.DoorPoint.Z = Landing.FloorZ + SafeMouthRadius; + + return Landing; +} + +float VF_EvaluatePassageLandingSDF( + const FVector& Position, + const FVoxelPassageLanding& Landing) +{ + if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y) + || !VoxelMath::IsFinite(Position.Z) + || !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) + { + return FLT_MAX; + } + + const FVector RoomCenter( + Landing.StandingPoint.X, + Landing.StandingPoint.Y, + (Landing.FloorZ + Landing.CeilingZ) * 0.5f); + constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels; + const FVector RoomHalfExtent( + FMath::Max(Landing.HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)), + FMath::Max(Landing.HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)), + FMath::Max((Landing.CeilingZ - Landing.FloorZ) * 0.5f - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f))); + + // max(Room, FloorZ-Z) is the important distinction from a bore: below FloorZ is solid, + // while every horizontal section above it has the same flat floor plane. + float LandingSDF = FMath::Max( + VoxelSDF::RoundedBox(Position, RoomCenter, RoomHalfExtent, RoomRounding), + Landing.FloorZ - Position.Z); + + return LandingSDF; +} + +bool VF_IsPassageLandingFloor( + 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; + } + + const auto IsInFloorBand = [](float Z, float FloorZ, float Thickness) + { + // The analytic ramp and the sampled density use the same float interpolation, but a + // caller can arrive at the mathematically identical plane through a different fused + // multiply/add sequence. Include one machine epsilon on the upper side so the support + // backstop cannot disappear at an exact floor sample. + return Z <= FloorZ + KINDA_SMALL_NUMBER && Z > FloorZ - Thickness; + }; + // The room floor is a true horizontal landing: it is the only floor that owns the full + // three-metre turning patch. The origin room owns its centre, including its support floor. + if (IsInFloorBand(Position.Z, Landing.FloorZ, Landing.FloorThickness)) + { + const float FloorHalfWidth = FMath::Max(Landing.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f); + if (FMath::Abs(Position.X - Landing.StandingPoint.X) <= FloorHalfWidth + && FMath::Abs(Position.Y - Landing.StandingPoint.Y) <= FloorHalfWidth) + { + return true; + } + } + + return false; +} + +bool VF_SuggestLandingPoint( + ECaveGeneratorType Archetype, + const FStrateGenerationParams& CaveParams, + const FSlabGenerationParams& SlabParams, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float DesiredX, + float DesiredY, + float MaxLateralSnap, + FVector& OutPoint, + int32 WorldSeed) +{ + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return VF_FindNearestHashRoomLandingPoint( + CaveParams, Seed, + WorldSeed == MIN_int32 ? Seed : WorldSeed, + StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return VF_SuggestSlabLandingPoint(SlabParams, Seed, StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + default: + // This overload intentionally covers only archetypes whose placement parameters are + // already present in the two legacy structs. The complete overload below carries the + // lattice/grid/blob parameters and answers those sources without guessing. + return false; + } +} + +bool VF_SuggestLandingPoint( + ECaveGeneratorType Archetype, + const FStrateGenerationParams& CaveParams, + const FSlabGenerationParams& SlabParams, + const FMazeGenerationParams& MazeParams, + const FVerticalShaftParams& VerticalShaftParams, + const FFloatingIslandParams& FloatingIslandParams, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float DesiredX, + float DesiredY, + float MaxLateralSnap, + FVector& OutPoint, + int32 WorldSeed) +{ + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return VF_FindNearestHashRoomLandingPoint( + CaveParams, Seed, + WorldSeed == MIN_int32 ? Seed : WorldSeed, + StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return VF_SuggestSlabLandingPoint(SlabParams, Seed, StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + case ECaveGeneratorType::Maze: + return VF_SuggestMazeLandingPoint(MazeParams, Seed, StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + case ECaveGeneratorType::VerticalShafts: + return VF_SuggestVerticalShaftLandingPoint(VerticalShaftParams, Seed, + StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + case ECaveGeneratorType::FloatingIslands: + return VF_SuggestFloatingIslandLandingPoint(FloatingIslandParams, Seed, + StrateTopZ, StrateBottomZ, + DesiredX, DesiredY, MaxLateralSnap, OutPoint); + + case ECaveGeneratorType::SurfaceWorld: + // The structural heightfield is analytically seed-based, but production SurfaceWorld + // resolves biome-selected surface params and per-column overhang state through the + // manager/cache path. This pure API has no biome context and must not query it. + return false; + + default: + return false; + } +} + +bool VoxelCaveMorphology::MayHaveFeatureInSearchBox( + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + bool bUseZ, float SearchMinZ, float SearchMaxZ, + const TArray* TerrainOps) +{ + if (!VoxelMath::IsFinite(SearchMinX) || !VoxelMath::IsFinite(SearchMinY) + || !VoxelMath::IsFinite(SearchMaxX) || !VoxelMath::IsFinite(SearchMaxY) + || SearchMinX > SearchMaxX || SearchMinY > SearchMaxY) + { + return true; + } + if (bUseZ + && (!VoxelMath::IsFinite(SearchMinZ) || !VoxelMath::IsFinite(SearchMaxZ) + || SearchMinZ > SearchMaxZ)) + { + return true; + } + if (Params.RoomSpacing <= 0.0f) + { + // BuildChunkCache returns an empty cache before creating the origin + // room when spacing is non-positive. + return false; + } + + FVFRoomGraphReach Reach; + if (!VF_ComputeRoomGraphReach(Params, TerrainOps, Reach)) + { + return true; + } + + const float CollectMinX = SearchMinX - Reach.CollectMargin; + const float CollectMinY = SearchMinY - Reach.CollectMargin; + const float CollectMaxX = SearchMaxX + Reach.CollectMargin; + const float CollectMaxY = SearchMaxY + Reach.CollectMargin; + if (!VoxelMath::IsFinite(CollectMinX) || !VoxelMath::IsFinite(CollectMinY) + || !VoxelMath::IsFinite(CollectMaxX) || !VoxelMath::IsFinite(CollectMaxY)) + { + return true; + } + + auto FloorCell = [](double Value, double CellSize, int32& OutCell) -> bool + { + const double Cell = FMath::FloorToDouble(Value / CellSize); + if (!VoxelMath::IsFinite(Cell) + || Cell < static_cast(MIN_int32) + 2.0 + || Cell > static_cast(MAX_int32) - 2.0) + { + return false; + } + OutCell = static_cast(Cell); + return true; + }; + int32 CellMinX = 0; + int32 CellMaxX = 0; + int32 CellMinY = 0; + int32 CellMaxY = 0; + if (!FloorCell( + static_cast(CollectMinX), + static_cast(Params.RoomSpacing), CellMinX) + || !FloorCell( + static_cast(CollectMaxX), + static_cast(Params.RoomSpacing), CellMaxX) + || !FloorCell( + static_cast(CollectMinY), + static_cast(Params.RoomSpacing), CellMinY) + || !FloorCell( + static_cast(CollectMaxY), + static_cast(Params.RoomSpacing), CellMaxY)) + { + return true; + } + + const int64 SpanX = static_cast(CellMaxX) + - static_cast(CellMinX) + 1; + const int64 SpanY = static_cast(CellMaxY) + - static_cast(CellMinY) + 1; + // This is deliberately a bounded proof pass. If an authored asset asks + // BuildChunkCache to hash a pathological number of cells, keep the old + // conservative path instead of making classification unbounded. + if (SpanX <= 0 || SpanY <= 0 || SpanX > 4096 || SpanY > 4096 + || SpanX > 262144 / FMath::Max(SpanY, 1)) + { + return true; + } + + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), + FMath::Abs(Params.MaxRoomRadius)); + const float RoomZBuffer = RoomRadiusEnvelope * Params.RoomHeightRatio; + const float StrateMinZ = Params.StrateBottomWorldZ + + Params.BoundarySealThickness + RoomZBuffer; + const float StrateMaxZ = Params.StrateTopWorldZ + - Params.BoundarySealThickness - RoomZBuffer; + const float StrateRangeZ = StrateMaxZ - StrateMinZ; + const float StrateCenterZ = (StrateMinZ + StrateMaxZ) * 0.5f; + if (!VoxelMath::IsFinite(StrateMinZ) || !VoxelMath::IsFinite(StrateMaxZ) + || !VoxelMath::IsFinite(StrateRangeZ) + || !VoxelMath::IsFinite(StrateCenterZ)) + { + return true; + } + + TArray> Rooms; + if (Params.OriginRoomRadius > 0.0f) + { + FBuildRoom OriginRoom{}; + OriginRoom.Center = FVector(0.0f, 0.0f, StrateCenterZ); + OriginRoom.RadiusXY = Params.OriginRoomRadius; + OriginRoom.RadiusZ = Params.OriginRoomRadius + * Params.RoomHeightRatio; + OriginRoom.CellX = INT32_MAX; + OriginRoom.CellY = INT32_MAX; + OriginRoom.Hash = VoxelHash::Cell( + 0, 0, VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex) + ^ 0x0A161Cu); + OriginRoom.bIsOrigin = true; + OriginRoom.bStore = true; + Rooms.Add(OriginRoom); + } + + const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed( + Seed, StrateIndex); + if (Params.RoomDensity > 0.0f) + { + for (int32 CY = CellMinY; CY <= CellMaxY; ++CY) + { + for (int32 CX = CellMinX; CX <= CellMaxX; ++CX) + { + const uint32 CellHash = VoxelHash::Cell( + CX, CY, StrateSeed); + if (VoxelHash::ToFloat01(CellHash) >= Params.RoomDensity) + { + continue; + } + + const float JitterX = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0x12345678u)); + const float JitterY = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u)); + const float JitterZ = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0x55AA55AAu)); + FBuildRoom Room{}; + Room.CellX = CX; + Room.CellY = CY; + Room.Hash = CellHash; + Room.Center.X = (static_cast(CX) + 0.15f + + JitterX * 0.7f) * Params.RoomSpacing; + Room.Center.Y = (static_cast(CY) + 0.15f + + JitterY * 0.7f) * Params.RoomSpacing; + Room.Center.Z = StrateMinZ + JitterZ + * FMath::Max(StrateRangeZ, 1.0f); + const float SizeFactor = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + Room.RadiusXY = FMath::Lerp( + Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); + Room.RadiusZ = Room.RadiusXY * Params.RoomHeightRatio; + Room.bStore = true; + Rooms.Add(Room); + } + } + } + + return VF_MayHaveRoomGraphFeature( + Rooms, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, StrateSeed, Reach, bUseZ, SearchMinZ, SearchMaxZ); +} + +bool VoxelCaveMorphology::IsRoomGraphWindowInvariant( + const FStrateGenerationParams& Params, + const TArray* TerrainOps) +{ + FVFRoomGraphReach Reach; + return VF_ComputeRoomGraphReach(Params, TerrainOps, Reach); +} + +//============================================================================= +// PHASE 1: BUILD CHUNK CACHE +//============================================================================= +// Collects all rooms in the COLLECT region, computes a window-invariant +// nearest-neighbor backbone for connectivity, decides tunnel connections, and +// pre-computes all tunnel geometry (radii, floor-aligned wandering chains, bounding +// spheres). Only rooms/tunnels relevant to the chunk (STORE region) are kept. +// +// The result is stored in OutCache and reused for every voxel in the chunk. + +void VoxelCaveMorphology::BuildChunkCache( + FChunkSDFCache& OutCache, + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + const TArray* TerrainOps, + ERoomGraphBuildSite BuildSite) +{ + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter(VoxelDensityProfile::ECounter::SdfCacheBuild); + const VoxelDensityProfile::ECounter SiteCounter = [&]() + { + switch (BuildSite) + { + case ERoomGraphBuildSite::GeneratorTile: + return VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTile; + case ERoomGraphBuildSite::GeneratorTunnelCore: + return VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTunnelCore; + case ERoomGraphBuildSite::OpShared: + return VoxelDensityProfile::ECounter::RoomGraphBuildOpShared; + case ERoomGraphBuildSite::OpLocal: + return VoxelDensityProfile::ECounter::RoomGraphBuildOpLocal; + case ERoomGraphBuildSite::ClassifierShared: + return VoxelDensityProfile::ECounter::RoomGraphBuildClassifierShared; + case ERoomGraphBuildSite::ClassifierLocal: + return VoxelDensityProfile::ECounter::RoomGraphBuildClassifierLocal; + default: + return VoxelDensityProfile::ECounter::RoomGraphBuildUnknown; + } + }(); + VoxelDensityProfile::AddCounter(SiteCounter); + } + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::RoomGraphBuild); + // Clear previous data (arrays keep their allocation for reuse) + OutCache.Reset(); + OutCache.SDFBlendRadius = Params.SDFBlendRadius; + + // Combine world seed with strate index so each strate gets unique caves + const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex); + + const float CellSize = Params.RoomSpacing; + if (CellSize <= 0.0f) return; + + // This reach proof is shared with the feature-free preflight. When it succeeds, the collect + // margin includes the largest direct room reach and the complete pair/chain/tube envelope; + // when it fails, keep the conservative legacy fallback and retain the malformed-input safety + // behavior below. The tile-window optimization is only a field-preserving optimization for + // the finite parameter envelope accepted by VF_ComputeRoomGraphReach. + FVFRoomGraphReach FeatureReach; + const bool bHasFeatureReach = VF_ComputeRoomGraphReach( + Params, TerrainOps, FeatureReach); + + //========================================================================= + // INFLUENCE RADII + //========================================================================= + // MaxInfluence = how far a room body / tunnel TUBE reaches PERPENDICULAR to its + // anchor — NOT its length. A room or tunnel whose anchor lies within MaxInfluence + // of a box can touch a voxel inside that box. + // Envelope conservatif / conservative bound: Lerp accepts inverted endpoints, + // so max(Min, Max) covers either radius without changing the authored roll. + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), FMath::Abs(Params.MaxRoomRadius)); + const float AllRoomRadiusEnvelope = FMath::Max( + RoomRadiusEnvelope, FMath::Abs(Params.OriginRoomRadius)); + const float AllRoomHeightEnvelope = AllRoomRadiusEnvelope + * FMath::Abs(Params.RoomHeightRatio); + // Interior control points can carry the authored radius variation (+/-18%). Include that + // reach in collection; the per-tunnel index below uses each baked radius exactly. + const float TunnelRadiusEnvelope = FMath::Max( + 0.5f, + FMath::Max(FMath::Abs(Params.TunnelMinRadius), FMath::Abs(Params.TunnelMaxRadius)) + * 1.18f); + const float FloorReliefEnvelope = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float BlendEnvelope = FMath::Max(Params.SDFBlendRadius, 0.0f); + const float DirectRoomReachFallback = VF_RoomShapeReachUpperBound( + AllRoomRadiusEnvelope, AllRoomHeightEnvelope) + + FloorReliefEnvelope + BlendEnvelope * 3.0f; + const float MaxInfluenceFallback = FMath::Max( + DirectRoomReachFallback, + FMath::Abs(Params.TunnelWarpStrength) + + TunnelRadiusEnvelope + BlendEnvelope * 3.0f); + + const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f); + + //========================================================================= + // STORE decision — what we keep for the per-voxel loop. + //========================================================================= + // A room/tunnel is stored iff its OWN influence sphere can overlap the search box + // (RoomReachesSearchBox / tunnel bounding spheres below). Per-voxel culling refines. + + //========================================================================= + // COLLECT region — what we hash into existence for the connectivity decision. + //========================================================================= + // To DECIDE the graph identically in neighboring chunks, we must see, for every + // room that could emit a tunnel touching this chunk, that room's ENTIRE + // nearest-neighbor candidate set (all rooms within MaxTunnelLength of it): + // - A tunnel touching the box has both endpoint centers inside the pair envelope. + // - Each endpoint's NN candidates lie within MaxTunnelLength of that endpoint. + // => collect the pair envelope plus two graph hops. This is independent of the search + // box size, so it remains valid for a whole tile as well as a one-chunk window. + const float CollectMargin = bHasFeatureReach + ? FeatureReach.CollectMargin + : 2.0f * MaxTunnelLen + MaxInfluenceFallback; + const float CollectMinX = SearchMinX - CollectMargin; + const float CollectMinY = SearchMinY - CollectMargin; + const float CollectMaxX = SearchMaxX + CollectMargin; + const float CollectMaxY = SearchMaxY + CollectMargin; + + // Convert COLLECT bounds to cell range + const int32 CellMinX = FMath::FloorToInt(CollectMinX / CellSize); + const int32 CellMaxX = FMath::FloorToInt(CollectMaxX / CellSize); + const int32 CellMinY = FMath::FloorToInt(CollectMinY / CellSize); + const int32 CellMaxY = FMath::FloorToInt(CollectMaxY / CellSize); + + //========================================================================= + // Vertical range for room CENTER placement. + // PLACEMENT CONTRACT: this formula MUST remain identical to + // VF_FindNearestHashRoomLandingPoint above. The pure landing query deliberately does not call this + // per-chunk cache builder during Initialize, so the fixed + // VoxelForge.Determinism.PassageLandsInOpenSpace ring test is the guard that catches drift + // between the two sites. + //========================================================================= + // Buffer = seal thickness + max room half-height. + // This guarantees the tallest possible room (RoomRadiusEnvelope * RoomHeightRatio) + // fits entirely within the seal boundary — no room gets its ceiling or floor + // cut flat by the seal. Smaller rooms have proportionally more margin. + const float RoomZBuffer = RoomRadiusEnvelope * Params.RoomHeightRatio; + const float StrateMinZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness + RoomZBuffer; + const float StrateMaxZ = Params.StrateTopWorldZ - Params.BoundarySealThickness - RoomZBuffer; + const float StrateRangeZ = StrateMaxZ - StrateMinZ; + const float StrateCenterZ = (StrateMinZ + StrateMaxZ) * 0.5f; + + const float BlendK = Params.SDFBlendRadius; + + // "This room can reach the search box" test, using the room's OWN reach. The old test + // compared the center against a box inflated + // by the SHARED MaxInfluence, which silently assumed every room's reach <= MaxInfluence. + // That's FALSE for the origin room (OriginRoomRadius >> MaxRoomRadius) — chunks inside + // the big room but > MaxInfluence from (0,0) didn't store it, so its carve clipped at an + // arbitrary chunk-aligned radius — and slightly false even for hash rooms (1.5x stretch). + auto RoomReachesSearchBox = [&](const FVector& C, float RadiusXY, float RadiusZ) -> bool + { + const float ShapeReach = VF_RoomShapeReachUpperBound(RadiusXY, RadiusZ); + const float Reach = FMath::Max( + ShapeReach + FloorReliefEnvelope + BlendEnvelope * 3.0f, + bHasFeatureReach ? FeatureReach.TerrainOpReach : 0.0f); + const float dx = FMath::Max3((float)(SearchMinX - C.X), 0.0f, (float)(C.X - SearchMaxX)); + const float dy = FMath::Max3((float)(SearchMinY - C.Y), 0.0f, (float)(C.Y - SearchMaxY)); + return (dx * dx + dy * dy) <= Reach * Reach; + }; + + // Sphere-vs-search-box test in XY (treated as infinite in Z; per-voxel culling + // resolves Z). Used to decide whether a tunnel is worth storing for this chunk. + auto SphereTouchesSearchXY = [&](const FVector& C, float RSq) -> bool + { + const float dx = FMath::Max3((float)(SearchMinX - C.X), 0.0f, (float)(C.X - SearchMaxX)); + const float dy = FMath::Max3((float)(SearchMinY - C.Y), 0.0f, (float)(C.Y - SearchMaxY)); + return (dx * dx + dy * dy) <= RSq; + }; + + // A connected edge can be discarded before the expensive player-fit bake when even a + // conservative AABB around its entire possible chain misses the search window. The endpoint + // fit stays inside 75% of its room radius; each cave-warped endpoint/control point can move by + // at most two proven warp envelopes (fit inversion plus the final map); the interior wander is + // bounded by TunnelWarpStrength. This is only a reject gate: non-finite inputs retain the old + // full build, and a touching envelope still takes the exact path below. + const float CaveWarpBound = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f; + auto PossibleTunnelTouchesSearchXY = [&](const FBuildRoom& RoomA, + const FBuildRoom& RoomB) -> bool + { + // If the finite reach proof declined the authored parameter envelope, do not use a + // hand-written approximation here. Building the edge is the safe fallback and preserves + // the malformed-input behavior of the old path. + if (!bHasFeatureReach) + { + return true; + } + const float RadiusA = FMath::Abs(RoomA.RadiusXY); + const float RadiusB = FMath::Abs(RoomB.RadiusXY); + const float RoomEndpointShift = FMath::Max( + 2.0f, 0.75f * FMath::Max(RadiusA, RadiusB)) + CaveWarpBound; + const float TunnelRadius = FMath::Max( + 0.5f, + FMath::Max(FMath::Abs(Params.TunnelMinRadius), + FMath::Abs(Params.TunnelMaxRadius)) * 1.18f); + const float ChainReach = FMath::Max( + FeatureReach.PairAabbReach, + RoomEndpointShift + TunnelRadius + FMath::Max( + FMath::Max(Params.SDFBlendRadius, 0.0f) * 3.0f, + VoxelPassageGeometry::LandingFloorThicknessVoxels)); + if (!VoxelMath::IsFinite(CaveWarpBound) + || !VoxelMath::IsFinite(ChainReach) + || ChainReach < 0.0f + || !VoxelMath::IsFinite((float)RoomA.Center.X) + || !VoxelMath::IsFinite((float)RoomA.Center.Y) + || !VoxelMath::IsFinite((float)RoomB.Center.X) + || !VoxelMath::IsFinite((float)RoomB.Center.Y)) + { + return true; + } + + const float MinX = FMath::Min( + static_cast(RoomA.Center.X), static_cast(RoomB.Center.X)) + - ChainReach; + const float MinY = FMath::Min( + static_cast(RoomA.Center.Y), static_cast(RoomB.Center.Y)) + - ChainReach; + const float MaxX = FMath::Max( + static_cast(RoomA.Center.X), static_cast(RoomB.Center.X)) + + ChainReach; + const float MaxY = FMath::Max( + static_cast(RoomA.Center.Y), static_cast(RoomB.Center.Y)) + + ChainReach; + return VoxelMath::IsFinite(MinX) && VoxelMath::IsFinite(MinY) + && VoxelMath::IsFinite(MaxX) && VoxelMath::IsFinite(MaxY) + && MaxX >= SearchMinX && MaxY >= SearchMinY + && MinX <= SearchMaxX && MinY <= SearchMaxY; + }; + + // Temporary array with cell coordinates for tunnel connection decisions + TArray> BuildRooms; + + // --- ORIGIN ROOM --- + // Guaranteed large origin landing at (0, 0) in each strate — the future shaft landing and + // graph root room. Inter-strate passage mouths are placed elsewhere and have no radial road. + // Collected whenever (0,0) is inside the COLLECT region so it participates in the + // connectivity decision; only stored if it can reach this chunk. + int32 OriginIdx = -1; + if (Params.OriginRoomRadius > 0.0f) + { + // For a valid reach proof, the origin's own body is necessarily inside the collect region + // whenever it can reach the search box. Keep the old own-body fallback only for malformed + // or unsupported authored parameters, where the proof deliberately declines to claim + // window invariance. + const bool bOriginInCollect = + 0.0f >= CollectMinX && 0.0f <= CollectMaxX && + 0.0f >= CollectMinY && 0.0f <= CollectMaxY; + const float OriginRZ = Params.OriginRoomRadius * Params.RoomHeightRatio; + if (bOriginInCollect + || (!bHasFeatureReach + && RoomReachesSearchBox( + FVector(0.0f, 0.0f, StrateCenterZ), + Params.OriginRoomRadius, OriginRZ))) + { + FBuildRoom OriginRoom; + OriginRoom.CellX = INT32_MAX; // Sentinel — never matches a real grid cell + OriginRoom.CellY = INT32_MAX; + OriginRoom.Hash = VoxelHash::Cell(0, 0, StrateSeed ^ 0x0A161Cu); + OriginRoom.Center = FVector(0.0f, 0.0f, StrateCenterZ); + OriginRoom.RadiusXY = Params.OriginRoomRadius; + OriginRoom.RadiusZ = Params.OriginRoomRadius * Params.RoomHeightRatio; + OriginRoom.bIsOrigin = true; + OriginRoom.bStore = RoomReachesSearchBox(OriginRoom.Center, OriginRoom.RadiusXY, OriginRoom.RadiusZ); + OriginIdx = BuildRooms.Add(OriginRoom); + } + } + + // --- HASH-BASED ROOMS --- + for (int32 CY = CellMinY; CY <= CellMaxY; CY++) + { + for (int32 CX = CellMinX; CX <= CellMaxX; CX++) + { + // Hash this cell to decide if it has a room + const uint32 CellHash = VoxelHash::Cell(CX, CY, StrateSeed); + const float RoomChance = VoxelHash::ToFloat01(CellHash); + + // Skip empty cells (no room here) + if (RoomChance >= Params.RoomDensity) continue; + + // Room position: jittered within the cell + const float JitterX = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x12345678u)); + const float JitterY = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u)); + const float JitterZ = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x55AA55AAu)); + + FBuildRoom Room; + Room.CellX = CX; + Room.CellY = CY; + Room.Hash = CellHash; + Room.Center.X = (CX + 0.15f + JitterX * 0.7f) * CellSize; + Room.Center.Y = (CY + 0.15f + JitterY * 0.7f) * CellSize; + Room.Center.Z = StrateMinZ + JitterZ * FMath::Max(StrateRangeZ, 1.0f); + + // Room size: lerp between min and max + const float SizeFactor = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); + Room.RadiusZ = Room.RadiusXY * Params.RoomHeightRatio; + Room.bIsOrigin = false; + Room.bStore = RoomReachesSearchBox(Room.Center, Room.RadiusXY, Room.RadiusZ); + + BuildRooms.Add(Room); + } + } + + // The collect window is intentionally much wider than the stored window: + // it is needed to make the nearest-neighbor graph seam-invariant. Prove + // the genuinely feature-free case before the O(N^2) graph and player-fit + // bake. The helper repeats only the deterministic graph decisions that can + // affect whether a room/tunnel bound reaches this search window. + if (bHasFeatureReach + && !VF_MayHaveRoomGraphFeature( + BuildRooms, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, StrateSeed, FeatureReach)) + { + VF_FinalizeCacheBroadPhaseBounds(OutCache, BlendK); + return; + } + + const int32 NumRooms = BuildRooms.Num(); + if (NumRooms == 0) + { + VF_FinalizeCacheBroadPhaseBounds(OutCache, BlendK); + return; + } + + // Keep the room floor calculation in one place so tunnel mouths and overlap joins use the + // same deterministic support plane as the emitted room. It includes the room-centre relief; + // the evaluator still applies the spatial relief field per voxel around that anchor. + const auto RoomFloorZFor = [&Params, Seed](const FBuildRoom& Room) -> float + { + return VF_RoomFloorZFromParts( + Room.Center, Room.RadiusXY, Room.RadiusZ, Room.Hash, Seed, Params); + }; + + //========================================================================= + // Window-invariant guaranteed backbone + //========================================================================= + // Each room gets ONE guaranteed link, chosen among candidates within MaxTunnelLength + // (that reach filter is what keeps the decision identical across chunk windows). + // + // bTunnelsFlowTowardOrigin = true (default): the link target is the best candidate + // among rooms STRICTLY CLOSER to (0,0) in XY. Every chain of links then descends in + // origin-distance and terminates at the origin room → the network is a TREE ROOTED AT + // THE SPINE HUB: every room is reachable, tunnels flow inward like tributaries. + // (Frontier rooms with no closer candidate in reach fall back to plain NN — a far + // cluster stays internally chained even when it can't bridge to the origin side.) + // + // bTunnelsFlowTowardOrigin = false (legacy): plain nearest-neighbor pairing. NOTE: an + // NN-graph is a FOREST of small clusters, not a connected tree — isolated cave pockets + // are expected in this mode. + // + // Selection metric (not the reach filter) penalizes vertical separation via + // TunnelHorizontalBias, so the GUARANTEED links also prefer walkable slopes — + // biasing only the random TunnelDensity extras lets backbone tunnels come out + // absurdly steep. + TArray> NearestNeighbor; + NearestNeighbor.SetNumUninitialized(NumRooms); + + const float MaxTunnelLenSq = MaxTunnelLen * MaxTunnelLen; + const bool bFlowToOrigin = Params.bTunnelsFlowTowardOrigin; + + auto LinkMetric = [&](int32 I, int32 J) -> float + { + const float D = FVector::Dist(BuildRooms[I].Center, BuildRooms[J].Center); + const float VertSep = FMath::Abs(BuildRooms[I].Center.Z - BuildRooms[J].Center.Z); + return D + VertSep * Params.TunnelHorizontalBias * 5.0f; + }; + // Squared XY distance to the (0,0) spine — the "inward" ordering. Purely positional, + // so it is window-invariant by construction. + auto OriginKeySq = [&](int32 I) -> float + { + const FVector& C = BuildRooms[I].Center; + return C.X * C.X + C.Y * C.Y; + }; + + // ExcludeJ: used by the origin-cap redirect below (re-pick ignoring the origin room). + auto PickNeighbor = [&](int32 I, int32 ExcludeJ) -> int32 + { + const float MyKeySq = OriginKeySq(I); + float BestInward = FLT_MAX; int32 BestInwardJ = -1; + float BestAny = FLT_MAX; int32 BestAnyJ = -1; + for (int32 J = 0; J < NumRooms; J++) + { + if (J == I || J == ExcludeJ) continue; + const float DSq = FVector::DistSquared(BuildRooms[I].Center, BuildRooms[J].Center); + if (DSq > MaxTunnelLenSq) continue; // out of reach — never a tunnel + const float M = LinkMetric(I, J); + if (M < BestAny) { BestAny = M; BestAnyJ = J; } + if (bFlowToOrigin && OriginKeySq(J) < MyKeySq && M < BestInward) + { + BestInward = M; BestInwardJ = J; + } + } + return (bFlowToOrigin && BestInwardJ != -1) ? BestInwardJ : BestAnyJ; + }; + + for (int32 I = 0; I < NumRooms; I++) + { + NearestNeighbor[I] = PickNeighbor(I, /*ExcludeJ=*/INDEX_NONE); + } + + //========================================================================= + // ORIGIN CONNECTION CAP (deterministic, order-independent) + //========================================================================= + // OriginRoomMaxConnections caps how many rooms backbone-force into the origin. + // The OLD approach counted connections in pair-loop order, which depended on the + // per-chunk room ordering → non-deterministic across chunks. Instead we gather + // ALL rooms backbone-linked to origin (window-invariant given the COLLECT region), + // rank them by a deterministic pair hash, and keep only the top N as forced. + // The rest are DOWNGRADED to the random TunnelDensity path (connectivity not + // broken, only the "guaranteed" aspect is limited). + TSet OriginDowngraded; + const int32 MaxOriginConn = Params.OriginRoomMaxConnections; + if (OriginIdx >= 0 && MaxOriginConn > 0) + { + // Collect origin backbone candidates with a deterministic ranking key. + TArray, TInlineAllocator<32>> OriginLinks; + for (int32 I = 0; I < NumRooms; I++) + { + if (I == OriginIdx) continue; + const bool bLinked = (NearestNeighbor[I] == OriginIdx) || (NearestNeighbor[OriginIdx] == I); + if (!bLinked) continue; + const uint32 Key = VoxelHash::Pair( + BuildRooms[OriginIdx].CellX, BuildRooms[OriginIdx].CellY, + BuildRooms[I].CellX, BuildRooms[I].CellY, + StrateSeed ^ 0x031A1Eu); + OriginLinks.Add(TPair(Key, I)); + } + // Stable deterministic order by (hash, then index for tie-break). + OriginLinks.Sort([](const TPair& A, const TPair& B) + { + return A.Key != B.Key ? A.Key < B.Key : A.Value < B.Value; + }); + for (int32 R = MaxOriginConn; R < OriginLinks.Num(); ++R) + { + OriginDowngraded.Add(OriginLinks[R].Value); + } + + // REDIRECT, don't strand: a downgraded room whose guaranteed link pointed at the + // origin re-picks its best target EXCLUDING origin. It keeps a guaranteed link + // (chains to the hub through another room instead of directly), which matters + // doubly now that rooms with zero connections are culled below. Deterministic: + // same candidate set, same metric, one exclusion. + for (int32 DowngradedI : OriginDowngraded) + { + if (NearestNeighbor[DowngradedI] == OriginIdx) + { + NearestNeighbor[DowngradedI] = PickNeighbor(DowngradedI, /*ExcludeJ=*/OriginIdx); + } + } + } + + //========================================================================= + // Resolve tunnel connections, pre-compute geometry, store chunk-relevant ones + //========================================================================= + // A tunnel exists if EITHER: + // 1. One is the other's nearest neighbor (backbone — guarantees connectivity), + // and (for origin links) it survived the origin cap, OR + // 2. The pair hash passes TunnelDensity (random extra loops). + // Both must pass the distance check (MaxTunnelLength). Only tunnels whose bounding + // sphere reaches the search box are stored for the per-voxel loop. + // Tracks whether each room ends up with at least one tunnel — DECIDED connections, + // independent of whether the tunnel itself is stored for this chunk (a room near the + // window edge may have all its tunnels outside the box; it's still "connected"). + // Stored rooms with zero connections are culled at emission: they'd be sealed air + // pockets no tunnel ever reaches. Window-invariant: a stored room's full candidate + // set (and each candidate's own candidates) lies inside the COLLECT region. + TArray> RoomConnected; + RoomConnected.Init(false, NumRooms); + TArray> RoomMouthRises; + + struct FResolvedGraphEdge + { + int32 RoomA = INDEX_NONE; + int32 RoomB = INDEX_NONE; + bool bBackbone = false; + bool bRedundant = false; + bool bLeaf = false; + bool bWalkForestEdge = false; + }; + + // Resolve the complete graph before emitting any geometry. Ledge eligibility is a graph + // property, not a chunk-window property: an edge may drop only when it is incident to a leaf or + // when removing it leaves another route between its endpoints. All other edges must wind. + // The collect region contains the complete candidate neighbourhood for every stored edge, so + // this bridge test is also window-invariant. + TArray> GraphEdges; + TArray> GraphDegrees; + GraphDegrees.Init(0, NumRooms); + for (int32 I = 0; I < NumRooms; ++I) + { + for (int32 J = I + 1; J < NumRooms; ++J) + { + const FBuildRoom& RoomA = BuildRooms[I]; + const FBuildRoom& RoomB = BuildRooms[J]; + bool bBackbone = (NearestNeighbor[I] == J) || (NearestNeighbor[J] == I); + + if (bBackbone && (RoomA.bIsOrigin || RoomB.bIsOrigin)) + { + const int32 Other = RoomA.bIsOrigin ? J : I; + if (OriginDowngraded.Contains(Other)) + { + bBackbone = false; + } + } + + const float EuclidDist = FVector::Dist(RoomA.Center, RoomB.Center); + float CheckDist = EuclidDist; + if (!bBackbone && Params.TunnelHorizontalBias > 0.0f) + { + const float VertSep = FMath::Abs(RoomA.Center.Z - RoomB.Center.Z); + CheckDist += VertSep * Params.TunnelHorizontalBias * 5.0f; + } + if (CheckDist > Params.MaxTunnelLength) + { + continue; + } + + if (!bBackbone) + { + const uint32 PairHash = VoxelHash::Pair( + RoomA.CellX, RoomA.CellY, + RoomB.CellX, RoomB.CellY, + StrateSeed); + if (VoxelHash::ToFloat01(PairHash) >= Params.TunnelDensity) + { + continue; + } + } + + FResolvedGraphEdge& Edge = GraphEdges.Emplace_GetRef(); + Edge.RoomA = I; + Edge.RoomB = J; + Edge.bBackbone = bBackbone; + RoomConnected[I] = true; + RoomConnected[J] = true; + ++GraphDegrees[I]; + ++GraphDegrees[J]; + } + } + + TArray, TInlineAllocator<64>> GraphAdjacency; + GraphAdjacency.SetNum(NumRooms); + for (int32 EdgeIndex = 0; EdgeIndex < GraphEdges.Num(); ++EdgeIndex) + { + const FResolvedGraphEdge& Edge = GraphEdges[EdgeIndex]; + GraphAdjacency[Edge.RoomA].Add(EdgeIndex); + GraphAdjacency[Edge.RoomB].Add(EdgeIndex); + } + TArray> GraphDiscovery; + TArray> GraphLowLink; + TArray> GraphBridges; + GraphDiscovery.Init(INDEX_NONE, NumRooms); + GraphLowLink.Init(INDEX_NONE, NumRooms); + GraphBridges.Init(0u, GraphEdges.Num()); + int32 GraphDiscoveryTime = 0; + TFunction VisitGraph = [&](int32 Node, int32 ParentEdge) + { + GraphDiscovery[Node] = GraphDiscoveryTime; + GraphLowLink[Node] = GraphDiscoveryTime; + ++GraphDiscoveryTime; + for (const int32 EdgeIndex : GraphAdjacency[Node]) + { + if (EdgeIndex == ParentEdge) + { + continue; + } + const FResolvedGraphEdge& Edge = GraphEdges[EdgeIndex]; + const int32 Other = Edge.RoomA == Node ? Edge.RoomB : Edge.RoomA; + if (GraphDiscovery[Other] == INDEX_NONE) + { + VisitGraph(Other, EdgeIndex); + GraphLowLink[Node] = FMath::Min( + GraphLowLink[Node], GraphLowLink[Other]); + if (GraphLowLink[Other] > GraphDiscovery[Node]) + { + GraphBridges[EdgeIndex] = 1u; + } + } + else + { + GraphLowLink[Node] = FMath::Min( + GraphLowLink[Node], GraphDiscovery[Other]); + } + } + }; + for (int32 Node = 0; Node < NumRooms; ++Node) + { + if (GraphDiscovery[Node] == INDEX_NONE) + { + VisitGraph(Node, INDEX_NONE); + } + } + for (int32 EdgeIndex = 0; EdgeIndex < GraphEdges.Num(); ++EdgeIndex) + { + FResolvedGraphEdge& Edge = GraphEdges[EdgeIndex]; + Edge.bRedundant = GraphBridges[EdgeIndex] == 0u; + Edge.bLeaf = GraphDegrees[Edge.RoomA] <= 1 + || GraphDegrees[Edge.RoomB] <= 1; + } + + // Independent bridge tests are not enough for a batch of drops: two non-bridge edges from the + // same cycle can become a cut when selected together. Reserve a deterministic spanning forest + // before the hash choice so every non-leaf room remains connected by walkable edges even when + // several redundant edges receive ledges. Leaf edges are still allowed to drop by policy. + TArray> GraphForestVisited; + TArray> GraphForestEdges; + GraphForestVisited.Init(0u, NumRooms); + GraphForestEdges.Init(0u, GraphEdges.Num()); + TFunction VisitWalkForest = [&](int32 Node) + { + GraphForestVisited[Node] = 1u; + for (const int32 EdgeIndex : GraphAdjacency[Node]) + { + const FResolvedGraphEdge& Edge = GraphEdges[EdgeIndex]; + const int32 Other = Edge.RoomA == Node ? Edge.RoomB : Edge.RoomA; + if (GraphForestVisited[Other] != 0u) + { + continue; + } + GraphForestEdges[EdgeIndex] = 1u; + VisitWalkForest(Other); + } + }; + for (int32 Node = 0; Node < NumRooms; ++Node) + { + if (GraphForestVisited[Node] == 0u) + { + VisitWalkForest(Node); + } + } + for (int32 EdgeIndex = 0; EdgeIndex < GraphEdges.Num(); ++EdgeIndex) + { + GraphEdges[EdgeIndex].bWalkForestEdge = GraphForestEdges[EdgeIndex] != 0u; + } + + const auto ResolvePlayerFitPoint = [](FBuildRoom& Room, const FStrateGenerationParams& InParams, + uint32 InWorldSeed) -> bool + { + if (Room.bIsOrigin) + { + return false; + } + if (Room.bPlayerFitAttempted) + { + return Room.bHasPlayerFitPoint; + } + Room.bPlayerFitAttempted = true; + const FVFRoomLandingSite Site{ + Room.Center, + Room.RadiusXY, + Room.RadiusZ, + Room.Hash, + false}; + Room.bHasPlayerFitPoint = VF_FindPlayerFitPointForRoomMemoized( + InParams, + Site, + InParams.StrateTopWorldZ, + InParams.StrateBottomWorldZ, + InWorldSeed, + Room.PlayerFitPoint); + return Room.bHasPlayerFitPoint; + }; + +#if !UE_BUILD_SHIPPING + const bool bTrimRoomMouth = CVarVoxelForgeTunnelMouthTrim.GetValueOnAnyThread() != 0; +#else + constexpr bool bTrimRoomMouth = true; +#endif + const auto BuildRoomMouth = [ + &BuildRooms, &ResolvePlayerFitPoint, &RoomFloorZFor, + &Params, Seed, BlendK, bTrimRoomMouth](int32 RoomIndex, const FVector& Towards, + float TunnelRadius) -> FVector + { + FBuildRoom& Room = BuildRooms[RoomIndex]; + FVector Anchor = Room.Center; + if (!Room.bIsOrigin + && ResolvePlayerFitPoint(Room, Params, Seed)) + { + Anchor = Room.PlayerFitPoint; + } + + if (!bTrimRoomMouth) + { + // Keep the proven legacy endpoint as the safe default while the room-wall trim + // remains an explicit investigation switch. The endpoint still uses the room's + // deterministic floor and player-fit anchor, so the raw tunnel floor remains the + // authored tunnel surface rather than an added slab. + const float RoomFloorZ = RoomFloorZFor(Room); + const float SafeFloorZ = VoxelMath::IsFinite(RoomFloorZ) + ? RoomFloorZ : Room.Center.Z - Room.RadiusZ; + FVector End = Room.Center + FVector( + 0.0f, 0.0f, SafeFloorZ + FMath::Abs(TunnelRadius) - Room.Center.Z); + FVector WorldEnd = VF_UnwarpCavePoint(End, Params, Seed); + if (!Room.bIsOrigin && Room.bHasPlayerFitPoint) + { + WorldEnd = FVector( + Room.PlayerFitPoint.X, + Room.PlayerFitPoint.Y, + Room.PlayerFitPoint.Z - 1.0f + FMath::Abs(TunnelRadius)); + } + return WorldEnd; + } + + const FVFRoomLandingSite Site{ + Room.Center, + Room.RadiusXY, + Room.RadiusZ, + Room.Hash, + Room.bIsOrigin}; + const FVFRoomMouthMemoKey MemoKey = VF_MakeRoomMouthMemoKey( + Params, Site, Anchor, Towards, TunnelRadius, BlendK, Seed); + FVector CachedWorldEnd = FVector::ZeroVector; + GRoomMouthMemo.FindOrCompute( + MemoKey, + CachedWorldEnd, + [&](FVector& OutWorldEnd) -> bool + { + FVector MouthXY = Anchor; + float BoundaryDistance = 0.0f; + if (!VF_FindRoomMouthBoundary( + Site, Params, Seed, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Anchor, Towards, TunnelRadius, BlendK, + MouthXY, BoundaryDistance)) + { + FVector FallbackDirection(Towards.X, Towards.Y, 0.0f); + if (!FallbackDirection.Normalize()) + { + FallbackDirection = FVector::ForwardVector; + } + const float MouthInset = VF_TunnelMouthInset(TunnelRadius, BlendK); + MouthXY = Anchor + FallbackDirection * FMath::Max( + 0.0f, FMath::Abs(Room.RadiusXY) - MouthInset); + } + (void)BoundaryDistance; + + float MouthFloorZ = VF_RoomFloorZFromParts( + FVector(MouthXY.X, MouthXY.Y, Room.Center.Z), + Room.RadiusXY, Room.RadiusZ, Room.Hash, Seed, Params); + if (!VoxelMath::IsFinite(MouthFloorZ)) + { + MouthFloorZ = RoomFloorZFor(Room); + } + if (!VoxelMath::IsFinite(MouthFloorZ)) + { + MouthFloorZ = Room.Center.Z - Room.RadiusZ; + } + float WorldFloorZ = -FLT_MAX; + const bool bFoundWorldFloor = VF_FindRoomFloorCrossingWorld( + Site, Params, Seed, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + MouthXY, WorldFloorZ); + if (bFoundWorldFloor) + { + // The boundary search returns world XY. Put the endpoint directly on the + // room's world-space floor crossing; the later VF_ApplyCaveWarp call creates + // the matching SDF-chain endpoint. This makes the room/tunnel floor join + // exact by construction. + OutWorldEnd = FVector( + MouthXY.X, MouthXY.Y, + WorldFloorZ + FMath::Abs(TunnelRadius)); + } + else + { + // Malformed/unsupported room data keeps the bounded fallback. The outer + // construction path applies VF_ApplyCaveWarp when it creates the SDF-chain + // endpoint, so invert that warp here just as the legacy endpoint path does. + OutWorldEnd = VF_UnwarpCavePoint( + FVector( + MouthXY.X, MouthXY.Y, + MouthFloorZ + FMath::Abs(TunnelRadius)), + Params, Seed); + } + return VoxelMath::IsFinite(OutWorldEnd.X) + && VoxelMath::IsFinite(OutWorldEnd.Y) + && VoxelMath::IsFinite(OutWorldEnd.Z); + }); + return CachedWorldEnd; + }; + + for (const FResolvedGraphEdge& GraphEdge : GraphEdges) + { + const int32 I = GraphEdge.RoomA; + const int32 J = GraphEdge.RoomB; + FBuildRoom& RoomA = BuildRooms[I]; + FBuildRoom& RoomB = BuildRooms[J]; + + if (!PossibleTunnelTouchesSearchXY(RoomA, RoomB)) + { + // The graph decision is retained above; only the search-window geometry is + // omitted. This preserves connectivity, joins, and all neighbouring-window + // decisions while avoiding player-fit work for edges that cannot be stored. + continue; + } + + // --- TUNNEL HASH (for deriving all tunnel properties) --- + const uint32 TunnelHash = VoxelHash::Pair( + RoomA.CellX, RoomA.CellY, + RoomB.CellX, RoomB.CellY, + StrateSeed ^ 0xDECAF001u + ); + + // --- RADIUS --- + const float FactorA = VoxelHash::ToFloat01(VoxelHash::Mix(TunnelHash ^ 0xBAADF00Du)); + const float FactorB = VoxelHash::ToFloat01(VoxelHash::Mix(TunnelHash ^ 0x8BADF00Du)); + const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); + const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); + + // --- ENDPOINT ROOM-WALL TRIM + FLOOR ALIGNMENT --- + // Resolve each endpoint against the actual hashed room boundary along the edge. The + // old endpoint was the room centre/player-fit point, so its capsule necessarily ran + // through the chamber. The trim leaves half a tunnel radius plus the SDF blend inside + // the wall, which preserves a walkable overlap without projecting a floor slab into + // the room. + FVector TunnelDirection( + RoomB.Center.X - RoomA.Center.X, + RoomB.Center.Y - RoomA.Center.Y, + 0.0f); + if (!TunnelDirection.Normalize()) + { + TunnelDirection = FVector::ForwardVector; + } + const FVector WorldEndA = BuildRoomMouth(I, TunnelDirection, RadA); + const FVector WorldEndB = BuildRoomMouth(J, -TunnelDirection, RadB); + const FVector EndA = VF_ApplyCaveWarp(WorldEndA, Params, Seed); + const FVector EndB = VF_ApplyCaveWarp(WorldEndB, Params, Seed); + + // --- BUILD CACHED TUNNEL --- + FCachedTunnel CT; + CT.EndpointA = EndA; + CT.EndpointB = EndB; + CT.RadiusA = RadA; + CT.RadiusB = RadB; + CT.FloorReliefStrength = Params.FloorReliefStrength; + CT.FloorReliefFrequency = Params.FloorReliefFrequency; + CT.FloorSeed = VoxelHash::Mix(TunnelHash ^ 0xF100F1u); + CT.bHasFloorRoomOwnership = true; + CT.FloorRoomHashA = RoomA.Hash; + CT.FloorRoomHashB = RoomB.Hash; + CT.bTunnelFloorTerracingEnabled = Params.bTunnelFloorTerracingEnabled; + CT.TunnelFloorTerraceStepHeight = Params.TunnelFloorTerraceStepHeight; + CT.TunnelFloorMaxLedgeHeight = Params.TunnelFloorMaxLedgeHeight; + CT.TunnelFloorGentleSlopeGradient = + VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + Params.TunnelFloorGentleSlopeThreshold); + CT.TunnelFloorLedgeCountPreference = Params.TunnelFloorLedgeCountPreference; + CT.TunnelFloorMaxLedges = Params.TunnelFloorMaxLedges; + + // A room edge is not a straight capsule between cell centres. Build a deterministic + // chain of hash-jittered control points instead. The chain is keyed only by the pair + // hash, so every chunk that collects this edge reconstructs the same path; the wide + // collect region above keeps that topology seam-safe. The envelope is zero at both + // mouths so the room/tunnel join remains anchored. + // + // A direct room-floor delta beyond the strate's gentle threshold is solved in one of + // two ways. A walk-critical edge gets a sinusoidal switchback whose horizontal length + // is chosen from the actual floor delta. A graph-redundant or leaf edge may instead + // receive a deterministic handful of explicit ledge transitions. Both decisions are + // made before the SDF chain is derived, so the world chain is the authoritative + // walkability route and the warped chain remains an exact representation of it. + const float WorldFloorA = static_cast(WorldEndA.Z) + - FMath::Abs(RadA); + const float WorldFloorB = static_cast(WorldEndB.Z) + - FMath::Abs(RadB); + const float FloorDelta = WorldFloorB - WorldFloorA; + const float DirectHorizontalRun = FVector2D( + static_cast(WorldEndB.X - WorldEndA.X), + static_cast(WorldEndB.Y - WorldEndA.Y)).Size(); + const float GentleThreshold = VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + Params.TunnelFloorGentleSlopeThreshold); + const float SafeGentleThreshold = FMath::Max( + GentleThreshold, KINDA_SMALL_NUMBER); + const bool bDirectSteep = VoxelMath::IsFinite(WorldFloorA) + && VoxelMath::IsFinite(WorldFloorB) + && FMath::Abs(FloorDelta) + > FMath::Max(DirectHorizontalRun, KINDA_SMALL_NUMBER) + * SafeGentleThreshold; + const bool bGraphLedgeEligible = GraphEdge.bLeaf + || (GraphEdge.bRedundant && !GraphEdge.bWalkForestEdge); + const uint32 LedgeChoiceHash = VoxelHash::Mix( + TunnelHash ^ 0xC1A0E5EDu); + constexpr float DramaticLedgeChance = 0.35f; + const bool bDramaticLedge = Params.bTunnelFloorTerracingEnabled + && bDirectSteep + && bGraphLedgeEligible + && VoxelHash::ToFloat01(LedgeChoiceHash) < DramaticLedgeChance; + CT.bLedgeGraphEligible = bGraphLedgeEligible; + CT.bDramaticLedge = bDramaticLedge; + + const int32 MaxLedges = FMath::Clamp( + Params.TunnelFloorMaxLedges, 1, 4096); + const int32 PreferredLedges = FMath::Max( + Params.TunnelFloorLedgeCountPreference, 0); + const float MaxLedgeHeight = VoxelMath::IsFinite( + Params.TunnelFloorMaxLedgeHeight) + ? FMath::Max(Params.TunnelFloorMaxLedgeHeight, KINDA_SMALL_NUMBER) + : KINDA_SMALL_NUMBER; + const int32 HeightDrivenLedges = FMath::Max( + 1, FMath::CeilToInt(FMath::Abs(FloorDelta) / MaxLedgeHeight)); + const int32 RequestedLedges = FMath::Max( + PreferredLedges, HeightDrivenLedges); + // The chain deliberately has a finite number of broad control spans. Even an authored + // max of 4096 cannot turn into a thin staircase; a long change is represented by at + // most sixteen large drops separated by walkable spans. + const int32 ChainLedgeCount = bDramaticLedge + ? FMath::Clamp(RequestedLedges, 1, FMath::Min(MaxLedges, 16)) + : 0; + CT.TunnelFloorAuthoredLedgeCount = ChainLedgeCount; + + const float TunnelLength = FVector::Dist(WorldEndA, WorldEndB); + const int32 BaseSegmentDivisor = bDirectSteep ? 40 : 48; + const int32 BaseSegmentMin = bDirectSteep ? 4 : 3; + const int32 BaseSegmentMax = bDirectSteep ? 16 : 12; + const int32 BaseSegments = TunnelLength > 1.0f + ? FMath::Clamp( + FMath::CeilToInt(TunnelLength / static_cast(BaseSegmentDivisor)), + BaseSegmentMin, BaseSegmentMax) + : 1; + const int32 MinSegmentsForLedges = ChainLedgeCount > 0 + ? FMath::Max(3, ChainLedgeCount * 2) + : 1; + const int32 WanderSegments = FMath::Clamp( + FMath::Max(BaseSegments, MinSegmentsForLedges), + 1, 32); + + // A raw tunnel still needs a continuous, gentle route when its floor relief/ledge + // modifier is disabled. The wind is centreline geometry, not an added floor slab; it + // supplies the horizontal run for the authored floor plane and keeps the room-to-room + // graph connected. The rings observed in the owner asset came from the separate + // world-core handoff exposing span joins, not from this smooth route decision. + const bool bNeedsWind = bDirectSteep + && !bDramaticLedge + && GentleThreshold > KINDA_SMALL_NUMBER; + CT.bFloorRouteWasWound = bNeedsWind; + const float RequiredHorizontalRun = bNeedsWind + ? FMath::Min( + VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, + FMath::Abs(FloorDelta) / SafeGentleThreshold) + : DirectHorizontalRun; + const int32 WindWaves = bNeedsWind + ? FMath::Clamp( + FMath::CeilToInt(RequiredHorizontalRun + / FMath::Max(DirectHorizontalRun, 16.0f)), + 1, VoxelPassageGeometry::TunnelFloorMaximumWindWaves) + : 0; + FVector HorizontalAxis( + WorldEndB.X - WorldEndA.X, WorldEndB.Y - WorldEndA.Y, 0.0f); + FVector PerpA; + if (HorizontalAxis.Normalize()) + { + PerpA = FVector(-HorizontalAxis.Y, HorizontalAxis.X, 0.0f); + } + else + { + PerpA = FVector::RightVector; + } + + CT.WorldControlPoints.SetNum(WanderSegments + 1); + CT.WorldControlRadii.SetNum(WanderSegments + 1); + const float MaxWander = FMath::Min( + FMath::Max(Params.TunnelWarpStrength, 0.0f), + FMath::Max(TunnelLength, DirectHorizontalRun) * 0.25f); + const float MaxRadiusVariation = 0.18f; + auto PopulateWorldChain = [&](float WindAmplitude) + { + float PreviousSide = 0.0f; + for (int32 ControlIndex = 0; + ControlIndex <= WanderSegments; + ++ControlIndex) + { + const float T = static_cast(ControlIndex) + / static_cast(WanderSegments); + const float Envelope = VoxelMath::DetSin(T * PI); + FVector WorldControlPoint = FMath::Lerp(WorldEndA, WorldEndB, T); + const float RadiusBase = FMath::Lerp(RadA, RadB, T); + float ControlRadius = RadiusBase; + + if (ControlIndex > 0 && ControlIndex < WanderSegments) + { + const uint32 PointHash = VoxelHash::Mix( + TunnelHash ^ (0xBADC0DEu + + static_cast(ControlIndex) * 0x9E3779B9u)); + const float RawSide = VoxelHash::ToFloatSigned( + VoxelHash::Mix(PointHash ^ 0x13579BDFu)); + // The original low-pass remains the organic baseline. Wind is a separate + // deterministic alternating wave, which gives critical edges a visible + // serpentine route without moving either mouth. + const float Side = RawSide * 0.65f + PreviousSide * 0.35f; + const float WindWave = bNeedsWind + ? VoxelMath::DetSin(static_cast(WindWaves) * PI * T) + * WindAmplitude * Envelope + * ((LedgeChoiceHash & 1u) != 0u ? 1.0f : -1.0f) + : 0.0f; + WorldControlPoint += PerpA * ( + Side * MaxWander * Envelope + WindWave); + PreviousSide = Side; + + const float RadiusNoise = VoxelHash::ToFloatSigned( + VoxelHash::Mix(PointHash ^ 0x5A17EADu)); + ControlRadius = FMath::Max( + 0.5f, + RadiusBase * (1.0f + RadiusNoise * MaxRadiusVariation * Envelope)); + } + else if (ControlIndex == 0) + { + PreviousSide = 0.0f; + } + + // Pin the endpoints after all arithmetic. This is the seam and room-mouth + // contract: no residual sin(PI) or interpolation rounding moves a mouth. + if (ControlIndex == 0) + { + WorldControlPoint = WorldEndA; + ControlRadius = RadA; + } + else if (ControlIndex == WanderSegments) + { + WorldControlPoint = WorldEndB; + ControlRadius = RadB; + } + CT.WorldControlPoints[ControlIndex] = WorldControlPoint; + CT.WorldControlRadii[ControlIndex] = ControlRadius; + } + }; + auto HorizontalPolylineLength = [&]() -> float + { + float Length = 0.0f; + for (int32 Index = 0; Index < WanderSegments; ++Index) + { + const FVector& A = CT.WorldControlPoints[Index]; + const FVector& B = CT.WorldControlPoints[Index + 1]; + Length += FVector2D( + static_cast(B.X - A.X), + static_cast(B.Y - A.Y)).Size(); + } + return Length; + }; + + PopulateWorldChain(0.0f); + if (bNeedsWind) + { + const float TargetRun = FMath::Max( + RequiredHorizontalRun, DirectHorizontalRun); + float LowAmplitude = 0.0f; + float HighAmplitude = FMath::Max( + 8.0f, TargetRun - DirectHorizontalRun); + PopulateWorldChain(HighAmplitude); + for (int32 Expand = 0; + Expand < 8 && HorizontalPolylineLength() < TargetRun; + ++Expand) + { + HighAmplitude = FMath::Max( + HighAmplitude * 2.0f, TargetRun); + PopulateWorldChain(HighAmplitude); + } + for (int32 Iteration = 0; Iteration < 12; ++Iteration) + { + const float MidAmplitude = (LowAmplitude + HighAmplitude) * 0.5f; + PopulateWorldChain(MidAmplitude); + if (HorizontalPolylineLength() < TargetRun) + { + LowAmplitude = MidAmplitude; + } + else + { + HighAmplitude = MidAmplitude; + } + } + PopulateWorldChain(HighAmplitude); + } + + TArray> CumulativeHorizontal; + CumulativeHorizontal.SetNumZeroed(WanderSegments + 1); + for (int32 Index = 0; Index < WanderSegments; ++Index) + { + const FVector& A = CT.WorldControlPoints[Index]; + const FVector& B = CT.WorldControlPoints[Index + 1]; + CumulativeHorizontal[Index + 1] = CumulativeHorizontal[Index] + + FVector2D( + static_cast(B.X - A.X), + static_cast(B.Y - A.Y)).Size(); + } + const float TotalHorizontalRun = CumulativeHorizontal.Last(); + CT.FloorLedgeLevels.Reset(); + if (ChainLedgeCount > 0) + { + CT.FloorLedgeLevels.SetNum(WanderSegments + 1); + } + for (int32 ControlIndex = 0; + ControlIndex <= WanderSegments; + ++ControlIndex) + { + const float ArcLengthAlpha = TotalHorizontalRun > KINDA_SMALL_NUMBER + ? CumulativeHorizontal[ControlIndex] / TotalHorizontalRun + : static_cast(ControlIndex) + / static_cast(WanderSegments); + // Direct gentle tunnels retain their established parameter-linear chain. A + // winding route needs horizontal arc length for its slope budget, and a ledge + // route needs it to distribute the few explicit transitions along the route. + const float RouteAlpha = (bNeedsWind || ChainLedgeCount > 0) + ? ArcLengthAlpha + : static_cast(ControlIndex) + / static_cast(WanderSegments); + const int32 LedgeLevel = ChainLedgeCount > 0 + ? FMath::Clamp(FMath::RoundToInt( + RouteAlpha * static_cast(ChainLedgeCount)), + 0, ChainLedgeCount) + : 0; + if (ChainLedgeCount > 0) + { + CT.FloorLedgeLevels[ControlIndex] = LedgeLevel; + } + const float AuthoredFloor = ChainLedgeCount > 0 + ? WorldFloorA + FloorDelta * static_cast(LedgeLevel) + / static_cast(ChainLedgeCount) + : WorldFloorA + FloorDelta * RouteAlpha; + CT.WorldControlPoints[ControlIndex].Z = (bNeedsWind || ChainLedgeCount > 0) + ? AuthoredFloor + FMath::Abs(CT.WorldControlRadii[ControlIndex]) + : FMath::Lerp(WorldEndA.Z, WorldEndB.Z, + static_cast(ControlIndex) + / static_cast(WanderSegments)); + } + // Pin Z as well as XY after the arc-length floor assignment. The profile starts and + // ends at the exact room-mouth feet planes even when the route has no horizontal run. + CT.WorldControlPoints[0] = WorldEndA; + CT.WorldControlPoints.Last() = WorldEndB; + + CT.ControlPoints.Reserve(WanderSegments + 1); + CT.ControlRadii.Reserve(WanderSegments + 1); + for (int32 ControlIndex = 0; + ControlIndex <= WanderSegments; + ++ControlIndex) + { + const FVector& WorldControlPoint = CT.WorldControlPoints[ControlIndex]; + const FVector ControlPoint = (ControlIndex == 0) + ? EndA + : ((ControlIndex == WanderSegments) + ? EndB + : VF_ApplyCaveWarp(WorldControlPoint, Params, Seed)); + CT.ControlPoints.Add(ControlPoint); + CT.ControlRadii.Add(CT.WorldControlRadii[ControlIndex]); + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + if (bDirectSteep) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorSteepEdges); + } + if (bNeedsWind) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorWindingEdges); + } + if (bDramaticLedge) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorDropEdges); + } + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorMultiStepEdges, 0); + } + + // Author the complete floor profile once, after both representations of the chain + // are known. Evaluation now only projects onto this immutable profile; no sample can + // independently decide how many terraces the tunnel needs. + VF_BuildTunnelFloorProfile( + CT.ControlPoints, CT.ControlRadii, CT, &CT.FloorLedgeLevels, + CT.FloorProfiles); + VF_BuildTunnelFloorProfile( + CT.WorldControlPoints, CT.WorldControlRadii, CT, &CT.FloorLedgeLevels, + CT.WorldFloorProfiles); + CT.bHasCompleteFloorProfile = CT.FloorProfiles.Num() == CT.ControlPoints.Num() - 1 + && CT.FloorProfiles.Num() > 0; + CT.bHasCompleteWorldFloorProfile = CT.WorldFloorProfiles.Num() + == CT.WorldControlPoints.Num() - 1 + && CT.WorldFloorProfiles.Num() > 0; + // The floor hand-off is a short apron around each destination, not a second floor + // that owns the whole room. Bake the same bounded region in both coordinate spaces; + // this keeps the room/tunnel boolean deterministic even though the cave warp is 3D. + CT.SDFMouthBlendRadiusA = FMath::Min( + FMath::Max(RoomA.RadiusXY, 0.0f), VF_TunnelMouthBlendRadiusVoxels); + CT.SDFMouthBlendRadiusB = FMath::Min( + FMath::Max(RoomB.RadiusXY, 0.0f), VF_TunnelMouthBlendRadiusVoxels); + CT.WorldMouthBlendRadiusA = CT.SDFMouthBlendRadiusA; + CT.WorldMouthBlendRadiusB = CT.SDFMouthBlendRadiusB; + if (CT.FloorProfiles.Num() == CT.ControlPoints.Num() - 1 + && CT.FloorProfiles.Num() > 0) + { + CT.SDFMouthFloorZA = CT.FloorProfiles[0].StartFloorZ; + CT.SDFMouthFloorZB = CT.FloorProfiles.Last().EndFloorZ; + } + if (CT.WorldFloorProfiles.Num() == CT.WorldControlPoints.Num() - 1 + && CT.WorldFloorProfiles.Num() > 0) + { + CT.WorldMouthFloorZA = CT.WorldFloorProfiles[0].StartFloorZ; + CT.WorldMouthFloorZB = CT.WorldFloorProfiles.Last().EndFloorZ; + } + else + { + CT.WorldMouthFloorZA = static_cast( + CT.WorldControlPoints[0].Z) + - FMath::Abs(CT.WorldControlRadii[0]); + CT.WorldMouthFloorZB = static_cast( + CT.WorldControlPoints.Last().Z) + - FMath::Abs(CT.WorldControlRadii.Last()); + } + + // Author optional room-side benches now that the tunnel mouth floors are known. A + // rise is admitted only toward a higher mouth and only when the room's own ceiling + // still contains the player capsule. The evaluator later reads this descriptor after + // the room's base floor is resolved; no per-sample tunnel search or correction is + // involved. Strength zero is the exact legacy path. + const float MouthRiseStrength = FMath::Clamp( + VoxelMath::IsFinite(Params.RoomMouthRiseStrength) + ? Params.RoomMouthRiseStrength : 0.0f, + 0.0f, 1.0f); + const float MouthRiseBlend = FMath::Clamp( + VoxelMath::IsFinite(Params.RoomMouthRiseBlendVoxels) + ? Params.RoomMouthRiseBlendVoxels : 8.0f, + 8.0f, 128.0f); + const float RequiredRoomClearance = + VoxelPassageGeometry::PlayerHeightVoxels + + VoxelPassageGeometry::HeadroomVoxels; + const auto AddRoomMouthRise = [ + &RoomMouthRises, &RoomFloorZFor, MouthRiseStrength, + MouthRiseBlend, RequiredRoomClearance]( + int32 RoomIndex, const FBuildRoom& Room, + const FVector& Mouth, float TargetFloorZ) + { + if (!(MouthRiseStrength > 0.0f) + || !VoxelMath::IsFinite(TargetFloorZ) + || !VoxelMath::IsFinite(Mouth.X) + || !VoxelMath::IsFinite(Mouth.Y) + || !VoxelMath::IsFinite(Mouth.Z)) + { + return; + } + const float BaseFloorZ = RoomFloorZFor(Room); + if (!VoxelMath::IsFinite(BaseFloorZ) + || TargetFloorZ <= BaseFloorZ + KINDA_SMALL_NUMBER + || Room.Center.Z + Room.RadiusZ - TargetFloorZ + < RequiredRoomClearance) + { + return; + } + FBuildRoomMouthRise& Rise = RoomMouthRises.Emplace_GetRef(); + Rise.RoomIndex = RoomIndex; + Rise.Mouth = Mouth; + Rise.TargetFloorZ = TargetFloorZ; + Rise.BlendRadius = MouthRiseBlend; + Rise.Strength = MouthRiseStrength; + }; + if (CT.ControlPoints.Num() >= 2) + { + AddRoomMouthRise( + I, RoomA, CT.ControlPoints[0], CT.SDFMouthFloorZA); + AddRoomMouthRise( + J, RoomB, CT.ControlPoints.Last(), CT.SDFMouthFloorZB); + } + + CT.Midpoint = CT.ControlPoints.Num() > 2 + ? CT.ControlPoints[CT.ControlPoints.Num() / 2] + : FVector::ZeroVector; + CT.RadiusMid = CT.ControlRadii.Num() > 2 + ? CT.ControlRadii[CT.ControlRadii.Num() / 2] + : 0.0f; + CT.bHasMidpoint = CT.ControlPoints.Num() > 2; + + // Bounding sphere: enclose every control point and the widest local tube. The bound + // is built from the complete chain, not a midpoint approximation, so culling cannot + // clip a bend at a chunk edge. + CT.BoundCenter = FVector::ZeroVector; + for (const FVector& Point : CT.ControlPoints) + { + CT.BoundCenter += Point; + } + CT.BoundCenter /= static_cast(FMath::Max(CT.ControlPoints.Num(), 1)); + float MaxTunnelRadius = 0.0f; + float BoundR = 0.0f; + for (int32 PointIndex = 0; PointIndex < CT.ControlPoints.Num(); ++PointIndex) + { + MaxTunnelRadius = FMath::Max( + MaxTunnelRadius, FMath::Abs(CT.ControlRadii[PointIndex])); + BoundR = FMath::Max( + BoundR, FVector::Dist(CT.BoundCenter, CT.ControlPoints[PointIndex])); + } + BoundR += MaxTunnelRadius + FMath::Max(BlendK, 0.0f) * 3.0f; + CT.BoundRadiusSq = BoundR * BoundR; + + // Keep a separate bound for the world-space structural backstop. The SDF and world + // chains have the same topology but their cave-warped coordinates do not share a + // useful rejection sphere. + CT.WorldBoundCenter = FVector::ZeroVector; + for (const FVector& Point : CT.WorldControlPoints) + { + CT.WorldBoundCenter += Point; + } + CT.WorldBoundCenter /= static_cast( + FMath::Max(CT.WorldControlPoints.Num(), 1)); + float WorldBoundR = 0.0f; + for (int32 PointIndex = 0; + PointIndex < CT.WorldControlPoints.Num(); + ++PointIndex) + { + WorldBoundR = FMath::Max( + WorldBoundR, + FVector::Dist(CT.WorldBoundCenter, CT.WorldControlPoints[PointIndex])); + } + // The support predicate reaches LandingFloorThickness below the centreline floor; + // the world sphere must cover that slab, not only the blended SDF tube. + WorldBoundR += MaxTunnelRadius + FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, + FMath::Max(BlendK, 0.0f)); + CT.WorldBoundRadiusSq = WorldBoundR * WorldBoundR; + + // Store if either representation can reach a voxel in this chunk. The warped SDF + // chain is needed by the room morphology; the world chain is needed by the final + // walkable-air backstop. Their bounds differ by design, so testing only the former + // could make a world-space route disappear at a chunk edge. + if (SphereTouchesSearchXY(CT.BoundCenter, CT.BoundRadiusSq) + || SphereTouchesSearchXY(CT.WorldBoundCenter, CT.WorldBoundRadiusSq)) + { + OutCache.Tunnels.Add(CT); + } + } + + //========================================================================== + // FLATTEN INTERSECTING ROOMS + //========================================================================== + // A spherical room union is not automatically walkable when the two rooms' floor cuts land + // at different Z values. For every overlapping, connected room pair, add a short flat bridge + // only inside their actual horizontal overlap. This is a floor repair, not a graph edge: it + // does not connect a landing to the origin and it never creates a corridor through unrelated + // cells. The common plane is the higher of the two existing floors, so it does not cut below a + // room's authored support; the bridge is admitted only when the shared player/headroom volume + // still fits below both room ceilings. + OutCache.RoomFloorJoins.Reserve(NumRooms / 2); + const float JoinRequiredHeight = + VoxelPassageGeometry::PlayerHeightVoxels + + VoxelPassageGeometry::HeadroomVoxels; + const float JoinMinimumRadius = + VoxelPassageGeometry::PlayerRadiusVoxels + 0.5f; + const float JoinCentreClearance = + VoxelPassageGeometry::PlayerRadiusVoxels + 2.0f; + for (int32 I = 0; I < NumRooms; ++I) + { + const FBuildRoom& RoomA = BuildRooms[I]; + if (!RoomA.bStore || !RoomConnected[I]) continue; + + for (int32 J = I + 1; J < NumRooms; ++J) + { + const FBuildRoom& RoomB = BuildRooms[J]; + if (!RoomB.bStore || !RoomConnected[J]) continue; + + const FVector2D DeltaXY( + RoomB.Center.X - RoomA.Center.X, + RoomB.Center.Y - RoomA.Center.Y); + const float DistanceXY = DeltaXY.Size(); + if (DistanceXY <= KINDA_SMALL_NUMBER + || DistanceXY >= RoomA.RadiusXY + RoomB.RadiusXY) + { + continue; + } + + const float VerticalReach = RoomA.RadiusZ + RoomB.RadiusZ; + if (FMath::Abs(RoomB.Center.Z - RoomA.Center.Z) >= VerticalReach) + { + continue; + } + + const float FloorA = RoomFloorZFor(RoomA); + const float FloorB = RoomFloorZFor(RoomB); + const float CommonFloor = FMath::Max(FloorA, FloorB); + const float SharedCeiling = FMath::Min( + RoomA.Center.Z + RoomA.RadiusZ, + RoomB.Center.Z + RoomB.RadiusZ); + if (!VoxelMath::IsFinite(CommonFloor) + || !VoxelMath::IsFinite(SharedCeiling) + || SharedCeiling - CommonFloor < JoinRequiredHeight) + { + continue; + } + + // Along the centre-centre line, the two projected disks overlap over this interval. + // Trim both ends so the bridge remains a local room-edge join and leaves each room's + // player-fit centre untouched for the pure landing query. + const float OverlapStart = FMath::Max( + 0.0f, DistanceXY - RoomB.RadiusXY); + const float OverlapEnd = FMath::Min( + DistanceXY, RoomA.RadiusXY); + const float OverlapLength = OverlapEnd - OverlapStart; + if (OverlapLength < 2.0f * JoinCentreClearance) + { + continue; + } + + const float EdgeInset = FMath::Min(2.0f, OverlapLength * 0.2f); + const float StartDistance = FMath::Max( + OverlapStart + EdgeInset, JoinCentreClearance); + const float EndDistance = FMath::Min( + OverlapEnd - EdgeInset, DistanceXY - JoinCentreClearance); + if (EndDistance <= StartDistance) + { + continue; + } + + const float JoinRadius = FMath::Min( + FMath::Min(RoomA.RadiusXY, RoomB.RadiusXY) * 0.35f, + (EndDistance - StartDistance) * 0.5f); + if (!VoxelMath::IsFinite(JoinRadius) || JoinRadius < JoinMinimumRadius) + { + continue; + } + + const FVector2D AxisXY = DeltaXY / DistanceXY; + const float JoinCentreZ = CommonFloor + 0.5f * JoinRequiredHeight; + FCachedRoomFloorJoin Join; + Join.Start = FVector( + RoomA.Center.X + AxisXY.X * StartDistance, + RoomA.Center.Y + AxisXY.Y * StartDistance, + JoinCentreZ); + Join.End = FVector( + RoomA.Center.X + AxisXY.X * EndDistance, + RoomA.Center.Y + AxisXY.Y * EndDistance, + JoinCentreZ); + Join.Radius = JoinRadius; + Join.FloorZ = CommonFloor; + Join.CeilingZ = CommonFloor + JoinRequiredHeight; + Join.BoundCenter = (Join.Start + Join.End) * 0.5f; + const float BoundRadius = 0.5f * FVector::Dist(Join.Start, Join.End) + + FMath::Abs(Join.Radius) + + FMath::Max(BlendK, 0.0f) * 3.0f; + Join.BoundRadiusSq = BoundRadius * BoundRadius; + + if (SphereTouchesSearchXY(Join.BoundCenter, Join.BoundRadiusSq)) + { + OutCache.RoomFloorJoins.Add(MoveTemp(Join)); + } + } + } + + //========================================================================= + // Copy STORE-relevant rooms to cache (cull radii + per-room terrain ops), + // then pre-bake their pits / chimneys / columns. + //========================================================================= + // Build terrain op pool stats once (outside the per-room loop). + // TerrainOps is the strate's probability pool; each entry has a Probability + // in [0,1]. We do a weighted random draw per room using the room's hash. + // + // PROBABILITY MODEL: + // - Entries are checked cumulatively (like drawing from a bucket). + // - The "no op" slot takes the remaining probability space (if sum < 1.0). + // - If sum >= 1.0, every room gets an op (normalized selection). + float TotalOpProb = 0.0f; + if (TerrainOps) + { + for (const FStrateTerrainOpEntry& E : *TerrainOps) + TotalOpProb += FMath::Max(E.Probability, 0.0f); + } + const float NormFactor = (TotalOpProb > 1.0f) ? (1.0f / TotalOpProb) : 1.0f; + + // Temporary params struct for reading op fields (PitDensity, PitMinRadius, etc.) + FStrateGenerationParams OpParams; + + OutCache.Rooms.Reserve(NumRooms); + + for (int32 RoomIdx = 0; RoomIdx < NumRooms; RoomIdx++) + { + const FBuildRoom& BR = BuildRooms[RoomIdx]; + if (!BR.bStore) continue; // Far room — collected for connectivity only + + // Sealed-bubble cull: a room no tunnel ever reaches would be an isolated air + // pocket — don't carve it at all. The origin landing is always kept (future shaft/root). + if (!RoomConnected[RoomIdx] && !BR.bIsOrigin) continue; + + FCachedRoom CR; + CR.Center = BR.Center; + CR.RadiusXY = BR.RadiusXY; + CR.RadiusZ = BR.RadiusZ; + CR.Hash = BR.Hash; + CR.bIsOrigin = BR.bIsOrigin; + + // Cull radius: max extent the room can reach + blend margin. + // 1.5x accounts for capsule shapes extending beyond nominal radius. + float MaxExtent = FMath::Max( + FMath::Abs(BR.RadiusXY) * 1.5f, + FMath::Abs(BR.RadiusZ)); + MaxExtent += FloorReliefEnvelope + BlendEnvelope * 3.0f; + + // --- PRE-BAKED SHAPE --- + // Same hash roll + thresholds + capsule trig the evaluator would otherwise redo PER VOXEL; + // done once here → EvaluateSDFCached just switches on ShapeType. Bit-identical output. + { + const uint32 ShapeHash = VoxelHash::Mix(CR.Hash ^ 0xDEADBEEFu); + const float ShapeRoll = CR.bIsOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); + const float BoxThreshold = 1.0f - Params.RoomShapeVariety * 0.5f; + const float CapsuleThreshold = 1.0f - Params.RoomShapeVariety * 0.2f; + + if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + { + // ROUNDED BOX: angular chamber with smooth corners + CR.ShapeType = 1; + CR.ShapeA = FVector(CR.RadiusXY * 0.8f, CR.RadiusXY * 0.8f, CR.RadiusZ * 0.8f); + CR.ShapeR = CR.RadiusXY * 0.25f; + } + else if (ShapeRoll >= CapsuleThreshold) + { + // ELONGATED CAPSULE: stretched hall/corridor-room + CR.ShapeType = 2; + const float DirAngle = VoxelHash::ToFloat01(VoxelHash::Mix(CR.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; + const float StretchDist = CR.RadiusXY * 0.7f; + float SinDir = 0.0f, CosDir = 0.0f; + VoxelMath::DetSinCos(SinDir, CosDir, DirAngle); + const FVector Dir(CosDir, SinDir, 0.0f); + CR.ShapeA = CR.Center + Dir * StretchDist; + CR.ShapeB = CR.Center - Dir * StretchDist; + CR.ShapeR = FMath::Min(CR.RadiusXY * 0.6f, CR.RadiusZ); + } + else + { + // ELLIPSOID (default): smooth oval chamber + CR.ShapeType = 0; + CR.ShapeA = FVector(CR.RadiusXY, CR.RadiusXY, CR.RadiusZ); + } + + // Use the actual pre-baked shape's farthest possible distance, not a guessed 1.5x + // sphere. The old guess underbounded a rounded-box corner when RadiusZ was large. + float ShapeReach = 0.0f; + if (CR.ShapeType == 1) + { + const float Round = FMath::Abs(CR.ShapeR); + const FVector Extent( + FMath::Abs(CR.ShapeA.X) + Round, + FMath::Abs(CR.ShapeA.Y) + Round, + FMath::Abs(CR.ShapeA.Z) + Round); + ShapeReach = Extent.Size(); + } + else if (CR.ShapeType == 2) + { + ShapeReach = 0.5f * FVector::Dist(CR.ShapeA, CR.ShapeB) + + FMath::Abs(CR.ShapeR); + } + else + { + ShapeReach = FMath::Max3( + FMath::Abs(CR.ShapeA.X), + FMath::Abs(CR.ShapeA.Y), + FMath::Abs(CR.ShapeA.Z)); + } + MaxExtent = ShapeReach + FloorReliefEnvelope + BlendEnvelope * 3.0f; + if (bHasFeatureReach) + { + MaxExtent = FMath::Max(MaxExtent, FeatureReach.TerrainOpReach); + } + CR.CullRadiusSq = MaxExtent * MaxExtent; + } + + // Flat floor cut: soft floor plane per room, hash-rolled from [Min, Max]. + // SmoothMax applied in EvaluateSDFCached so tunnels/pits don't create hard seams. + // Sentinel -FLT_MAX means "no cut" so the per-voxel check is a single compare. + { + const float FloorRoll = VoxelHash::ToFloat01(VoxelHash::Mix(BR.Hash ^ 0xF100F2u)); + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FloorRoll + ); + + if (FloorCut < 1.0f) + { + CR.FloorCutZ = CR.Center.Z - CR.RadiusZ * FloorCut; + CR.FloorReliefStrength = Params.FloorReliefStrength; + CR.FloorReliefFrequency = Params.FloorReliefFrequency; + CR.FloorSeed = VoxelHash::Mix(BR.Hash ^ 0xF100F1u); + } + else + { + CR.FloorCutZ = -FLT_MAX; + CR.FloorReliefStrength = 0.0f; + CR.FloorReliefFrequency = 0.015f; + CR.FloorSeed = 0; + } + } + + // --- PER-ROOM TERRAIN OP SELECTION --- + if (TerrainOps && TerrainOps->Num() > 0 && TotalOpProb > 0.0f) + { + const uint32 OpHash = VoxelHash::Mix(BR.Hash ^ 0x0FEED00u); + const float Roll = VoxelHash::ToFloat01(OpHash); + + float Cursor = 0.0f; + for (const FStrateTerrainOpEntry& E : *TerrainOps) + { + Cursor += FMath::Max(E.Probability, 0.0f) * NormFactor; + if (Roll < Cursor) + { + const UVoxelTerrainOpDefinition* Op = E.Operation.Get(); + if (Op) + { + CR.RoomOp = Op; + CR.RoomOpWeight = E.Weight; + } + break; + } + } + } + + // ARCHES/PINCHES — all geometry below is a pure function of the room hash and the + // room-local terrain parameters. Bake the hash chains and the two trigonometric pairs + // once per room instead of once per near-surface voxel. The active decision uses the + // same `ToFloat01(H) > Density` predicate as the old loop; only its location changes. + FStrateGenerationParams FeatureParams = Params; + if (CR.RoomOp) + { + CR.RoomOp->ApplyTo(FeatureParams, CR.RoomOpWeight); + } + for (int32 i = 0; i < 3; ++i) + { + const uint32 AH = VoxelHash::Mix(CR.Hash ^ (0xA4C400u + (uint32)i * 7369u)); + if (VoxelHash::ToFloat01(AH) <= FeatureParams.ArchDensity) + { + const uint32 AH2 = VoxelHash::Mix(AH ^ 0xA4C4u); + const float ArcCX = CR.Center.X + + VoxelHash::ToFloatSigned(AH2) * CR.RadiusXY * 0.3f; + const float ArcCY = CR.Center.Y + + VoxelHash::ToFloatSigned(VoxelHash::Mix(AH2)) * CR.RadiusXY * 0.3f; + const uint32 AH3 = VoxelHash::Mix(AH2 ^ 0xB41Du); + const float ArcCZ = CR.Center.Z + + VoxelHash::ToFloatSigned(AH3) * CR.RadiusZ * 0.4f; + const uint32 AH4 = VoxelHash::Mix(AH3 ^ 0xCAFEu); + const float Angle = VoxelHash::ToFloat01(AH4) * PI; + const float HalfSpan = CR.RadiusXY + * (0.5f + VoxelHash::ToFloat01(VoxelHash::Mix(AH4)) * 0.35f); + float SinA = 0.0f, CosA = 0.0f; + VoxelMath::DetSinCos(SinA, CosA, Angle); + FCachedArch& Arch = CR.Arches[i]; + Arch.bActive = true; + Arch.EndpointA = FVector(ArcCX - CosA * HalfSpan, + ArcCY - SinA * HalfSpan, ArcCZ); + Arch.EndpointB = FVector(ArcCX + CosA * HalfSpan, + ArcCY + SinA * HalfSpan, ArcCZ); + const uint32 AH5 = VoxelHash::Mix(AH4 ^ 0xF00Du); + Arch.Radius = FMath::Lerp( + FeatureParams.ArchMinRadius, FeatureParams.ArchMaxRadius, + VoxelHash::ToFloat01(AH5)); + Arch.BaseDensity = FeatureParams.BaseDensity; + } + + const uint32 PnH = VoxelHash::Mix(CR.Hash ^ (0xF1C400u + (uint32)i * 5417u)); + if (VoxelHash::ToFloat01(PnH) <= FeatureParams.PinchDensity) + { + const uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u); + const float PnX = CR.Center.X + + VoxelHash::ToFloatSigned(PnH2) * CR.RadiusXY * 0.85f; + const float PnY = CR.Center.Y + + VoxelHash::ToFloatSigned(VoxelHash::Mix(PnH2)) * CR.RadiusXY * 0.85f; + const uint32 PnH3 = VoxelHash::Mix(PnH2 ^ 0x5432u); + const float PnZ = CR.Center.Z + + VoxelHash::ToFloatSigned(PnH3) * CR.RadiusZ * 0.5f; + const uint32 PnH4 = VoxelHash::Mix(PnH3 ^ 0x9A3Bu); + const float PnAngle = VoxelHash::ToFloat01(PnH4) * PI; + FCachedPinch& Pinch = CR.Pinches[i]; + Pinch.bActive = true; + Pinch.CenterX = PnX; + Pinch.CenterY = PnY; + Pinch.CenterZ = PnZ; + VoxelMath::DetSinCos(Pinch.SinAngle, Pinch.CosAngle, PnAngle); + Pinch.MaxExtent = FMath::Max( + FeatureParams.PinchLength, FeatureParams.PinchStrength) + 5.0f; + Pinch.HalfLength = FeatureParams.PinchLength * 0.5f; + Pinch.HalfNarrow = FeatureParams.PinchStrength; + Pinch.HalfVertical = FeatureParams.PinchStrength * 1.5f; + Pinch.BaseDensity = FeatureParams.BaseDensity; + } + } + + // Attach the complete room-side mouth descriptors after the room shape/floor fields are + // baked. Expand the conservative room cull to cover the local blend even when the mouth + // is offset from the room centre. + for (const FBuildRoomMouthRise& AuthoredRise : RoomMouthRises) + { + if (AuthoredRise.RoomIndex != RoomIdx) + { + continue; + } + FCachedRoomMouthRise& Rise = CR.MouthRises.Emplace_GetRef(); + Rise.Mouth = AuthoredRise.Mouth; + Rise.TargetFloorZ = AuthoredRise.TargetFloorZ; + Rise.BlendRadius = AuthoredRise.BlendRadius; + Rise.Strength = AuthoredRise.Strength; + const float Reach = static_cast(FVector::Dist( + CR.Center, Rise.Mouth)) + FMath::Max(Rise.BlendRadius, 0.0f); + if (VoxelMath::IsFinite(Reach)) + { + CR.CullRadiusSq = FMath::Max(CR.CullRadiusSq, Reach * Reach); + } + } + + OutCache.Rooms.Add(CR); + + //--------------------------------------------------------------------- + // PRE-BAKE: PITS, CHIMNEYS, COLUMNS for this room + //--------------------------------------------------------------------- + // Pre-baking makes features independent of NearestRoomIdx (no thin "lid" + // when the owning room flips mid-shaft). Only stored rooms are baked — + // a far room's features can't reach this chunk anyway. + if (!CR.RoomOp) continue; + + OpParams = FStrateGenerationParams{}; + CR.RoomOp->ApplyTo(OpParams, CR.RoomOpWeight); + + // PITS — downward shafts anchored in the room's lower half. + if (!VoxelDensityAblation::IsPitChimneySDFOff()) + { + BakeRoomFeature(CR, /*Max*/2, OpParams.PitDensity, + 0xDE1A7Eu, 6271u, 0xABCDu, 0x5EEDu, + /*XYScale*/0.6f, OpParams.PitMinRadius, OpParams.PitMaxRadius, + [&](float PX, float PY, float PitRadius, uint32 PH3) + { + const uint32 PH4 = VoxelHash::Mix(PH3 ^ 0xF00Du); + FCachedPit Pit; + Pit.CenterX = PX; + Pit.CenterY = PY; + Pit.TopZ = CR.Center.Z - CR.RadiusZ * 0.5f + + VoxelHash::ToFloat01(PH4) * CR.RadiusZ * 0.2f; + Pit.Radius = PitRadius; + Pit.Depth = OpParams.PitDepth; + Pit.FlareDist = PitRadius * 2.0f; + Pit.FlareExtra = PitRadius * 1.0f; + Pit.BaseDensity = Params.BaseDensity; + Pit.BlendK = Params.SDFBlendRadius; + const float MaxXYR = PitRadius + PitRadius + Params.SDFBlendRadius + 4.0f; + Pit.BoundXYRadiusSq = MaxXYR * MaxXYR; + OutCache.Pits.Add(Pit); + }); + + // CHIMNEYS — mirror of pits: upward tubes anchored in the room's upper half. + BakeRoomFeature(CR, /*Max*/2, OpParams.ChimneyDensity, + 0xC4F007u, 7919u, 0x1337u, 0xCAFEu, + /*XYScale*/0.6f, OpParams.ChimneyMinRadius, OpParams.ChimneyMaxRadius, + [&](float CX, float CY, float ChmRadius, uint32 CH3) + { + const uint32 CH4 = VoxelHash::Mix(CH3 ^ 0xD00Du); + FCachedChimney Chim; + Chim.CenterX = CX; + Chim.CenterY = CY; + Chim.BottomZ = CR.Center.Z + CR.RadiusZ * 0.5f + - VoxelHash::ToFloat01(CH4) * CR.RadiusZ * 0.2f; + Chim.Radius = ChmRadius; + Chim.Height = OpParams.ChimneyHeight; + Chim.FlareDist = ChmRadius * 2.0f; + Chim.FlareExtra = ChmRadius * 1.0f; + Chim.BaseDensity = Params.BaseDensity; + Chim.BlendK = Params.SDFBlendRadius; + const float MaxXYR = ChmRadius + ChmRadius + Params.SDFBlendRadius + 4.0f; + Chim.BoundXYRadiusSq = MaxXYR * MaxXYR; + OutCache.Chimneys.Add(Chim); + }); + } + + // COLUMNS — full-height solid cylinders (no Z anchor, no flare). + BakeRoomFeature(CR, /*Max*/4, OpParams.ColumnDensity, + 0xC01C01u, 3571u, 0x1A2B3Cu, 0xBEEFu, + /*XYScale*/0.75f, OpParams.ColumnMinRadius, OpParams.ColumnMaxRadius, + [&](float ColX, float ColY, float ColR, uint32 /*H3*/) + { + FCachedColumn Col; + Col.CenterX = ColX; + Col.CenterY = ColY; + Col.Radius = ColR; + Col.BaseDensity = Params.BaseDensity; + const float MaxXYR = ColR + 6.0f; + Col.BoundXYRadiusSq = MaxXYR * MaxXYR; + OutCache.Columns.Add(Col); + }); + } + + // All candidate arrays are complete now. Build the immutable broad phase only after every + // deterministic room/tunnel decision and feature bake has finished. + VF_FinalizeCacheBroadPhaseBounds(OutCache, BlendK); +} + +//============================================================================= +// PHASE 2: EVALUATE SDF WITH CACHED DATA +//============================================================================= +// Pure SDF math — no hashing, no array building, no backbone computation. +// Just loops through the pre-built rooms and tunnels, evaluates distance, +// and smooth-mins everything together. Distance culling skips primitives +// that are clearly too far to contribute. + +float VoxelCaveMorphology::EvaluateSDFCached( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + float SDFBlendRadius, + int32* OutNearestRoomIdx, + bool bUseSpatialIndex, + const FVector* WorldTunnelPosition, + bool* OutRoomOwnsBottom) +{ + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::RoomGraphSdf); + float MinSDF = FLT_MAX; + const float BlendK = SDFBlendRadius; + const FVector Pos(WorldX, WorldY, WorldZ); + const FVFFloorReliefColumnKey ReliefColumn = + VF_MakeFloorReliefColumnKey(Pos.X, Pos.Y); + const FCachedRoom* RoomData = Cache.Rooms.GetData(); + const FCachedRoomFloorJoin* RoomFloorJoinData = Cache.RoomFloorJoins.GetData(); + const FCachedTunnel* TunnelData = Cache.Tunnels.GetData(); + + // Track which room contributes the smallest (most-inside) raw SDF. + // This is used by the terrain ops system to find the "owning" room for + // this voxel and apply that room's per-room terrain operation. + // We track raw room SDF (before SmoothMin) so tunnel SDFs don't interfere. + float NearestRoomRawSDF = FLT_MAX; + int32 NearestIdx = -1; + // EvaluateRoom already computes the exact raw authored room shape used by the room field. + // Keep its sign for the room-ownership rule instead of traversing the room spatial index a + // second time after the room pass. + bool bInsideRoomShape = false; + + //========================================================================= + // Room SDFs + //========================================================================= + auto EvaluateRoom = [&](int32 RoomIdx) + { + const FCachedRoom& Room = RoomData[RoomIdx]; + + // --- DISTANCE CULL --- + const float DistSq = VF_FloatDistSquared(WorldX, WorldY, WorldZ, Room.Center); + if (DistSq > Room.CullRadiusSq) return; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveRoomEvaluated); + } + + // --- SHAPE (pre-baked in BuildChunkCache — no per-voxel hash roll / trig) --- + float RoomSDF; + switch (Room.ShapeType) + { + case 1: RoomSDF = VoxelSDF::RoundedBox(Pos, Room.Center, Room.ShapeA, Room.ShapeR); break; + case 2: RoomSDF = VoxelSDF::Capsule(Pos, Room.ShapeA, Room.ShapeB, Room.ShapeR); break; + default: RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Room.ShapeA); break; + } + // This is deliberately before the optional floor cut. Room ownership is defined by the + // authored body, not by the room's raised/relieved floor SDF. + if (RoomSDF < 0.0f) + { + bInsideRoomShape = true; + } + + // Soft floor: SmoothMax of the room SDF and the floor half-space. + if (Room.FloorCutZ > -FLT_MAX) + { + float FloorZ = Room.FloorCutZ; + for (const FCachedRoomMouthRise& Rise : Room.MouthRises) + { + const float DX = static_cast(Pos.X - Rise.Mouth.X); + const float DY = static_cast(Pos.Y - Rise.Mouth.Y); + const float Radius = FMath::Max(Rise.BlendRadius, 1.0f); + const float Distance = FMath::Sqrt(DX * DX + DY * DY); + if (Distance >= Radius + || !(Rise.Strength > 0.0f) + || !VoxelMath::IsFinite(Rise.TargetFloorZ)) + { + continue; + } + const float Weight = 1.0f - SmoothStep01( + FMath::Clamp(Distance / Radius, 0.0f, 1.0f)); + const float DesiredFloorZ = FMath::Lerp( + FloorZ, Rise.TargetFloorZ, + Weight * FMath::Clamp(Rise.Strength, 0.0f, 1.0f)); + // A lower tunnel never cuts down into the room. The room remains the owner of + // its floor, while a higher mouth receives a deliberate, smooth bench. + FloorZ = FMath::Max(FloorZ, DesiredFloorZ); + } + const float FloorBlend = BlendK * 0.35f; + bool bFloorCutIsIdentity = false; + if (Room.FloorReliefStrength > 0.0f) + { + float ReliefBound = 0.0f; + if (VF_GetFloorReliefBound( + Room.FloorReliefStrength, 1.0f, 1.0f, + Room.FloorReliefFrequency, ReliefBound)) + { + // SmoothMax(A, B, K) is exactly A when A >= B + K. Use the highest + // possible relieved floor as B's bound. The extra margin is deliberately + // conservative; an uncertain sample pays for the two noise octaves. + bFloorCutIsIdentity = RoomSDF >= + Room.FloorCutZ + ReliefBound - static_cast(Pos.Z) + + FMath::Max(FloorBlend, 0.0f); + } + + if (!bFloorCutIsIdentity) + { + float N = VF_FloorReliefNoiseDouble( + Pos.X, Pos.Y, + Room.FloorSeed, Room.FloorReliefFrequency, + &Cache, EVFFloorReliefFeature::RoomDouble, RoomIdx, + ReliefColumn); + N *= VOXEL_NOISE_SCALE; + FloorZ += N * Room.FloorReliefStrength; + } + } + + if (!bFloorCutIsIdentity) + { + RoomSDF = VoxelSDF::SmoothMax(RoomSDF, FloorZ - Pos.Z, FloorBlend); + } + } + + // Track the room whose SDF is smallest (most inside). + if (OutNearestRoomIdx && RoomSDF < NearestRoomRawSDF) + { + NearestRoomRawSDF = RoomSDF; + NearestIdx = RoomIdx; + } + + MinSDF = VoxelSDF::SmoothMin(MinSDF, RoomSDF, BlendK); + }; + + int32 RoomCandidateCount = 0; + if (!VoxelDensityAblation::IsRoomSDFOff()) + { + RoomCandidateCount = VF_ForEachSDFCandidate( + Cache.RoomSpatialIndex, +#if WITH_EDITOR + Cache.RoomBrickIndex, +#endif + Cache.Rooms.Num(), WorldX, WorldY, WorldZ, EvaluateRoom, bUseSpatialIndex); + } + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveRoomCandidates, + static_cast(RoomCandidateCount)); + } + const bool bRoomOwnsBottom = !VoxelDensityAblation::IsRoomSDFOff() + && bInsideRoomShape; + if (OutRoomOwnsBottom) + { + *OutRoomOwnsBottom = bRoomOwnsBottom; + } + + //========================================================================== + // Flattened room joins + //========================================================================== + // These short bridges exist only where two room bodies already overlap. Their floor is a + // single horizontal plane, so a player crossing the shared volume cannot meet the tall lip + // produced by two independent floor cuts. They are deliberately not reported as rooms and do + // not participate in nearest-room terrain-op ownership. + auto EvaluateJoin = [&](int32 JoinIdx) + { + const FCachedRoomFloorJoin& Join = RoomFloorJoinData[JoinIdx]; + const float DistSq = VF_FloatDistSquared(WorldX, WorldY, WorldZ, Join.BoundCenter); + if (DistSq > Join.BoundRadiusSq) return; + + const float HorizontalSDF = VoxelSDF::Capsule( + Pos, Join.Start, Join.End, Join.Radius); + const float VerticalSDF = FMath::Max( + Join.FloorZ - Pos.Z, + Pos.Z - Join.CeilingZ); + const float JoinSDF = FMath::Max(HorizontalSDF, VerticalSDF); + MinSDF = VoxelSDF::SmoothMin(MinSDF, JoinSDF, BlendK); + }; + if (!VoxelDensityAblation::IsRoomSDFOff()) + { + VF_ForEachSDFCandidate( + Cache.RoomFloorJoinSpatialIndex, +#if WITH_EDITOR + Cache.RoomFloorJoinBrickIndex, +#endif + Cache.RoomFloorJoins.Num(), WorldX, WorldY, WorldZ, + EvaluateJoin, bUseSpatialIndex); + } + + //========================================================================= + // Tunnel SDFs + //========================================================================= + // Keep the tunnel source in the authored world frame when the caller supplies it. Rooms + // intentionally remain at the warped query position; the world-chain tunnel is the same + // continuous field used by the core hand-off below, so the source cannot form a second, + // differently-shaped surface around the route. + const bool bUseWorldTunnel = WorldTunnelPosition != nullptr; + const FVector TunnelPosition = bUseWorldTunnel ? *WorldTunnelPosition : Pos; + // A room owns every point inside its authored body. The short mouth ownership fade below + // handles the fillet outside the wall; this broader test is the hard A > B rule for the room + // interior. The exact raw shape sign was captured during the room SDF pass above, so this + // point does not perform a second spatial-index traversal. + auto EvaluateTunnel = [&](int32 TunnelIdx) + { + const FCachedTunnel& Tunnel = TunnelData[TunnelIdx]; + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const bool bUseWorldChain = bUseWorldTunnel && bHasWorldChain; + const FVector& QueryPosition = bUseWorldChain ? TunnelPosition : Pos; + const FVector& CenterlineMin = bUseWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bUseWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + const float InfluenceRadius = bUseWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + const FVector& BoundCenter = bUseWorldChain + ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; + const float BoundRadiusSq = bUseWorldChain + ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; + if (InfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + QueryPosition.X, QueryPosition.Y, QueryPosition.Z, + CenterlineMin, CenterlineMax) + > FMath::Square(InfluenceRadius)) + { + return; + } + // --- BOUNDING SPHERE CULL --- + const float DistSq = VF_FloatDistSquared( + QueryPosition.X, QueryPosition.Y, QueryPosition.Z, BoundCenter); + if (DistSq > BoundRadiusSq) return; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveTunnelEvaluated); + } + + // The corridor is a swept floor cut: each tapered capsule segment is intersected with + // its build-time floor profile before the tunnel union is blended into rooms. The room's + // finite mouth ownership is also passed into that same shape evaluation; this is the SDF + // side of A > B, while the world-space post keeps the disturbance path consistent. + FVFRoomFloorOwnership RoomMouthOwnership; + const FVFRoomFloorOwnership* RoomMouthOwnershipPtr = nullptr; + if (!bRoomOwnsBottom + && VF_MayHaveTunnelMouthOwnership( + QueryPosition, Tunnel, bUseWorldChain, BlendK)) + { + RoomMouthOwnership = bUseWorldChain + ? VF_FindWorldTunnelMouthOwnership(QueryPosition, Tunnel, BlendK) + : VF_FindSDFTunnelMouthOwnership(Pos, Tunnel, BlendK); + if (RoomMouthOwnership.bValid || RoomMouthOwnership.bSuppressTunnelBottom) + { + RoomMouthOwnershipPtr = &RoomMouthOwnership; + } + } + float TunnelSDF = 0.0f; + if (!bRoomOwnsBottom && RoomMouthOwnershipPtr == nullptr) + { + // Keep the overwhelmingly common open-corridor path on the same literal arguments + // as the pre-ownership evaluator. Besides avoiding the ownership checks, this lets + // the compiler fold the floor-cut and null-ownership branches in the swept kernel. + TunnelSDF = VF_EvaluateSweptTunnel( + QueryPosition, Tunnel, bUseWorldChain, BlendK, + /*bApplyFloorCut=*/true, &Cache, TunnelIdx).SDF; + } + else + { + TunnelSDF = VF_EvaluateSweptTunnel( + QueryPosition, Tunnel, bUseWorldChain, BlendK, + /*bApplyFloorCut=*/!(bRoomOwnsBottom + || (RoomMouthOwnershipPtr != nullptr + && RoomMouthOwnershipPtr->bSuppressTunnelBottom)), + &Cache, TunnelIdx, RoomMouthOwnershipPtr).SDF; + } + + MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); + }; + + int32 TunnelCandidateCount = 0; + if (!VoxelDensityAblation::IsTunnelSDFOff()) + { + TunnelCandidateCount = VF_ForEachSDFCandidate( + Cache.TunnelSpatialIndex, +#if WITH_EDITOR + Cache.TunnelBrickIndex, +#endif + Cache.Tunnels.Num(), static_cast(TunnelPosition.X), + static_cast(TunnelPosition.Y), static_cast(TunnelPosition.Z), + EvaluateTunnel, bUseSpatialIndex); + } + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveTunnelCandidates, + static_cast(TunnelCandidateCount)); + } + + // Write nearest room index for the caller (terrain ops system) + if (OutNearestRoomIdx) + { + *OutNearestRoomIdx = NearestIdx; + } + + return MinSDF; +} + +FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + const FTunnelSupportFloorColumn* SupportColumn, + bool bUseSpatialIndex, + const FVector* RoomQueryPosition, + const bool* CachedRoomOwnsBottom) +{ + VoxelGenLOD::FScopedReachCost ReachCost( + VoxelGenLOD::ETileReachCostKind::TunnelCoreWorld); + if (VoxelDensityAblation::IsTunnelCoreOff()) + { + return FTunnelCoreWorldEvaluation(); + } + // GenerateMesh installs the exact tile/block reach mask after building the complete tile + // cache. Point queries retain the canonical all-reachable TLS default; debug proof calls set + // the bypass bit so the same sample can be evaluated as the reference field. + // A reach mask is meaningful only inside the mesher's proven tile session. Point queries + // (including the owner-value probe and gameplay queries) must retain the canonical core even + // if another caller left a conservative flag in this worker's TLS. The mesher enables the + // proof bit only after it has installed the exact tile-window decision, so this guard cannot + // turn an ordinary query into a skipped core. + if (VoxelGenLOD::bTileCoreReachProofEnabled + && VoxelGenLOD::bTileReachDiagnosticTile + && !VoxelGenLOD::IsTilePostReachBypassActive() + && !VoxelGenLOD::IsTunnelCoreReachable()) + { + return FTunnelCoreWorldEvaluation(); + } + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCoreWorld); + const FVector Pos(WorldX, WorldY, WorldZ); + FTunnelCoreWorldEvaluation Result; + const FCachedTunnel* TunnelData = Cache.Tunnels.GetData(); + const FVector& RoomPosition = RoomQueryPosition != nullptr + ? *RoomQueryPosition : Pos; + const bool bRoomOwnsBottom = CachedRoomOwnsBottom != nullptr + ? *CachedRoomOwnsBottom + : VF_IsInsideCachedRoomShape(RoomPosition, Cache, bUseSpatialIndex); + + if (SupportColumn != nullptr && VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorQueries); + } + auto EvaluateTunnel = [&](int32 TunnelIdx) + { + const FCachedTunnel& Tunnel = TunnelData[TunnelIdx]; + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + bool bSDFNear = true; + if (Tunnel.SDFInfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) + > FMath::Square(Tunnel.SDFInfluenceRadius)) + { + bSDFNear = false; + } + if (Tunnel.BoundRadiusSq > 0.0f + && VF_FloatDistSquared( + WorldX, WorldY, WorldZ, Tunnel.BoundCenter) > Tunnel.BoundRadiusSq) + { + bSDFNear = false; + } + bool bWorldNear = false; + if (bHasWorldChain) + { + bWorldNear = true; + if (Tunnel.WorldInfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, + Tunnel.WorldCenterlineMin, Tunnel.WorldCenterlineMax) + > FMath::Square(Tunnel.WorldInfluenceRadius)) + { + bWorldNear = false; + } + if (Tunnel.WorldBoundRadiusSq > 0.0f + && VF_FloatDistSquared( + WorldX, WorldY, WorldZ, Tunnel.WorldBoundCenter) + > Tunnel.WorldBoundRadiusSq) + { + bWorldNear = false; + } + } + if (!bSDFNear && !bWorldNear) + { + return; + } + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } + + FVFRoomFloorOwnership RoomMouthOwnership; + const FVFRoomFloorOwnership* RoomMouthOwnershipPtr = nullptr; + if (!bRoomOwnsBottom + && VF_MayHaveTunnelMouthOwnership( + Pos, Tunnel, /*bWorldChain=*/true, Cache.SDFBlendRadius)) + { + RoomMouthOwnership = + VF_FindWorldTunnelMouthOwnership(Pos, Tunnel, Cache.SDFBlendRadius); + if (RoomMouthOwnership.bValid || RoomMouthOwnership.bSuppressTunnelBottom) + { + RoomMouthOwnershipPtr = &RoomMouthOwnership; + } + } + // The world chain is the authored tunnel shape. Its floor is evaluated by the same + // SmoothMax profile as the cave-warped chain; there is no second support-column field and + // no hard floor hand-off at a segment boundary. Keep the legacy argument source-compatible + // for callers compiled against the previous probe API, but deliberately ignore it. + (void)SupportColumn; + + FVFTunnelShapeEvaluation TunnelShape; + if (!bRoomOwnsBottom && RoomMouthOwnershipPtr == nullptr) + { + TunnelShape = VF_EvaluateSweptTunnel( + Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, + /*bApplyFloorCut=*/true, &Cache, TunnelIdx); + } + else + { + TunnelShape = VF_EvaluateSweptTunnel( + Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, + /*bApplyFloorCut=*/!(bRoomOwnsBottom + || (RoomMouthOwnershipPtr != nullptr + && RoomMouthOwnershipPtr->bSuppressTunnelBottom)), + &Cache, TunnelIdx, RoomMouthOwnershipPtr); + } + if (TunnelShape.SDF < Result.SDF) + { + Result.SDF = TunnelShape.SDF; + Result.bRoomFloor = bRoomOwnsBottom + || (RoomMouthOwnershipPtr != nullptr + && RoomMouthOwnershipPtr->bSuppressTunnelBottom); + Result.bHasSweptFloor = TunnelShape.bHasSweptFloor; + Result.SweptFloorZ = TunnelShape.SweptFloorZ; + Result.SweptFloorRadius = TunnelShape.SweptFloorRadius; + } + }; + + const int32 TunnelCandidateCount = VF_ForEachSDFCandidate( + Cache.TunnelSpatialIndex, +#if WITH_EDITOR + Cache.TunnelBrickIndex, +#endif + Cache.Tunnels.Num(), WorldX, WorldY, WorldZ, + EvaluateTunnel, bUseSpatialIndex); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreCandidates, + static_cast(TunnelCandidateCount)); + } + + return Result; +} + +bool VoxelCaveMorphology::AnyTunnelCoreWorldNearLattice( + const FChunkSDFCache& Cache, + const FBox& VoxelBox, + float ReachScale) +{ + if (VoxelDensityAblation::IsTunnelCoreOff()) + { + return false; + } + if (!VoxelBox.IsValid + || !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f) + { + return true; + } + + const auto TouchesSphere = [&VoxelBox](const FVector& Center, float Radius) -> bool + { + if (!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)VoxelBox.Min.X, (float)VoxelBox.Max.X), + FMath::Clamp(Center.Y, (float)VoxelBox.Min.Y, (float)VoxelBox.Max.Y), + FMath::Clamp(Center.Z, (float)VoxelBox.Min.Z, (float)VoxelBox.Max.Z)); + return FVector::DistSquared(Closest, Center) <= Radius * Radius; + }; + + const auto TouchesExpandedAabb = [&VoxelBox]( + const FVector& Min, const FVector& Max, float Pad) -> 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) + || !VoxelMath::IsFinite(Pad) || Pad < 0.0f) + { + return true; + } + const float DX = FMath::Max3( + static_cast(VoxelBox.Min.X) - static_cast(Max.X), + static_cast(Min.X) - static_cast(VoxelBox.Max.X), 0.0f); + const float DY = FMath::Max3( + static_cast(VoxelBox.Min.Y) - static_cast(Max.Y), + static_cast(Min.Y) - static_cast(VoxelBox.Max.Y), 0.0f); + const float DZ = FMath::Max3( + static_cast(VoxelBox.Min.Z) - static_cast(Max.Z), + static_cast(Min.Z) - static_cast(VoxelBox.Max.Z), 0.0f); + return DX <= Pad && DY <= Pad && DZ <= Pad; + }; + + const float Fade = FMath::Max(Cache.SDFBlendRadius, 0.0f) * 0.35f; + if (!VoxelMath::IsFinite(Fade)) + { + return true; + } + + for (const FCachedTunnel& Tunnel : Cache.Tunnels) + { + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const FVector& BoundCenter = bHasWorldChain + ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; + const float BoundRadiusSq = bHasWorldChain + ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; + const float InfluenceRadius = bHasWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + const FVector& CenterlineMin = bHasWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bHasWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + + if (!VoxelMath::IsFinite(BoundCenter.X) + || !VoxelMath::IsFinite(BoundCenter.Y) + || !VoxelMath::IsFinite(BoundCenter.Z) + || !VoxelMath::IsFinite(BoundRadiusSq) || BoundRadiusSq < 0.0f + || !VoxelMath::IsFinite(InfluenceRadius) || InfluenceRadius < 0.0f + || !VoxelMath::IsFinite(CenterlineMin.X) + || !VoxelMath::IsFinite(CenterlineMin.Y) + || !VoxelMath::IsFinite(CenterlineMin.Z) + || !VoxelMath::IsFinite(CenterlineMax.X) + || !VoxelMath::IsFinite(CenterlineMax.Y) + || !VoxelMath::IsFinite(CenterlineMax.Z)) + { + return true; + } + if (bHasWorldChain) + { + for (int32 Index = 0; Index < Tunnel.WorldControlPoints.Num(); ++Index) + { + const FVector& Point = Tunnel.WorldControlPoints[Index]; + if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z) + || !VoxelMath::IsFinite(Tunnel.WorldControlRadii[Index])) + { + return true; + } + } + } + + // VF_TunnelFloorRelief uses the same bounded noise as the floor profile. Use the + // profile-independent maximum scale (1.0) here: the normal authored scale is <= .20, + // but this remains sound for an old or malformed profile that bypassed that clamp. + float ReliefBound = 0.0f; + if (Tunnel.FloorReliefStrength > 0.0f) + { + if (!VoxelMath::IsFinite(Tunnel.FloorReliefStrength) + || !VoxelMath::IsFinite(Tunnel.FloorReliefFrequency)) + { + return true; + } + ReliefBound = FMath::Abs(Tunnel.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.01f; + if (!VoxelMath::IsFinite(ReliefBound)) + { + return true; + } + } + + float MouthReach = 0.0f; + float MouthFloorDelta = 0.0f; + if (bHasWorldChain && Tunnel.bHasFloorRoomOwnership) + { + MouthReach = FMath::Max( + FMath::Max(Tunnel.WorldMouthBlendRadiusA, Tunnel.WorldMouthBlendRadiusB), + 0.0f) + Fade + 1.0f; + if (!VoxelMath::IsFinite(MouthReach)) + { + return true; + } + const float NaturalA = static_cast(Tunnel.WorldControlPoints[0].Z) + - FMath::Abs(Tunnel.WorldControlRadii[0]); + const int32 Last = Tunnel.WorldControlPoints.Num() - 1; + const float NaturalB = static_cast(Tunnel.WorldControlPoints[Last].Z) + - FMath::Abs(Tunnel.WorldControlRadii[Last]); + if (!VoxelMath::IsFinite(NaturalA) || !VoxelMath::IsFinite(NaturalB)) + { + return true; + } + if (Tunnel.WorldMouthFloorZA > -FLT_MAX + && !VoxelMath::IsFinite(Tunnel.WorldMouthFloorZA)) + { + return true; + } + if (Tunnel.WorldMouthFloorZB > -FLT_MAX + && !VoxelMath::IsFinite(Tunnel.WorldMouthFloorZB)) + { + return true; + } + if (Tunnel.WorldMouthFloorZA > -FLT_MAX) + { + MouthFloorDelta = FMath::Max( + MouthFloorDelta, FMath::Abs(Tunnel.WorldMouthFloorZA - NaturalA)); + } + if (Tunnel.WorldMouthFloorZB > -FLT_MAX) + { + MouthFloorDelta = FMath::Max( + MouthFloorDelta, FMath::Abs(Tunnel.WorldMouthFloorZB - NaturalB)); + } + } + + // Tighten the construction sphere to the actual swept chain before falling back to the + // legacy bound. The evaluator tests a capsule segment, not the sphere around the whole + // wandering chain; using segment AABBs removes the long-chain false near result while + // retaining a conservative pad for radius, floor band, relief, and SmoothMax blend. + const TArray& ChainPoints = bHasWorldChain + ? Tunnel.WorldControlPoints : Tunnel.ControlPoints; + const TArray& ChainRadii = bHasWorldChain + ? Tunnel.WorldControlRadii : Tunnel.ControlRadii; + bool bHasTightChain = ChainPoints.Num() >= 2 + && ChainRadii.Num() == ChainPoints.Num(); + bool bTightTouch = false; + if (bHasTightChain) + { + float MaxRadius = 0.0f; + for (int32 Index = 0; Index < ChainPoints.Num(); ++Index) + { + const FVector& Point = ChainPoints[Index]; + if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z) + || !VoxelMath::IsFinite(ChainRadii[Index])) + { + bHasTightChain = false; + break; + } + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(ChainRadii[Index])); + } + if (bHasTightChain) + { + float ProfileFloorDelta = 0.0f; + const TArray& Profiles = bHasWorldChain + ? Tunnel.WorldFloorProfiles : Tunnel.FloorProfiles; + if (Profiles.Num() == ChainPoints.Num() - 1) + { + for (int32 SegmentIndex = 0; + SegmentIndex < Profiles.Num(); ++SegmentIndex) + { + const FTunnelFloorSegmentProfile& Profile = Profiles[SegmentIndex]; + if (!VoxelMath::IsFinite(Profile.StartFloorZ) + || !VoxelMath::IsFinite(Profile.EndFloorZ)) + { + bHasTightChain = false; + break; + } + const float NaturalA = static_cast( + ChainPoints[SegmentIndex].Z) + - FMath::Abs(ChainRadii[SegmentIndex]); + const float NaturalB = static_cast( + ChainPoints[SegmentIndex + 1].Z) + - FMath::Abs(ChainRadii[SegmentIndex + 1]); + ProfileFloorDelta = FMath::Max( + ProfileFloorDelta, + FMath::Max( + FMath::Abs(Profile.StartFloorZ - NaturalA), + FMath::Abs(Profile.EndFloorZ - NaturalB))); + } + } + if (bHasTightChain) + { + const float FloorPad = FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, + FMath::Max(Cache.SDFBlendRadius, 0.0f) * 0.35f); + const float SegmentPad = MaxRadius + FloorPad + + ProfileFloorDelta + ReliefBound; + if (!VoxelMath::IsFinite(SegmentPad)) + { + return true; + } + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < ChainPoints.Num(); ++SegmentIndex) + { + const FVector Min( + FMath::Min(ChainPoints[SegmentIndex].X, + ChainPoints[SegmentIndex + 1].X), + FMath::Min(ChainPoints[SegmentIndex].Y, + ChainPoints[SegmentIndex + 1].Y), + FMath::Min(ChainPoints[SegmentIndex].Z, + ChainPoints[SegmentIndex + 1].Z)); + const FVector Max( + FMath::Max(ChainPoints[SegmentIndex].X, + ChainPoints[SegmentIndex + 1].X), + FMath::Max(ChainPoints[SegmentIndex].Y, + ChainPoints[SegmentIndex + 1].Y), + FMath::Max(ChainPoints[SegmentIndex].Z, + ChainPoints[SegmentIndex + 1].Z)); + if (TouchesExpandedAabb(Min, Max, SegmentPad * ReachScale)) + { + bTightTouch = true; + break; + } + } + + if (!bTightTouch && bHasWorldChain + && Tunnel.bHasFloorRoomOwnership) + { + const float MouthHorizontal = MouthReach; + const float MouthVertical = FMath::Max( + MaxRadius + VoxelPassageGeometry::LandingFloorThicknessVoxels + + ReliefBound, + MouthFloorDelta + VoxelPassageGeometry::LandingFloorThicknessVoxels + + ReliefBound); + const float ScaledHorizontal = MouthHorizontal * ReachScale; + const float ScaledVertical = MouthVertical * ReachScale; + for (int32 Endpoint : {0, ChainPoints.Num() - 1}) + { + const FVector& Point = ChainPoints[Endpoint]; + if (TouchesExpandedAabb( + FVector( + static_cast(Point.X) - ScaledHorizontal, + static_cast(Point.Y) - ScaledHorizontal, + static_cast(Point.Z) - ScaledVertical), + FVector( + static_cast(Point.X) + ScaledHorizontal, + static_cast(Point.Y) + ScaledHorizontal, + static_cast(Point.Z) + ScaledVertical), + 0.0f)) + { + bTightTouch = true; + break; + } + } + } + } + } + } + if (bHasTightChain && !bTightTouch) + { + continue; + } + + // The broad sphere already encloses the swept radius and finite floor band. Add the + // vertical relief and room-mouth ownership envelopes before asking whether the lattice + // box can touch it. A miss is therefore an identity proof for the core evaluator and + // its support/room-floor tail; its only field writes are bRoomFloor or + // CoreSDF < -inset, all of which are inside this envelope. + const float Reach = ( + FMath::Sqrt(FMath::Max(BoundRadiusSq, 0.0f)) + + ReliefBound + MouthReach + MouthFloorDelta) * ReachScale; + if (!VoxelMath::IsFinite(Reach) + || TouchesSphere(BoundCenter, Reach)) + { + return true; + } + } + return false; +} + +float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + bool bUseSpatialIndex) +{ + return EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, Cache, nullptr, bUseSpatialIndex).SDF; +} + +//============================================================================= +// CONVENIENCE WRAPPER (backward compatible) +//============================================================================= +// Builds a temporary cache for a single point, then evaluates. +// For chunk generation, use BuildChunkCache + EvaluateSDFCached directly. +// The search box around the point only needs a MaxInfluence margin — BuildChunkCache +// internally widens the COLLECT region to (2*MaxTunnelLength + MaxInfluence) so the +// graph it builds is the same one the chunk path would build at this point. + diff --git a/Standalone/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h b/Standalone/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h new file mode 100644 index 0000000..6cf9c2f --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h @@ -0,0 +1,152 @@ +#pragma once + +#include "VoxelTypes.h" + +namespace VoxelClipmapDesiredTiles +{ + struct FParameters + { + int32 ClipRadius = 3; + int32 MaxClipLevel = 4; + int32 RenderDistanceChunks = 0; + }; + + FORCEINLINE int32 FloorDiv(int32 Value, int32 Divisor) + { + return Value >= 0 ? (Value / Divisor) : -(((-Value) + Divisor - 1) / Divisor); + } + + FORCEINLINE FIntVector FloorDiv(const FIntVector& Value, int32 Divisor) + { + return FIntVector(FloorDiv(Value.X, Divisor), FloorDiv(Value.Y, Divisor), + FloorDiv(Value.Z, Divisor)); + } + + FORCEINLINE int32 CeilDiv(int32 Value, int32 Divisor) + { + return -FloorDiv(-Value, Divisor); + } + + FORCEINLINE void AlignedRingBounds(const FIntVector& Center, int32 Radius, int32 Alignment, + FIntVector& OutMin, FIntVector& OutMax) + { + const auto AlignAxis = [Radius, Alignment](int32 CenterAxis, + int32& OutAxisMin, int32& OutAxisMax) + { + const int32 BaseMin = CenterAxis - Radius; + const int32 BaseEnd = CenterAxis + Radius + 1; + OutAxisMin = FloorDiv(BaseMin, Alignment) * Alignment; + OutAxisMax = CeilDiv(BaseEnd, Alignment) * Alignment - 1; + }; + AlignAxis(Center.X, OutMin.X, OutMax.X); + AlignAxis(Center.Y, OutMin.Y, OutMax.Y); + AlignAxis(Center.Z, OutMin.Z, OutMax.Z); + } + + FORCEINLINE int32 OuterShellRadius(const FParameters& Parameters, int32 Radius, int32 MaxLevel) + { + if (Parameters.RenderDistanceChunks <= 0) + { + return Radius; + } + return FMath::Max(Radius, + (Parameters.RenderDistanceChunks + (1 << MaxLevel) - 1) >> MaxLevel); + } + + FORCEINLINE void OuterShell(const FParameters& Parameters, int32 Radius, int32 MaxLevel, + int32& OutLevel, int32& OutRadius) + { + OutLevel = MaxLevel; + OutRadius = OuterShellRadius(Parameters, Radius, MaxLevel); + } + + FORCEINLINE void ResolveVerticalBounds( + int32 CenterChunkZ, int32 ViewDistanceUp, int32 ViewDistanceDown, + int32 StrateViewMarginChunks, bool bClampToStrate, bool bHasStrate, + int32 StrateTopChunkZ, int32 StrateBottomChunkZ, + int32& OutMinChunkZ, int32& OutMaxChunkZ) + { + OutMinChunkZ = MIN_int32; + OutMaxChunkZ = MAX_int32; + if (!bClampToStrate) + { + return; + } + + OutMinChunkZ = CenterChunkZ - ViewDistanceDown; + OutMaxChunkZ = CenterChunkZ + ViewDistanceUp; + if (bHasStrate) + { + OutMinChunkZ = StrateBottomChunkZ - StrateViewMarginChunks; + OutMaxChunkZ = StrateTopChunkZ + StrateViewMarginChunks; + } + } + + FORCEINLINE void Build(const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ, + const FParameters& Parameters, TArray& OutTiles) + { + OutTiles.Reset(); + const int32 Radius = FMath::Max(1, Parameters.ClipRadius); + const int32 MaxLevel = FMath::Clamp(Parameters.MaxClipLevel, 0, 8); + const int32 OuterRadius = OuterShellRadius(Parameters, Radius, MaxLevel); + FIntVector PreviousRingMin = FIntVector::ZeroValue; + FIntVector PreviousRingMax = FIntVector::ZeroValue; + + for (int32 Level = 0; Level <= MaxLevel; ++Level) + { + const int32 Pow = 1 << Level; + const FIntVector CenterLevel = FloorDiv(Center, Pow); + const int32 LevelRadius = (Level == MaxLevel) ? OuterRadius : Radius; + + // Every finer shell must end on a complete child-pair boundary so a coarser tile is + // either wholly inside it or wholly outside it. The outermost ring has no coarser + // neighbour and needs no alignment. + const int32 RingAlignment = Level < MaxLevel ? 2 : 1; + FIntVector RingMin, RingMax; + AlignedRingBounds(CenterLevel, LevelRadius, RingAlignment, RingMin, RingMax); + + int32 DeltaZMin = RingMin.Z - CenterLevel.Z; + int32 DeltaZMax = RingMax.Z - CenterLevel.Z; + if (LevelRadius > Radius && MinChunkZ != MIN_int32) + { + DeltaZMin = FMath::Max(DeltaZMin, FloorDiv(MinChunkZ, Pow) - CenterLevel.Z); + DeltaZMax = FMath::Min(DeltaZMax, FloorDiv(MaxChunkZ, Pow) - CenterLevel.Z); + } + + for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ) + for (int32 DeltaY = RingMin.Y - CenterLevel.Y; + DeltaY <= RingMax.Y - CenterLevel.Y; ++DeltaY) + for (int32 DeltaX = RingMin.X - CenterLevel.X; + DeltaX <= RingMax.X - CenterLevel.X; ++DeltaX) + { + const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ); + const int32 TileMinZ = Tile.Z << Level; + const int32 TileMaxZ = ((Tile.Z + 1) << Level) - 1; + if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ) + { + continue; + } + + if (Level > 0) + { + const bool bCovered = + (2 * Tile.X >= PreviousRingMin.X) + && (2 * Tile.X + 1 <= PreviousRingMax.X) + && (2 * Tile.Y >= PreviousRingMin.Y) + && (2 * Tile.Y + 1 <= PreviousRingMax.Y) + && (2 * Tile.Z >= PreviousRingMin.Z) + && (2 * Tile.Z + 1 <= PreviousRingMax.Z); + if (bCovered) + { + continue; + } + } + + OutTiles.Emplace(Tile, Level); + } + + PreviousRingMin = RingMin; + PreviousRingMax = RingMax; + } + } +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelDensityAblation.cpp b/Standalone/Source/VoxelForge/Private/VoxelDensityAblation.cpp new file mode 100644 index 0000000..b00dadc --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelDensityAblation.cpp @@ -0,0 +1,131 @@ +// VoxelDensityAblation.cpp +// Development-only TunnelNetwork ablation controls. + +#include "VoxelDensityAblation.h" + +#include "HAL/IConsoleManager.h" +#include "Misc/CommandLine.h" +#include "Misc/Parse.h" + +#include + +namespace +{ + constexpr uint32 UnresolvedMask = 0xFFFFFFFFu; + std::atomic GResolvedMask(UnresolvedMask); + +#if !UE_BUILD_SHIPPING + int32 GCaveWarp = 0; + int32 GDetailOps = 0; + int32 GRoomSDF = 0; + int32 GTunnelSDF = 0; + int32 GTunnelCore = 0; + int32 GPassageCarving = 0; + int32 GPassageStructuralPosts = 0; + int32 GNativeFloor = 0; + int32 GDisturbances = 0; + int32 GOriginSpine = 0; + int32 GBoundarySeal = 0; + int32 GLandingPosts = 0; + int32 GXYEdgeSeal = 0; + int32 GPitChimneySDF = 0; + +#define VF_ABLATION_CVAR(Name, Storage) \ + FAutoConsoleVariableRef CVar##Storage( \ + Name, Storage, \ + TEXT("WORLD-CHANGING DEVELOPMENT-ONLY measurement override. Forces this " \ + "TunnelNetwork density stage off; never use as a gameplay switch. " \ + "All peers and regenerated worlds must use the same value. Resolved once " \ + "per process. 0=on, 1=off."), ECVF_Default) + + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateCaveWarp"), GCaveWarp); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateDetailOps"), GDetailOps); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateRoomSDF"), GRoomSDF); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateTunnelSDF"), GTunnelSDF); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateTunnelCore"), GTunnelCore); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePassageCarving"), GPassageCarving); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePassageStructuralPosts"), GPassageStructuralPosts); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateNativeFloor"), GNativeFloor); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateDisturbances"), GDisturbances); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateOriginSpine"), GOriginSpine); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateBoundarySeal"), GBoundarySeal); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateLandingPosts"), GLandingPosts); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblateXYEdgeSeal"), GXYEdgeSeal); + VF_ABLATION_CVAR(TEXT("voxel.TunnelAblatePitChimneySDF"), GPitChimneySDF); + +#undef VF_ABLATION_CVAR + + uint32 ResolveValue(const TCHAR* Name, int32& Storage) + { + int32 Value = Storage; + FParse::Value(FCommandLine::Get(), Name, Value); + Storage = Value != 0 ? 1 : 0; + return Storage != 0 ? 1u : 0u; + } + + uint32 ResolveMask() + { + uint32 Mask = 0; + Mask |= ResolveValue(TEXT("voxel.TunnelAblateCaveWarp="), GCaveWarp) + * static_cast(VoxelDensityAblation::EStage::CaveWarp); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateDetailOps="), GDetailOps) + * static_cast(VoxelDensityAblation::EStage::DetailOps); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateRoomSDF="), GRoomSDF) + * static_cast(VoxelDensityAblation::EStage::RoomSDF); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateTunnelSDF="), GTunnelSDF) + * static_cast(VoxelDensityAblation::EStage::TunnelSDF); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateTunnelCore="), GTunnelCore) + * static_cast(VoxelDensityAblation::EStage::TunnelCore); + Mask |= ResolveValue(TEXT("voxel.TunnelAblatePassageCarving="), GPassageCarving) + * static_cast(VoxelDensityAblation::EStage::PassageCarving); + Mask |= ResolveValue( + TEXT("voxel.TunnelAblatePassageStructuralPosts="), GPassageStructuralPosts) + * static_cast(VoxelDensityAblation::EStage::PassageStructuralPosts); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateNativeFloor="), GNativeFloor) + * static_cast(VoxelDensityAblation::EStage::NativeFloor); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateDisturbances="), GDisturbances) + * static_cast(VoxelDensityAblation::EStage::Disturbances); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateOriginSpine="), GOriginSpine) + * static_cast(VoxelDensityAblation::EStage::OriginSpine); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateBoundarySeal="), GBoundarySeal) + * static_cast(VoxelDensityAblation::EStage::BoundarySeal); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateLandingPosts="), GLandingPosts) + * static_cast(VoxelDensityAblation::EStage::LandingPosts); + Mask |= ResolveValue(TEXT("voxel.TunnelAblateXYEdgeSeal="), GXYEdgeSeal) + * static_cast(VoxelDensityAblation::EStage::XYEdgeSeal); + Mask |= ResolveValue(TEXT("voxel.TunnelAblatePitChimneySDF="), GPitChimneySDF) + * static_cast(VoxelDensityAblation::EStage::PitChimneySDF); + return Mask; + } +#endif +} + +namespace VoxelDensityAblation +{ + uint32 GetResolvedMask() + { + uint32 Existing = GResolvedMask.load(std::memory_order_acquire); + if (Existing != UnresolvedMask) + { + return Existing; + } + +#if UE_BUILD_SHIPPING + constexpr uint32 Resolved = 0; +#else + const uint32 Resolved = ResolveMask(); +#endif + uint32 Expected = UnresolvedMask; + if (!GResolvedMask.compare_exchange_strong( + Expected, Resolved, std::memory_order_release, std::memory_order_acquire)) + { + return Expected; + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelAblation] resolved mask=0x%08x " + "(world-changing development measurement overrides; 0=all on)"), + Resolved); + return Resolved; + } +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp new file mode 100644 index 0000000..b028912 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -0,0 +1,10430 @@ +// VoxelDensityOpStack.cpp +// Les opérateurs concrets : les décompositions d'archétypes + le post-traitement structurel. +// The concrete operators: the archetype decompositions + the structural post-process. +// +// Ces opérateurs génèrent chaque strate du jeu (voir l'en-tête de VoxelDensityOpStack.h). +// These operators generate every strate in the game (see the VoxelDensityOpStack.h header). +// +// Convention de signe INTERNE : positif = solide ; la pile négative une fois pour le MC. +// Internal sign convention: positive = solid; the stack negates once for MC. + +#include "VoxelDensityOpStack.h" +#include "VoxelStrateComposer.h" + +#include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / seals / PassageCarving +#include "VoxelDensityAblation.h" // fingerprinted development-only stage measurements +#include "VoxelCaveMorphology.h" // VoxelSDF::Capsule, VoxelHash +#include "VoxelGenerator.h" // VoxelGenLOD::Eff +#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks +#include "VoxelNoise.h" // VoxelNoise::FBM +#include "VoxelWormField.h" +#include "VoxelStrateDefinition.h" // TerrainOperations — le pool que BuildChunkCache tire par salle +#include "VoxelTerrainOpDefinition.h" // ApplyTo — l'override d'op PAR SALLE (étape C1) +#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox +#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE +#include "VoxelStats.h" + +#include // l'id d'instance non recyclé du mémo de colonne +#include "HAL/CriticalSection.h" +#include "HAL/Event.h" +#include "HAL/PlatformTime.h" +#include "HAL/PlatformProcess.h" +#include "Misc/ScopeLock.h" + +namespace +{ + /** + * Concrete block thunks use the scalar body with the concrete operator type visible to the + * compiler. This is deliberately not a second numerical implementation: the scalar Eval is + * still the single source of truth, but the op-major caller pays one virtual dispatch for the + * block and the inner calls can inline/devirtualize. Operators with a block-native kernel can + * replace this helper later without changing the stack contract. + */ + template + FORCEINLINE void VF_EvalBlockByScalar(const TOp& Op, const FVoxelOpBlock& Block) + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + for (int32 Z = 0; Z < Block.SizeZ; ++Z) + { + for (int32 Y = 0; Y < Block.SizeY; ++Y) + { + for (int32 X = 0; X < Block.SizeX; ++X) + { + FVoxelOpSample& Sample = Block.At(X, Y, Z); + Op.PrepareBlockSample(Sample); + Op.Eval( + static_cast(Block.OriginVoxels.X + X * Block.Step), + static_cast(Block.OriginVoxels.Y + Y * Block.Step), + static_cast(Block.OriginVoxels.Z + Z * Block.Step), + Sample); + } + } + } + } + + /** + * BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0. + * + * L'en-tête de `VoxelNoise::Perlin3D` annonce « ~[-1,1] (typiquement [-0.7,0.7]) ». Le `~` + * est un aveu : c'est une observation, pas un théorème, et un verdict de boîte fondé sur une + * observation est exactement le genre de trou que ce fichier passe son temps à éviter. + * + * Ce qui EST démontrable, en lisant `GradDot` : il rend `ru + rv` où `ru` et `rv` sont des + * composantes de l'offset fractionnaire, donc chacune dans `[-1, 1]` ⇒ `|GradDot| ≤ 2`. La + * valeur finale est une interpolation trilinéaire de huit `GradDot`, et une interpolation + * convexe ne sort jamais de l'enveloppe de ses entrées ⇒ `|Perlin3D| ≤ 2`. (La vraie borne + * de Perlin 3D est `√3/2 ≈ 0.87` ; on ne s'appuie pas dessus, elle dépend du jeu de + * gradients.) Se tromper ici coûte une boîte de recherche un peu plus large, jamais un + * verdict faux : plus large ⇒ SUR-ensemble de primitives ⇒ `Identity` plus rare. + * + * ⚠️⚠️ **CETTE CONSTANTE EST LE TERME DOMINANT DE TOUTE LA FONCTION.** À lire avant d'y + * retoucher. + * + * La dilatation vaut `CaveWarpStrength · VOXEL_NOISE_SCALE · CETTE BORNE`. Avec les défauts + * (`CaveWarpStrength = 8`, `SCALE = 1.25`), une borne de 2.0 vaut **20 voxels** — appliquée + * des deux côtés de chaque axe d'une tuile de **10 voxels**, soit une boîte de requête de + * 50 voxels, **125× le volume de la tuile** : `BoxHalfDiag` est alors dominé par cette + * dilatation et non par la géométrie, et tout resserrement ailleurs ne gagne presque rien. + * + * ⚠️ LE RESTE DU PLUGIN EST MOINS PRUDENT : `BuildChunkCache` est appelée avec + * `Expansion = CaveWarpStrength + 2` (ici comme dans `GetDensityWithParams`), ce qui suppose + * `|Perlin3D| · SCALE ≤ CaveWarpStrength`, donc `|Perlin3D| ≤ 0.8`. Le code de production + * parie déjà là-dessus. 2.0 serait 2,5× plus conservateur que l'hypothèse dont dépend déjà la + * correction du cache. + * + * LA BORNE 1.5, DÉMONTRÉE (et non observée) : + * 1. `GradDot` rend `±u ± v` où `u` et `v` sont deux composantes **distinctes** de l'offset + * du coin — vérifié sur les quatre branches du `switch` de hash, pas supposé. + * 2. Pour l'axe x : les coins à `i=0` portent le poids `(1−su)` et l'offset `fx`, ceux à + * `i=1` le poids `su` et l'offset `1−fx`. Donc `Σ_c w_c·|dx_c| = (1−su)·fx + su·(1−fx)`, + * dont le maximum sur `[0,1]` vaut **0.5** (atteint en `fx = 0.5`, où `su = 0.5` ; + * 0.302 en 0.25 comme en 0.75). + * 3. `|Perlin| ≤ Σ_c w_c(|a_c| + |b_c|) ≤ S_x + S_y + S_z ≤ 3 × 0.5 = 1.5.` + * (Le vrai maximum est plus bas encore — seuls DEUX axes apparaissent par coin — mais 1.5 + * est la borne qui se démontre sans analyse de cas sur les hash. `√3/2 ≈ 0.87`, la borne + * classique de Perlin 3D, dépend du jeu de gradients : on ne s'appuie pas dessus.) + * + * This bound is the dominant term of the whole function: 2.0 would inflate a 10-voxel tile + * into a 50-voxel query box (125x the volume). The rest of the plugin already assumes + * |Perlin3D| <= 0.8 (BuildChunkCache's Expansion = CaveWarpStrength + 2). 1.5 is PROVED + * above from GradDot's two-distinct-axes form and the per-axis weighted bound of 0.5. + */ + static constexpr float VF_PerlinAbsBound = 1.5f; + + struct FVFInterval + { + float Min = 0.0f; + float Max = 0.0f; + }; + + FORCEINLINE FVFInterval VF_AddInterval(const FVFInterval& A, const FVFInterval& B) + { + return {A.Min + B.Min, A.Max + B.Max}; + } + + FORCEINLINE FVFInterval VF_NegateInterval(const FVFInterval& A) + { + return {-A.Max, -A.Min}; + } + + FORCEINLINE FVFInterval VF_MultiplyNonNegativeInterval( + const FVFInterval& A, const FVFInterval& B) + { + const float P0 = A.Min * B.Min; + const float P1 = A.Min * B.Max; + const float P2 = A.Max * B.Min; + const float P3 = A.Max * B.Max; + return {FMath::Min(FMath::Min(P0, P1), FMath::Min(P2, P3)), + FMath::Max(FMath::Max(P0, P1), FMath::Max(P2, P3))}; + } + + /** Exact interval for GradDot on one subdivision of one Perlin cell. */ + FORCEINLINE FVFInterval VF_GradDotInterval( + uint32 Hash, const FVFInterval& X, const FVFInterval& Y, const FVFInterval& Z) + { + const uint32 H = Hash & 15u; + const FVFInterval* Axes[3] = {&X, &Y, &Z}; + const FVFInterval& U = *Axes[(H & 8u) == 0u ? 0 : 1]; + + int32 VAxis = 2; + if ((H & 12u) == 0u) + { + VAxis = 1; + } + else if ((H & 13u) == 12u) + { + VAxis = 0; + } + + const FVFInterval SignedU = (H & 1u) == 0u ? U : VF_NegateInterval(U); + const FVFInterval SignedV = (H & 2u) == 0u + ? *Axes[VAxis] : VF_NegateInterval(*Axes[VAxis]); + return VF_AddInterval(SignedU, SignedV); + } + + /** + * Conservative local envelope for the exact scalar Perlin3D implementation. + * + * Perlin3D is a trilinear blend of eight linear GradDot values in each integer cell. We split + * every intersected cell into two pieces per axis and interval-evaluate the same fade/lerp + * formula. The result is therefore a proof over the whole finite box, not a sample-based + * guess. Large/invalid boxes fall back to the already-proven global envelope. + */ + FORCEINLINE float VF_PerlinAbsBoundOverBox( + const FVector3f& InMin, const FVector3f& InMax) + { + if (!VoxelMath::IsFinite(InMin.X) || !VoxelMath::IsFinite(InMin.Y) + || !VoxelMath::IsFinite(InMin.Z) || !VoxelMath::IsFinite(InMax.X) + || !VoxelMath::IsFinite(InMax.Y) || !VoxelMath::IsFinite(InMax.Z) + || InMin.X > InMax.X || InMin.Y > InMax.Y || InMin.Z > InMax.Z + || FMath::Abs(InMin.X) > 1000000.0f || FMath::Abs(InMax.X) > 1000000.0f + || FMath::Abs(InMin.Y) > 1000000.0f || FMath::Abs(InMax.Y) > 1000000.0f + || FMath::Abs(InMin.Z) > 1000000.0f || FMath::Abs(InMax.Z) > 1000000.0f) + { + return VF_PerlinAbsBound; + } + + const int32 X0 = FMath::FloorToInt(InMin.X); + const int32 Y0 = FMath::FloorToInt(InMin.Y); + const int32 Z0 = FMath::FloorToInt(InMin.Z); + const int32 X1 = FMath::FloorToInt(InMax.X); + const int32 Y1 = FMath::FloorToInt(InMax.Y); + const int32 Z1 = FMath::FloorToInt(InMax.Z); + const int64 CellCount = ((int64)X1 - X0 + 1) + * ((int64)Y1 - Y0 + 1) + * ((int64)Z1 - Z0 + 1); + if (X1 < X0 || Y1 < Y0 || Z1 < Z0 || CellCount <= 0 || CellCount > 64) + { + return VF_PerlinAbsBound; + } + + // The interval remains a proof for every subdivision size: each cell piece is evaluated + // with interval arithmetic, so a coarser partition can only widen the envelope. Eight + // pieces made a 32-voxel LOD0 root pay hundreds of thousands of tiny interval operations + // before the graph proof even started. Four pieces are still bounded, but retain enough + // finite-domain tightness at the 4-voxel refinement tail to discharge a proof without an + // exact lattice walk. If the bound cannot discharge the proof, the caller returns Mixed + // and the normal mesher remains the conservative fallback. + constexpr int32 Subdivisions = 4; + float Bound = 0.0f; + for (int32 CellZ = Z0; CellZ <= Z1; ++CellZ) + for (int32 CellY = Y0; CellY <= Y1; ++CellY) + for (int32 CellX = X0; CellX <= X1; ++CellX) + { + const float TX0 = FMath::Clamp(InMin.X - (float)CellX, 0.0f, 1.0f); + const float TY0 = FMath::Clamp(InMin.Y - (float)CellY, 0.0f, 1.0f); + const float TZ0 = FMath::Clamp(InMin.Z - (float)CellZ, 0.0f, 1.0f); + const float TX1 = FMath::Clamp(InMax.X - (float)CellX, 0.0f, 1.0f); + const float TY1 = FMath::Clamp(InMax.Y - (float)CellY, 0.0f, 1.0f); + const float TZ1 = FMath::Clamp(InMax.Z - (float)CellZ, 0.0f, 1.0f); + + for (int32 SZ = 0; SZ < Subdivisions; ++SZ) + for (int32 SY = 0; SY < Subdivisions; ++SY) + for (int32 SX = 0; SX < Subdivisions; ++SX) + { + const float AX = TX0 + (TX1 - TX0) * (float)SX / Subdivisions; + const float BX = TX0 + (TX1 - TX0) * (float)(SX + 1) / Subdivisions; + const float AY = TY0 + (TY1 - TY0) * (float)SY / Subdivisions; + const float BY = TY0 + (TY1 - TY0) * (float)(SY + 1) / Subdivisions; + const float AZ = TZ0 + (TZ1 - TZ0) * (float)SZ / Subdivisions; + const float BZ = TZ0 + (TZ1 - TZ0) * (float)(SZ + 1) / Subdivisions; + + const float FX0 = VoxelNoise::Detail::Fade(AX); + const float FX1 = VoxelNoise::Detail::Fade(BX); + const float FY0 = VoxelNoise::Detail::Fade(AY); + const float FY1 = VoxelNoise::Detail::Fade(BY); + const float FZ0 = VoxelNoise::Detail::Fade(AZ); + const float FZ1 = VoxelNoise::Detail::Fade(BZ); + const float WXMin[2] = {1.0f - FX1, FX0}; + const float WXMax[2] = {1.0f - FX0, FX1}; + const float WYMin[2] = {1.0f - FY1, FY0}; + const float WYMax[2] = {1.0f - FY0, FY1}; + const float WZMin[2] = {1.0f - FZ1, FZ0}; + const float WZMax[2] = {1.0f - FZ0, FZ1}; + + float ValueMin = 0.0f; + float ValueMax = 0.0f; + for (int32 CZ = 0; CZ < 2; ++CZ) + for (int32 CY = 0; CY < 2; ++CY) + for (int32 CX = 0; CX < 2; ++CX) + { + const FVFInterval Grad = VF_GradDotInterval( + VoxelNoise::Detail::HashCorner(CellX + CX, CellY + CY, CellZ + CZ), + {AX - (float)CX, BX - (float)CX}, + {AY - (float)CY, BY - (float)CY}, + {AZ - (float)CZ, BZ - (float)CZ}); + const FVFInterval Weight = VF_MultiplyNonNegativeInterval( + {WXMin[CX], WXMax[CX]}, + VF_MultiplyNonNegativeInterval( + {WYMin[CY], WYMax[CY]}, {WZMin[CZ], WZMax[CZ]})); + const FVFInterval Contribution = VF_MultiplyNonNegativeInterval(Weight, Grad); + ValueMin += Contribution.Min; + ValueMax += Contribution.Max; + } + + if (!VoxelMath::IsFinite(ValueMin) || !VoxelMath::IsFinite(ValueMax)) + { + return VF_PerlinAbsBound; + } + Bound = FMath::Max(Bound, FMath::Max(FMath::Abs(ValueMin), FMath::Abs(ValueMax))); + } + } + + // Cover the finite-precision rounding in the nested float lerps. If the interval proof is + // looser than the global theorem, retaining the theorem is both cheaper and safer. + Bound += 0.002f; + return FMath::Min(FMath::Max(Bound, 0.0f), VF_PerlinAbsBound); + } + + FORCEINLINE float VF_PerlinAbsBoundOverWorldBox( + float WorldMinX, float WorldMaxX, float WorldMinY, float WorldMaxY, + float EffectiveMinZ, float EffectiveMaxZ, float Frequency, + float OffsetX, float OffsetY, float OffsetZ) + { + const float X0 = WorldMinX * Frequency + OffsetX; + const float X1 = WorldMaxX * Frequency + OffsetX; + const float Y0 = WorldMinY * Frequency + OffsetY; + const float Y1 = WorldMaxY * Frequency + OffsetY; + const float Z0 = EffectiveMinZ * Frequency + OffsetZ; + const float Z1 = EffectiveMaxZ * Frequency + OffsetZ; + return VF_PerlinAbsBoundOverBox( + FVector3f(FMath::Min(X0, X1), FMath::Min(Y0, Y1), FMath::Min(Z0, Z1)), + FVector3f(FMath::Max(X0, X1), FMath::Max(Y0, Y1), FMath::Max(Z0, Z1))); + } + + // Cellular3D returns 2*(F2-F1)-1 without a clamp. Every searched feature point is at most + // sqrt(12) from the query's unit cell, so F2-F1 <= sqrt(12) and the positive supremum is + // 2*sqrt(12)-1 = 5.9282... . Round that up for float evaluation instead of trusting the + // "~[-1,1]" comment in the noise implementation (which is an observation, not a proof). + static constexpr float VF_CellularAbsBound = 6.0f; + + /** La même enveloppe que `FractalNoise3D` de VoxelGenerator.cpp (qui y est `static`, donc + * invisible ici). Les coordonnées de bruit restent en float : FVector est double dans UE5 + * et son aller-retour ne servait qu'à conserver l'ancien arrondi, exigence abandonnée. */ + FORCEINLINE float HFractal3D(const FVector3f& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::FBM(Position.X, Position.Y, Position.Z, + Octaves, Lacunarity, Persistence); + } + + /** Idem pour `RidgedNoise3D` (également `static` dans VoxelGenerator.cpp). */ + FORCEINLINE float HRidged3D(const FVector3f& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::Ridged(Position.X, Position.Y, Position.Z, + Octaves, Lacunarity, Persistence); + } + + //========================================================================= + // LE GATE `bNearCaveSurface` — ÉTAPE B + //========================================================================= + // ⚠️ DÉCISION DE L'ÉTAPE B5, ÉCRITE ICI PARCE QUE C'EST LE POINT OÙ ELLE SE LIT. + // Dans l'original, les douze modificateurs de détail vivent dans UN SEUL `if (bNearCaveSurface)`. + // Deux façons de porter ça : (a) un opérateur « conteneur » qui enveloppe ses enfants, (b) le + // même early-out répété dans chaque opérateur. **C'est (b), délibérément :** + // + // • La pile est une LISTE PLATE, et `FVoxelOpStack::ClassifyBox` plie les opérateurs un par un. + // Un conteneur devrait replier ses enfants lui-même — donc reproduire `VF_FoldOp` — et ses + // enfants deviendraient invisibles au pliage. On paierait une abstraction pour en casser une. + // • Un opérateur qui n'existe QUE dans un conteneur n'est pas composable, donc pas transposable + // en asset (Phase 3). Le motif « chaque op teste son propre gate » est déjà celui de + // `FSdfRoughnessMod` (`InOut.Sdf >= ApplyWithin`) et de `FShaftLedgeMod`. + // • Le gate n'est de toute façon PAS uniforme : chaque modificateur a EN PLUS sa propre fenêtre + // (`RoughnessDepth`, `TerraceRange`, `LineRange`…). Le gate partagé n'est qu'un early-out + // commun, pas la condition réelle de chacun. + // + // ⚠️ CE QUE ÇA COÛTE, dit franchement : l'original teste UNE fois et saute les douze ; la pile + // teste douze fois. Douze comparaisons flottantes parfaitement prédites par voxel de roc profond + // — mesurable, mais c'est exactement le genre de chose que `AUDIT §C10` dit de MESURER avant + // d'optimiser. C'est un poste de perf connu, pas à « corriger » à l'aveugle. + // + // STAGE B5 DECISION: repeated early-out in each op, NOT a scoping container — the stack is a flat + // list that ClassifyBox folds op by op, and an op that only exists inside a container is not + // composable. Cost stated honestly: twelve predictable compares instead of one branch. + FORCEINLINE bool VF_NearCaveSurface(float Sdf, float SDFBlendRadius) + { + // Transcrit tel quel, ordre des comparaisons compris : + // const float DetailThreshold = Params.SDFBlendRadius * 3.0f; + // const bool bNearCaveSurface = (CaveSDF < DetailThreshold) && (CaveSDF < FLT_MAX); + const float DetailThreshold = SDFBlendRadius * 3.0f; + return (Sdf < DetailThreshold) && (Sdf < FLT_MAX); + } + + /** Lower bound for the distance between two axis-aligned boxes. The segment's AABB contains + * the segment, so this is a conservative lower bound for point-to-segment distance. */ + FORCEINLINE float VF_DistanceBetweenBoxes(const FVector& AMin, const FVector& AMax, + const FVector& BMin, const FVector& BMax) + { + const float DX = FMath::Max3((float)(AMin.X - BMax.X), (float)(BMin.X - AMax.X), 0.0f); + const float DY = FMath::Max3((float)(AMin.Y - BMax.Y), (float)(BMin.Y - AMax.Y), 0.0f); + const float DZ = FMath::Max3((float)(AMin.Z - BMax.Z), (float)(BMin.Z - AMax.Z), 0.0f); + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + } + + FORCEINLINE float VF_DistanceBoxToPointXY(const FBox& Box, float X, float Y) + { + const float DX = FMath::Max3((float)(Box.Min.X - X), 0.0f, (float)(X - Box.Max.X)); + const float DY = FMath::Max3((float)(Box.Min.Y - Y), 0.0f, (float)(Y - Box.Max.Y)); + return FMath::Sqrt(DX * DX + DY * DY); + } + + /** Distance from the nearest exact lattice coordinate in one axis to a point. The continuous + * box proof uses the interval [BoxMin, BoxMax]; the lattice proof uses only + * Origin + integer * Step values inside that interval. Extra is a per-axis displacement + * envelope (the cave warp); subtracting it keeps the result conservative. */ + FORCEINLINE float VF_LatticeAxisDistanceToPoint( + float BoxMin, float BoxMax, float Origin, float Step, float Target, float Extra) + { + if (!(Step > 0.0f) || !VoxelMath::IsFinite(BoxMin) || !VoxelMath::IsFinite(BoxMax) + || !VoxelMath::IsFinite(Origin) || !VoxelMath::IsFinite(Target) + || BoxMin > BoxMax) + { + return FLT_MAX; + } + + const int32 GMin = FMath::CeilToInt((BoxMin - Origin) / Step - 1.0e-4f); + const int32 GMax = FMath::FloorToInt((BoxMax - Origin) / Step + 1.0e-4f); + if (GMin > GMax) { return FLT_MAX; } + + const int32 Near = FMath::FloorToInt((Target - Origin) / Step); + float Distance = FLT_MAX; + auto Consider = [&](int32 G) + { + G = FMath::Clamp(G, GMin, GMax); + Distance = FMath::Min(Distance, + FMath::Abs((Origin + static_cast(G) * Step) - Target)); + }; + Consider(GMin); + Consider(GMax); + Consider(Near); + Consider(Near + 1); + return FMath::Max(0.0f, Distance - FMath::Max(Extra, 0.0f)); + } + + /** Same discrete distance, but to an axis-aligned interval. */ + FORCEINLINE float VF_LatticeAxisDistanceToInterval( + float BoxMin, float BoxMax, float Origin, float Step, + float TargetMin, float TargetMax, float Extra) + { + if (!(Step > 0.0f) || !VoxelMath::IsFinite(TargetMin) || !VoxelMath::IsFinite(TargetMax) + || TargetMin > TargetMax) + { + return FLT_MAX; + } + + const int32 GMin = FMath::CeilToInt((BoxMin - Origin) / Step - 1.0e-4f); + const int32 GMax = FMath::FloorToInt((BoxMax - Origin) / Step + 1.0e-4f); + if (GMin > GMax) { return FLT_MAX; } + + const int32 NearMin = FMath::FloorToInt((TargetMin - Origin) / Step); + const int32 NearMax = FMath::FloorToInt((TargetMax - Origin) / Step); + float Distance = FLT_MAX; + auto Consider = [&](int32 G) + { + G = FMath::Clamp(G, GMin, GMax); + const float Value = Origin + static_cast(G) * Step; + const float Delta = Value < TargetMin ? TargetMin - Value + : Value > TargetMax ? Value - TargetMax : 0.0f; + Distance = FMath::Min(Distance, Delta); + }; + Consider(GMin); + Consider(GMax); + Consider(NearMin); + Consider(NearMin + 1); + Consider(NearMax); + Consider(NearMax + 1); + return FMath::Max(0.0f, Distance - FMath::Max(Extra, 0.0f)); + } + + FORCEINLINE float VF_SaturatingAdd(float A, float B) + { + if (!VoxelMath::IsFinite(A) || !VoxelMath::IsFinite(B)) { return FLT_MAX; } + if (B > 0.0f && A > FLT_MAX - B) { return FLT_MAX; } + if (B < 0.0f && A < -FLT_MAX - B) { return -FLT_MAX; } + return A + B; + } + + //========================================================================= + // RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide) + //========================================================================= + // `float Density = Params.BaseDensity; // start solid` — la première ligne de TunnelNetwork, + // de Maze ET de VerticalShafts. Et `float Density = -Params.BaseDensity; // open air (void)` — + // la première ligne de FloatingIslands. **C'est le MÊME opérateur au signe près**, et le signe + // n'est pas un détail : il décide du verdict de boîte de départ (AllSolid contre AllAir), donc + // de ce que la strate saura sauter. + // + // Quatre archétypes, une ligne, un opérateur. Deux fabriques (`MakeConstantRockSource` / + // `MakeConstantVoidSource`) parce que « roc » et « vide » sont ce que l'auteur veut DIRE ; la + // classe, elle, n'a aucune raison d'exister en deux exemplaires. + // + // One operator, two factories: rock and void are the same constant field with opposite signs, + // and the sign is what decides the starting box verdict (AllSolid vs AllAir). + class FConstantFieldSource final : public IVoxelDensityOp + { + public: + explicit FConstantFieldSource(float InValue) : Value(InValue) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + bool IsXYPure() const override { return true; } // constant ⇒ trivialement sans Z + + void Eval(float, float, float, FVoxelOpSample& InOut) const override + { + InOut.Density = Value; // Replace : racine de pile, ignore l'entrée + } + + // Exact et gratuit, dans les DEUX sens (convention interne : positif = solide). + // Positif ⇒ AllSolid : c'est ce qui donne aux strates de grotte une hypothèse de départ + // qu'elles n'ont jamais eue. Négatif ⇒ AllAir : c'est ce qui rend une strate d'îles + // flottantes — un grand vide surtout vide — sautable là où aucune île n'arrive. + EVoxelTileClass ClassifyBox(const FBox&, const FVoxelOpContext&) const override + { + if (Value > 0.0f) { return EVoxelTileClass::AllSolid; } + if (Value < 0.0f) { return EVoxelTileClass::AllAir; } + return EVoxelTileClass::Mixed; // exactement 0 : le mesher le compte du côté AIR, + // mais un champ nul n'est pas une hypothèse utile. + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant + } + + /** ⚠️ LA MOITIÉ MANQUANTE DU PLIAGE NUMÉRIQUE (`OPSTACK-DECOMPOSITION §0.2`). + * `MaxCarveOverBox` dit ce qu'un opérateur peut RETIRER ; ceci dit ce qu'il y avait à + * retirer. Un champ constant est le seul opérateur du plugin qui connaisse cette marge + * EXACTEMENT : la densité vaut `Value` partout, donc la marge est `|Value|`. Sans elle la + * soustraction n'a pas de premier terme et tout carve borné tue quand même l'hypothèse. */ + float ForcedMarginOverBox(const FBox&, const FVoxelOpContext&) const override + { + return FMath::Abs(Value); + } + + const TCHAR* DebugName() const override { return TEXT("ConstantFieldSource"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + float Value; + }; + + //======================================================================= + // TIER 4b — GENERIC ROLLED SOURCE/MODIFIER PRIMITIVES + //======================================================================= + // These two small primitives keep the non-room channel space large enough for the promised + // k=4..8 draw. They are deliberately bounded: their box contracts are amplitude proofs, not + // guesses. They do not carry any room/shaft/surface pointer, so the resource graph correctly + // treats them as legal after every SDF shape source. + class FNoiseRibbonSource final : public IVoxelDensityOp + { + public: + FNoiseRibbonSource(const FMazeGenerationParams& InP, int32 InSeed) + : CellSize(FMath::Max(InP.CellSize, 8.0f)) + , CorridorRadius(FMath::Max(InP.CorridorRadius, 0.5f)) + , SeedU(static_cast(InSeed) ^ 0x5249626Eu) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float Frequency = 1.0f / CellSize; + const FVector3f NoisePos( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 3.17f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 7.31f), + WorldZ * Frequency + VoxelHash::SeedOffset(SeedU, 11.47f)); + const float Ribbon = FMath::Clamp( + FMath::Abs(VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, + VoxelGenLOD::Eff(3), 2.0f, 0.5f)) + * VOXEL_NOISE_SCALE, + 0.0f, 2.0f); + InOut.Sdf = Ribbon * CellSize - CorridorRadius; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; + } + + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox&, + const FVoxelOpContext&) const override + { + if (!VoxelMath::IsFinite(CellSize) || !VoxelMath::IsFinite(CorridorRadius) + || CellSize <= 0.0f || CorridorRadius < 0.0f) + { + InOut.SetUnknown(); + return; + } + // Eval clamps the FBM envelope to [0,2]. This interval is exact enough for safety + // and independent of the queried box, so it never claims a false empty region. + InOut.Set(-CorridorRadius, 2.0f * CellSize - CorridorRadius); + } + + const TCHAR* DebugName() const override { return TEXT("NoiseRibbonSource"); } + + private: + float CellSize; + float CorridorRadius; + uint32 SeedU; + }; + + class FDensityNoiseMod final : public IVoxelDensityOp + { + public: + FDensityNoiseMod(float InStrength, float InFrequency, int32 InOctaves, + int32 InSeed, bool bInFill) + : Strength(FMath::Max(InStrength, 0.0f)) + , Frequency(FMath::Max(InFrequency, 0.0001f)) + , Octaves(FMath::Clamp(InOctaves, 1, 8)) + , SeedU(static_cast(InSeed) ^ (bInFill ? 0x46696C6Cu : 0x43617276u)) + , bFill(bInFill) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (!(Strength > 0.0f)) { return; } + const FVector3f NoisePos( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 13.2f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 17.8f), + WorldZ * Frequency + VoxelHash::SeedOffset(SeedU, 23.4f)); + const float Noise01 = FMath::Clamp( + VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, + VoxelGenLOD::Eff(Octaves), 2.0f, 0.5f) * 0.5f + 0.5f, + 0.0f, 1.0f); + const float Delta = Strength * Noise01; + InOut.Density += bFill ? Delta : -Delta; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + if (!(Strength > 0.0f)) { return EVoxelOpEffect::Identity; } + return bFill ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::CarveOnly; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return bFill ? 0.0f : Strength; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return bFill ? Strength : 0.0f; + } + + const TCHAR* DebugName() const override + { + return bFill ? TEXT("DensityNoiseFillMod") : TEXT("DensityNoiseCarveMod"); + } + + private: + float Strength; + float Frequency; + int32 Octaves; + uint32 SeedU; + bool bFill; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : COULOIRS SUR TREILLIS 3D / 3D LATTICE CORRIDORS + //========================================================================= + // Chaque nœud du treillis est au centre d'une cellule. Il choisit exactement un parent parmi + // les axes qui le rapprochent de l'origine ; l'arête parent abaisse donc |X|+|Y|+|Z| de un. + // Le résultat est un arbre couvrant de tout le treillis infini. Quelques arêtes supplémentaires + // sont hashées comme des boucles, avec des probabilités plafonnées qui ne participent jamais à + // la connectivité. Le couloir est une capsule. + // + // La règle est locale et seam-safe : les huit nœuds-enfants du halo {-1,0}³ suffisent à émettre + // toutes les arêtes qui peuvent toucher la cellule évaluée. Chaque décision est une fonction + // pure du nœud, de l'axe et de la seed ; il n'y a ni collect global ni dépendance à la fenêtre + // de chunk. + class FLatticeCorridorSource final : public IVoxelDensityOp + { + public: + FLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed) + : CellSize(FMath::Max(P.CellSize, 1.0f)) + , CorridorRadius(FMath::Max(P.CorridorRadius, 0.5f)) + , BranchProbability(P.BranchProbability) + , Verticality(P.Verticality) + , Salt((uint32)Seed ^ 0x4D617A65u) // 'Maze' + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Sdf; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const int32 CX = FMath::FloorToInt(WorldX / CellSize); + const int32 CY = FMath::FloorToInt(WorldY / CellSize); + const int32 CZ = FMath::FloorToInt(WorldZ / CellSize); + + const TArray>& Edges = GetCellEdges(FIntVector(CX, CY, CZ)); + + const FVector Pos(WorldX, WorldY, WorldZ); + float Sdf = FLT_MAX; + for (const FEdge& E : Edges) + { + Sdf = FMath::Min(Sdf, VoxelSDF::Capsule(Pos, E.A, E.B, CorridorRadius)); + } + // Union de formes ⇒ MIN sur le canal SDF (voir la note de signe dans VoxelDensityOp.h : + // « min » ici veut dire l'inverse de ce qu'il veut dire sur le canal densité). + InOut.Sdf = FMath::Min(InOut.Sdf, Sdf); + } + + // This source writes SDF only. The following converter decides whether that SDF carves; + // the source itself has no density effect in isolation. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; + } + + /** Exact cell coverage plus a geometric lower bound for every open edge. The upper bound + * is FLT_MAX because a far point can retain an arbitrarily positive capsule distance. */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / CellSize); + const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / CellSize); + const int32 CZ0 = FMath::FloorToInt((float)VoxelBox.Min.Z / CellSize); + const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / CellSize); + const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / CellSize); + const int32 CZ1 = FMath::FloorToInt((float)VoxelBox.Max.Z / CellSize); + + constexpr int64 MaxCellsScanned = 64 * 64 * 64; + const int64 CellCount = (int64)(CX1 - CX0 + 1) * (CY1 - CY0 + 1) * (CZ1 - CZ0 + 1); + if (CellCount <= 0 || CellCount > MaxCellsScanned) + { + InOut.SetUnknown(); + return; + } + + float Lower = FLT_MAX; + bool bAnyEdge = false; + for (int32 cz = CZ0; cz <= CZ1; ++cz) + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + const TArray>& Edges = + GetCellEdges(FIntVector(cx, cy, cz)); + for (const FEdge& E : Edges) + { + bAnyEdge = true; + const FVector SegmentMin( + FMath::Min(E.A.X, E.B.X), FMath::Min(E.A.Y, E.B.Y), FMath::Min(E.A.Z, E.B.Z)); + const FVector SegmentMax( + FMath::Max(E.A.X, E.B.X), FMath::Max(E.A.Y, E.B.Y), FMath::Max(E.A.Z, E.B.Z)); + Lower = FMath::Min(Lower, + VF_DistanceBetweenBoxes(VoxelBox.Min, VoxelBox.Max, + SegmentMin, SegmentMax) - CorridorRadius); + } + } + + FVoxelBoxSdfInterval Own; + if (!bAnyEdge) { Own.Set(FLT_MAX, FLT_MAX); } + else { Own.Set(Lower, FLT_MAX); } + InOut.MinWith(Own); + } + + private: + struct FEdge { FVector A, B; }; + + FVector NodeCenter(int32 X, int32 Y, int32 Z) const + { + return FVector((X + 0.5f) * CellSize, (Y + 0.5f) * CellSize, (Z + 0.5f) * CellSize); + } + + /** + * Le cache par CELLULE. Il est `thread_local` et non + * membre parce que la pile est PARTAGÉE entre workers en lecture — un membre mutable serait + * une course. La reconstruction seule inspecte le petit voisinage ; Eval ne refait aucune + * décision de parent ou de boucle. + * + * ⚠️ PHASE 3 : quand les opérateurs deviendront des assets partagés, il faudra un objet + * d'état PAR WORKER plutôt que ce `thread_local` (qui est global à la fonction, donc partagé + * entre DEUX piles Maze différentes sur le même thread — la clé le rattrape, mais au prix + * d'un rebuild à chaque alternance). + */ + const TArray>& GetCellEdges(const FIntVector& Cell) const + { + thread_local TArray> MZ_Edges; + thread_local FIntVector MZ_Cell(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local uint32 MZ_Seed = 0xFFFFFFFFu; + thread_local float MZ_CS = -1.0f, MZ_Branch = -1.0f, MZ_Vert = -1.0f; + + if (Cell != MZ_Cell || Salt != MZ_Seed || CellSize != MZ_CS || + BranchProbability != MZ_Branch || Verticality != MZ_Vert) + { + MZ_Cell = Cell; MZ_Seed = Salt; MZ_CS = CellSize; + MZ_Branch = BranchProbability; MZ_Vert = Verticality; + MZ_Edges.Reset(); + + // Canonical lower-node edges in {-1,0} per axis cover every corridor that can + // reach this voxel's cell. IsOpenEdge checks both endpoints, including a +1 + // node's parent choice, without needing a wider cache window. + for (int32 dz = -1; dz <= 0; dz++) + for (int32 dy = -1; dy <= 0; dy++) + for (int32 dx = -1; dx <= 0; dx++) + { + const int32 nx = Cell.X + dx, ny = Cell.Y + dy, nz = Cell.Z + dz; + const FVector A = NodeCenter(nx, ny, nz); + + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::X, + Salt, BranchProbability, Verticality)) + { + MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) }); + } + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::Y, + Salt, BranchProbability, Verticality)) + { + MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) }); + } + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::Z, + Salt, BranchProbability, Verticality)) + { + MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) }); + } + } + } + return MZ_Edges; + } + + float CellSize, CorridorRadius, BranchProbability, Verticality; + uint32 Salt; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : DALLE / SLAB VOID (FlatPlain ET CrystalChamber) + //========================================================================= + // Surface de sol, surface de plafond, puis `Density = -min(distAuSol, distAuPlafond)`. + // + // DEUX archétypes, UN opérateur, sans le moindre branchement sur le type — CrystalChamber + // n'est rien d'autre que FlatPlain avec un `CeilingRoughness` plus grand (OPSTACK-PLAN §4) : + // la différence entre eux est un jeu de valeurs par défaut. + // + // Two archetypes, ONE op, with no branch on the type. + // CrystalChamber IS FlatPlain with a bigger CeilingRoughness. + // + // XY-PUR depuis §3.1 (le terme en Z des deux bruits est parti). C'est ce qui rend + // `ClassifyBox` exact plutôt qu'estimé — voir plus bas. + class FSlabVoidSource final : public IVoxelDensityOp + { + public: + FSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) + : SeedU((uint32)Seed) + , FloorRoughness(P.FloorRoughness) + , FloorFrequency(P.FloorRoughnessFrequency) + , CeilRoughness(P.CeilingRoughness) + , CeilFrequency(P.CeilingRoughnessFrequency) + { + const float StrateHeight = P.StrateTopWorldZ - P.StrateBottomWorldZ; + FloorZ = P.StrateBottomWorldZ + StrateHeight * P.FloorRelativeHeight; + CeilZ = P.StrateBottomWorldZ + StrateHeight * P.CeilingRelativeHeight; + + // FBM's proved supremum is 1.5, not the parameter's nominal [-1,1] label. Bounds must + // cover every authored value, so never normalise the roughness parameter to repair a + // bound: multiply the actual maximum noise envelope instead. + FloorAmp = VOXEL_NOISE_SCALE * VF_PerlinAbsBound * FMath::Abs(FloorRoughness); + CeilAmp = VOXEL_NOISE_SCALE * VF_PerlinAbsBound * FMath::Abs(CeilRoughness); + } + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + // ⚠️⚠️ `false`, ET NE PAS LE PASSER À `true`. + // + // Le contrat de `IsXYPure` est « **`Eval`** ne dépend pas de Z » — pas « les surfaces ne + // dépendent pas de Z ». Or `Eval` calcule `min(Z - sol, plafond - Z)` : il dépend de Z de + // la façon la plus directe qui soit. §3.1 a rendu les SURFACES pures en XY ; la DENSITÉ, + // elle, ne l'a jamais été et ne peut pas l'être — c'est une distance à une surface. + // + // Le jour où un cache de colonnes générique lit ce drapeau, un `true` ici ferait partager + // UNE valeur de densité sur TOUTE la pile verticale de chunks — un monde silencieusement + // faux, que `ValidateDeterminism` ne verrait pas parce qu'il échantillonne le long d'un + // bord X. C'est exactement le piège que l'avertissement de `VoxelDensityOp.h` décrit. + // + // ⚠️ Must stay `false`. The contract is "**Eval** does not depend on Z", and Eval computes + // min(Z - floor, ceil - Z). §3.1 made the SURFACES XY-pure; the DENSITY never was and + // cannot be — it is a distance to a surface. A generic column cache reading `true` here + // would share one density down the whole vertical chunk stack. + // + // C'est précisément cette distinction qui justifie l'espace-hauteur (`VoxelHeightOp.h`) : + // ce qui est pur en XY, ce sont les HAUTEURS, et elles y sont dans un type qui n'a pas de Z. + bool IsXYPure() const override { return false; } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float FloorSurface = SurfaceFloor(WorldX, WorldY); + const float CeilSurface = SurfaceCeil(WorldX, WorldY, FloorSurface); + + const float DistAboveFloor = WorldZ - FloorSurface; + const float DistBelowCeil = CeilSurface - WorldZ; + const float VoidField = FMath::Min(DistAboveFloor, DistBelowCeil); + + InOut.Density = -VoidField; // Replace : interne, positif = solide + } + + //--------------------------------------------------------------------- + // LE VERDICT QUE FLATPLAIN N'A JAMAIS EU + //--------------------------------------------------------------------- + // `ClassifyTile` ne prouve AUCUNE tuile pour les archétypes de grotte aujourd'hui. Ici la + // preuve est immédiate et n'exige aucun échantillonnage : les deux surfaces vivent dans des + // BANDES en Z dont on connaît les bornes exactes, donc une boîte entièrement sous la bande + // du sol est solide, et une boîte entièrement entre les deux bandes est de l'air. + // + // ⚠️ Conservatif dans le bon sens : rendre `Mixed` ne coûte que du CPU, rendre le mauvais + // verdict est un TROU. Toutes les comparaisons ci-dessous sont donc strictes et prennent le + // pire cas des deux bruits. + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + const float ZMin = (float)VoxelBox.Min.Z; + const float ZMax = (float)VoxelBox.Max.Z; + + // Bornes de la surface de sol : FloorZ ± FloorAmp. + const float FloorLo = FloorZ - FloorAmp; + const float FloorHi = FloorZ + FloorAmp; + + // Bornes du plafond. `CeilNoise = |bruit| · rugosité` ∈ [0, CeilAmp] ⇒ la surface ne + // peut que DESCENDRE depuis CeilZ… sauf que le clamp `Max(…, FloorSurface + 2)` peut la + // remonter. Le majorant honnête est donc le max des deux possibilités. + const float CeilLo = CeilZ - CeilAmp; + const float CeilHi = FMath::Max(CeilZ, FloorHi + 2.0f); + + // Sous le sol le plus bas possible ⇒ distAuSol < 0 partout ⇒ densité > 0 ⇒ SOLIDE. + if (ZMax < FloorLo) { return EVoxelTileClass::AllSolid; } + + // Au-dessus du plafond le plus haut possible ⇒ distAuPlafond < 0 ⇒ SOLIDE. + if (ZMin > CeilHi) { return EVoxelTileClass::AllSolid; } + + // Strictement entre les deux bandes ⇒ les deux distances sont > 0 ⇒ densité < 0 ⇒ AIR. + // (Les colonnes peuvent re-remplir cet air : c'est FGridColumnMod qui le déclare, en + // rendant FillOnly quand une colonne atteint la boîte. Le pliage s'en charge.) + if (ZMin > FloorHi && ZMax < CeilLo) { return EVoxelTileClass::AllAir; } + + return EVoxelTileClass::Mixed; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant + } + + private: + // The surface coordinates stay in float; an FVector (double) round-trip is not part of + // the output contract. + float SurfaceFloor(float WorldX, float WorldY) const + { + if (FloorRoughness <= 0.0f) { return FloorZ; } + const float FF = FloorFrequency; + const FVector3f NoisePos(WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), + 0.0f); + const float N = VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, + VoxelGenLOD::Eff(3), 2.0f, 0.5f) + * VOXEL_NOISE_SCALE * FloorRoughness; + return FloorZ + N; + } + + float SurfaceCeil(float WorldX, float WorldY, float FloorSurface) const + { + float CeilNoise = 0.0f; + if (CeilRoughness > 0.0f) + { + const float CF = CeilFrequency; + const FVector3f NoisePos(WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, + 3000.0f); + const float Raw = VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, + VoxelGenLOD::Eff(3), 2.0f, 0.5f) + * VOXEL_NOISE_SCALE; + // abs() ⇒ les formations ne pendent QUE vers le bas. + CeilNoise = FMath::Abs(Raw) * CeilRoughness; + } + return FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f); + } + + uint32 SeedU; + float FloorZ = 0.0f, CeilZ = 0.0f; + float FloorRoughness, FloorFrequency; + float CeilRoughness, CeilFrequency; + float FloorAmp = 0.0f, CeilAmp = 0.0f; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : COLONNE DE SURFACE / SURFACE COLUMN (SurfaceWorld) + //========================================================================= + // Le pont entre les deux espaces : consomme DEUX piles de hauteur (sol et voûte) et en fait une + // densité : + // + // Density = TerrainZ - Z ← solide sous le sol + // Density = max(Density, Z - CeilSurf) ← solide au-dessus de la voûte + // + // ⚠️ `IsXYPure()` est **false**, et la distinction est LE point de tout ce découpage : les + // HAUTEURS sont pures en XY (elles vivent dans `VoxelHeightOp.h`, un type sans Z), la DENSITÉ + // ne l'est pas et ne peut pas l'être — c'est une distance à une surface. Confondre les deux est + // exactement le bug que `FSlabVoidSource` portait jusqu'à aujourd'hui. + // + // The bridge between the two spaces: consumes two HEIGHT stacks and turns them into density. + // IsXYPure is false — the heights are XY-pure, the density is a distance to them and never can be. + class FSurfaceColumnSource final : public IVoxelDensityOp + { + public: + /** + * @param PerBiome vide ⇒ pas de biomes, chemin d'origine inchangé. Non vide ⇒ le sol est + * mélangé et le plafond sélectionné par `InField`. + * @param InField **POSSÉDÉ** — délibérément, plutôt qu'un pointeur nu. L'adaptateur réel + * pointe vers des `thread_local` du générateur ; lier sa durée de vie à + * celle de la pile (elle-même `thread_local`, reconstruite au même moment) + * rend la question de survie structurelle au lieu de la laisser à une + * convention que le prochain lecteur devrait deviner. + * OWNED on purpose rather than borrowed: tying its lifetime to the stack's + * makes the survival question structural instead of conventional. + */ + FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed, + const TArray& PerBiome, + TUniquePtr InField) + : P(InP), BiomeParams(PerBiome), Field(MoveTemp(InField)) + { + if (BiomeParams.Num() > 0) + { + // Chemin BIOMES : une pile complète par biome, sol mélangé / plafond sélectionné. + TerrainStack.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(BiomeParams, Seed, Field.Get())); + CeilingStack.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(BiomeParams, Seed, Field.Get())); + + // Un champ structurel PAR BIOME : la pente de l'overhang doit venir du champ du + // biome DOMINANT (l'original échantillonne `*PD`), pas d'un champ moyen. + PerBiomeStructural.Reserve(BiomeParams.Num()); + for (const FSurfaceGenerationParams& BP : BiomeParams) + { + const IVoxelHeightOp* Raw = nullptr; + PerBiomeStructural.Add(VoxelHeightOps::MakeStructuralHeightSource(BP, Seed, &Raw)); + } + Structural = PerBiomeStructural.Num() > 0 ? PerBiomeStructural[0].Get() : nullptr; + } + else + { + BuildSingleBiome(InP, Seed); + } + + // Identité unique et NON RECYCLÉE — la clé du mémo par colonne. `this` ne suffirait + // pas : une pile détruite puis une autre allouée à la même adresse avec d'autres params + // donnerait un faux positif silencieux. Un compteur qui ne redescend jamais l'interdit. + // Assignée ICI et nulle part ailleurs : l'autre constructeur délègue à celui-ci. + // A unique, never-recycled id — assigned here only; the other ctor delegates. + static std::atomic NextId{ 1 }; + InstanceId = NextId.fetch_add(1, std::memory_order_relaxed); + + // Départ sur l'identité D'INSTANCE, pas sur 0 : les entrées du mémo s'initialisent à + // `Key = 0`, donc une clé nulle ferait FAUSSEMENT toucher le slot vierge en (0,0). + // `PrepareChunk` remplacera ceci par la clé partagée de strate ; sans lui, on garde un + // cache par instance — moins de partage, mais correct. Dégrader, jamais mentir. + // Starting at the INSTANCE id rather than 0: slots initialise to Key = 0, so a zero key + // would falsely hit the pristine slot at (0,0). PrepareChunk upgrades this to the shared + // strate key; without it we simply cache per instance. Degrade, never lie. + ColumnKey = InstanceId; + + // Empreinte des params qui déterminent une colonne. `FSurfaceGenerationParams` est du + // POD pur (que des float/int/bool, vérifié : aucun TArray, FString ni pointeur), donc + // un CRC mémoire ne peut pas produire de FAUX POSITIF — au pire du padding non + // initialisé donne un faux NÉGATIF, c'est-à-dire un recalcul. Se tromper du côté qui + // coûte du CPU plutôt que du côté qui rend une mauvaise colonne. + // Pure POD (verified: no TArray/FString/pointer), so a memory CRC cannot produce a false + // HIT; at worst padding causes a false miss, i.e. a recompute. Err toward CPU, not lies. + ParamsFingerprint = FCrc::MemCrc32(&P, sizeof(P)); + for (const FSurfaceGenerationParams& BP : BiomeParams) + { + ParamsFingerprint = VoxelHash::Mix(ParamsFingerprint ^ FCrc::MemCrc32(&BP, sizeof(BP))); + } + } + + void BuildSingleBiome(const FSurfaceGenerationParams& InP, int32 Seed) + { + // Construite à la main (pas via BuildSurfaceHeightStack) pour GARDER le pointeur vers la + // source structurelle : l'overhang en a besoin, pour son gradient de pente comme pour + // son ré-échantillonnage amont. Même dépendance que le cliff, même raison. + TerrainStack.Add(VoxelHeightOps::MakeStructuralHeightSource(InP, Seed, &Structural)); + TerrainStack.Add(VoxelHeightOps::MakeCliffHeightMod(InP, Structural)); + TerrainStack.Add(VoxelHeightOps::MakeTerraceHeightMod(InP)); + TerrainStack.Add(VoxelHeightOps::MakeLayerLineHeightMod(InP)); + TerrainStack.Add(VoxelHeightOps::MakeBeachHeightMod(InP)); + + VoxelHeightOps::BuildSurfaceCeilingStack(CeilingStack, InP, Seed); + } + + /** La colonne complète, exactement les cinq sorties de `ComputeSurfaceColumn`. + * Le mémo est un LRU spatial de six boîtes à index direct, comme `GSurfColCache` : la + * pile évalue tous les Z d'une colonne au même XY, donc l'overhang lit la MÊME colonne + * que la source, par construction plutôt que par convention. + * + * The memo is a six-box spatial LRU with direct XY indexing, matching `GSurfColCache`. + * Six boxes retain interleaved strate regions at the cost of roughly 0.79 MiB of TLS for + * the five-float column payload plus one computed flag per cell, before compiler padding. */ + struct FColumn { float TerrainZ, CeilSurf, OverhangAmp, DirX, DirY; }; + + const FColumn& GetColumn(float WorldX, float WorldY) const + { + // Même schéma éprouvé que `GSurfColCache` : six boîtes à index direct dans XY, chacune + // avec un drapeau `Computed` par cellule et une clé uint64 exacte. Une tuile MC pleine + // résolution demande 35×35 = 1225 colonnes (anneau de marge inclus) ; une boîte de + // Dim×Dim, recentrée sur le premier échantillon, les garde toutes sans éviction. + // Same proven scheme as `GSurfColCache`: six direct-indexed XY boxes, each with one + // `Computed` flag per cell and an exact uint64 key. A full-resolution MC tile needs + // 35×35 = 1225 columns including its margin ring; one Dim×Dim box holds that tile. + struct FColumnBox + { + enum : int32 { Halo = CHUNK_SIZE + 8, Dim = 2 * Halo + 1 }; + int32 BaseX = 0, BaseY = 0; + uint64 Key = 0; // strate + layout + seed + ParamsFingerprint + uint32 LastUse = 0; // LRU stamp + bool bValid = false; + FColumn Cols[Dim * Dim]; + bool Computed[Dim * Dim]; + }; + + struct FColumnCache + { + enum : int32 { NumBoxes = 6 }; + FColumnBox Boxes[NumBoxes]; + uint32 Clock = 0; + + // Hit exact : clé complète + couverture XY complète. En cas de miss, seul le + // victim LRU est recentré et invalidé ; les cinq autres boîtes restent chaudes. + // Exact hit: full key + full XY coverage. On a miss, only the LRU victim is + // recentered and invalidated; the other five boxes remain warm. + FColumnBox& Acquire(int32 IX, int32 IY, uint64 InColumnKey) + { + ++Clock; + for (FColumnBox& B : Boxes) + { + if (B.bValid && B.Key == InColumnKey + && IX >= B.BaseX && IX < B.BaseX + FColumnBox::Dim + && IY >= B.BaseY && IY < B.BaseY + FColumnBox::Dim) + { + B.LastUse = Clock; + return B; + } + } + + // Miss d'acquisition : évincer/recentrer une seule boîte, jamais tout le cache. + // Acquisition miss: evict/recenter one box only, never the whole cache. + FColumnBox* Victim = &Boxes[0]; + for (FColumnBox& B : Boxes) + { + if (B.LastUse < Victim->LastUse) Victim = &B; + } + Victim->BaseX = IX - FColumnBox::Halo; + Victim->BaseY = IY - FColumnBox::Halo; + Victim->Key = InColumnKey; + Victim->LastUse = Clock; + Victim->bValid = true; + FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed)); + return *Victim; + } + }; + + thread_local FColumnCache Cache = {}; + thread_local FColumn DirectColumn = {}; + + // The production mesher and the exact-lattice classifier use integer XY. Fractional + // XY is still valid for the public density/equivalence probes: compute it directly so + // no integer cell can ever be returned for a different full (WorldX, WorldY) pair. + const bool bIntegerXY = WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY); + FColumn* MemoColumn = &DirectColumn; + // ⚠️ La boîte acquise doit survivre au `if` : le drapeau `Computed` n'est posé qu'APRÈS + // le calcul, plus bas, hors de cette portée. Non nul ⇔ chemin XY entier. + // The acquired box must outlive the `if`: the `Computed` flag is only set AFTER the + // column is computed, further down and outside this scope. Non-null <=> integer path. + FColumnBox* AcquiredBox = nullptr; + int32 CI = 0; + bool bNeedsCompute = true; + + if (bIntegerXY) + { + const int32 IX = (int32)WorldX; + const int32 IY = (int32)WorldY; + + // Acquire vérifie la clé uint64 complète et les bornes exactes avant de dériver CI. + // Acquire checks the exact uint64 key and exact bounds before deriving CI. + FColumnBox& Box = Cache.Acquire(IX, IY, ColumnKey); + AcquiredBox = &Box; + + CI = (IY - Box.BaseY) * FColumnBox::Dim + (IX - Box.BaseX); + MemoColumn = &Box.Cols[CI]; + if (Box.Computed[CI]) + { + INC_DWORD_STAT(STAT_VoxelForgeColumnMemoHit); + bNeedsCompute = false; + } + else + { + INC_DWORD_STAT(STAT_VoxelForgeColumnMemoMiss); + } + } + else + { + INC_DWORD_STAT(STAT_VoxelForgeColumnMemoMiss); + } + + if (bNeedsCompute) + { + FColumn& C = *MemoColumn; + + C.TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY); + C.CeilSurf = CeilingStack.EvalHeight(WorldX, WorldY); + C.OverhangAmp = 0.0f; C.DirX = 0.0f; C.DirY = 0.0f; + + // Gate d'overhang par colonne : pente issue d'une différence AVANT du champ + // STRUCTUREL, à l'échelle de la portée mais CLAMPÉE à [4,16]. Sans ce clamp, une + // grande `Reach` moyenne la pente sur une énorme portée et lit même une vraie + // falaise comme plate — le bug « grande Reach = rien ». Transcrit tel quel. + // Quel jeu de params gouverne cette colonne ? Sans biomes, `P`. Avec, le DOMINANT + // pour la pente et le seuil, et une interpolation de l'AMPLITUDE vers le voisin — + // exactement ce que fait `ComputeSurfaceColumn` (`Lerp(Amp(PD), Amp(PN), W)`, pente + // depuis `*PD` seul). Interpoler la pente n'aurait pas de sens : c'est une mesure du + // terrain, pas un réglage. + const FSurfaceGenerationParams* PD = &P; + const FSurfaceGenerationParams* PN = nullptr; + float W = 0.0f; + const IVoxelHeightOp* SlopeField = Structural; + + if (BiomeParams.Num() > 0 && Field) + { + const FVoxelBiomeWeights BW = Field->SampleAt(WorldX, WorldY); + const int32 Di = BiomeParams.IsValidIndex(BW.Dominant) ? BW.Dominant : 0; + PD = &BiomeParams[Di]; + if (PerBiomeStructural.IsValidIndex(Di)) { SlopeField = PerBiomeStructural[Di].Get(); } + if (BW.NeighborWeight > 0.0f && BiomeParams.IsValidIndex(BW.Neighbor)) + { + PN = &BiomeParams[BW.Neighbor]; + W = BW.NeighborWeight; + } + } + + const bool bAnyOverhang = (PD->OverhangStrength > 0.0f) + || (PN && PN->OverhangStrength > 0.0f); + + if (bAnyOverhang && SlopeField != nullptr) + { + const float SD = FMath::Clamp(PD->OverhangReach, 4.0f, 16.0f); + const float Z0 = SampleStructuralOf(SlopeField, WorldX, WorldY); + const float GX = (SampleStructuralOf(SlopeField, WorldX + SD, WorldY) - Z0) / SD; + const float GY = (SampleStructuralOf(SlopeField, WorldX, WorldY + SD) - Z0) / SD; + const float Slope = FMath::Sqrt(GX * GX + GY * GY); + + auto Amp = [Slope](const FSurfaceGenerationParams& Q) -> float + { + if (Q.OverhangStrength <= 0.0f) { return 0.0f; } + const float Thr = FMath::Max(Q.OverhangSlopeThreshold, 0.05f); + const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + return Q.OverhangStrength * Gate; // [0,1] + }; + C.OverhangAmp = (W > 0.0f && PN) ? FMath::Lerp(Amp(*PD), Amp(*PN), W) : Amp(*PD); + + // Direction amont unitaire (le gradient pointe vers le haut). Dégénérée sur le + // plat — mais l'amplitude y vaut 0 de toute façon. + if (Slope > KINDA_SMALL_NUMBER) { C.DirX = GX / Slope; C.DirY = GY / Slope; } + } + + if (AcquiredBox) { AcquiredBox->Computed[CI] = true; } + } + return *MemoColumn; + } + + /** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont. + * Avec des biomes, c'est celui du biome DOMINANT en ce point (l'original emprunte à `*PD`). */ + float SampleStructural(float WorldX, float WorldY) const + { + const IVoxelHeightOp* Src = Structural; + if (BiomeParams.Num() > 0 && Field) + { + const FVoxelBiomeWeights BW = Field->SampleAt(WorldX, WorldY); + const int32 Di = PerBiomeStructural.IsValidIndex(BW.Dominant) ? BW.Dominant : 0; + if (PerBiomeStructural.IsValidIndex(Di)) { Src = PerBiomeStructural[Di].Get(); } + } + return SampleStructuralOf(Src, WorldX, WorldY); + } + + static float SampleStructuralOf(const IVoxelHeightOp* Src, float WorldX, float WorldY) + { + if (!Src) { return 0.0f; } + FVoxelHeightSample S; + Src->Eval(WorldX, WorldY, S); + return S.Height; + } + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask ProvidedResources() const override { return VoxelOpResources::SurfaceColumn; } + bool IsAdditive() const override { return false; } + bool IsXYPure() const override { return false; } // voir le bloc ci-dessus + + /** + * ⚠️ C'EST ICI QUE SE JOUE LA PERF DE CET ARCHÉTYPE. + * + * Ne PAS clé ce mémo sur `InstanceId` : il change à CHAQUE reconstruction de pile, + * c'est-à-dire à chaque chunk, et une strate haute de 4 chunks recalculerait ses colonnes + * **4 fois**, resamples du cliff compris. `GSurfColCache` est clé sur + * `(boîte XY, StrateKey, Seed, LayoutVersion)` **SANS ChunkZ**, délibérément, « shared down + * the whole vertical strate stack ». Cette pile reprend la même identité de + * strate/layout/seed, en ajoutant l'empreinte obligatoire des params pour protéger ses + * sorties propres ; son mémo est un LRU spatial de six boîtes. + * + * Donc la clé garde l'identité partagée : ce qui rend deux colonnes interchangeables, c'est + * la STRATE, le seed, la version de layout et les params, pas le chunk. Le mémo étant + * `thread_local`, il SURVIT à la reconstruction de la pile — seule la clé l'invalide. + * + * POURQUOI C'EST SÛR : les hauteurs sont XY-pures par construction (c'est tout l'objet de + * `VoxelHeightOp.h`, où le type n'a pas de Z), et le champ de biomes est documenté + * XY-pur — « ZERO Z dependence: the climate/Voronoi fields are pure-XY ». C'est exactement + * la justification sur laquelle `GSurfColCache` repose déjà. + * + * Do not key the memo on InstanceId: it changes every chunk, so a 4-chunk strate would + * recompute every column 4x. GSurfColCache deliberately omits ChunkZ and shares down the whole + * vertical stack; this shares the same strate/layout/seed identity and adds the required + * params fingerprint for its own outputs. The six-box LRU keeps independent XY regions alive. Safe + * because heights are XY-pure by type and the biome field is documented Z-independent. + */ + void PrepareChunk(const FVoxelOpContext& Ctx) override + { + // `StrateBottomWorldZ` est unique par strate empilée — la même valeur que + // `CP_StrateKey` utilise dans `GetDensityAt`. La version de layout entre dans la clé + // (AUDIT §C2) : une édition à chaud qui change les params sans déplacer la strate doit + // invalider, sinon on sert des colonnes périmées. + const uint32 A = (uint32)FMath::RoundToInt(Ctx.StrateBottomWorldZ); + const uint32 B = Ctx.LayoutVersion; + const uint32 C = Ctx.Seed; + + // ⚠️ `ParamsFingerprint` EST OBLIGATOIRE, et son absence a été un vrai bug — attrapé par + // `SurfaceHeightEquivalence` au build suivant (69/20000 écarts, 1 traversée d'iso). + // + // Sans lui, la clé ne contenait que (strate, layout, seed). Deux piles de la MÊME strate + // avec des params DIFFÉRENTS obtenaient donc la même clé et se partageaient les colonnes : + // la seconde lisait les colonnes de la première, calculées avec `OverhangAmp = 0`, et + // l'overhang disparaissait purement et simplement. + // + // ET CE N'EST PAS QU'UN ARTEFACT DE TEST : c'est exactement la faiblesse que le code + // documente déjà pour `GSurfColCache` (VoxelGenerator.cpp, note AUDIT §C2 étendue) — + // « une édition à chaud qui change les params SANS déplacer la strate laisse la clé + // identique et sert des colonnes périmées ». En production `LayoutVersion` bouge à chaque + // `RebuildStrates`, ce qui masque le trou ; ma clé en avait hérité, et le test l'a trouvé + // tout de suite. Empreinte incluse ⇒ le trou est fermé ici, pas seulement masqué. + // + // The fingerprint is REQUIRED: without it two stacks of the same strate with different + // params shared columns, and the overhang silently vanished. Same weakness the codebase + // already documents for GSurfColCache, which LayoutVersion merely masks. + ColumnKey = ((uint64)VoxelHash::Mix(A ^ VoxelHash::Mix(B)) << 32) + | (uint64)VoxelHash::Mix(C ^ VoxelHash::Mix(A) ^ ParamsFingerprint); + if (ColumnKey == 0) { ColumnKey = 1; } // 0 = « jamais préparé » + } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + // Le mémo par colonne vit ici, dans six boîtes thread_local partagées par les instances + // mais séparées par la clé, et lues par les Eval de cette source et FOverhangShelfMod. + // Il est séparé de `GSurfColCache` : la pile possède ses propres sorties et sa clé + // complète (strate + layout + seed + empreinte des params), donc réutiliser le cache + // du générateur serait incorrect. + // The per-column memo lives here in six thread-local boxes shared across instances but + // separated by the key, and read by this source's Eval calls and FOverhangShelfMod. + // It is separate from `GSurfColCache`: the stack owns its own outputs and full key. + const FColumn& C = GetColumn(WorldX, WorldY); + + float Density = C.TerrainZ - WorldZ; + Density = FMath::Max(Density, WorldZ - C.CeilSurf); + InOut.Density = Density; // Replace : interne, positif = solide + } + + // Mixed, honnêtement. Un verdict exact demanderait de borner le heightfield structurel sur + // la boîte XY (continents + montagnes + détail sous un domain-warp) — faisable, mais c'est + // une vraie borne à dériver, pas une constante à lire comme pour la dalle. Rendre Mixed ne + // coûte que du CPU ; rendre faux serait un trou. À faire quand `MaxDisplacement` saura + // répondre pour la source structurelle. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; + } + + private: + FSurfaceGenerationParams P; + FVoxelHeightStack TerrainStack; + FVoxelHeightStack CeilingStack; + const IVoxelHeightOp* Structural = nullptr; // NON possédant : la pile terrain le possède + + // Chemin BIOMES. Vide ⇒ chemin d'origine, inchangé bit pour bit. + TArray BiomeParams; + TUniquePtr Field; // POSSÉDÉ (voir le constructeur) + TArray> PerBiomeStructural; // pente d'overhang par biome + + uint64 InstanceId = 0; // unique, jamais recyclée — le repli quand PrepareChunk n'a pas eu lieu + uint64 ColumnKey = 0; // l'identité PARTAGÉE (strate + layout + seed + params) : voir PrepareChunk + uint32 ParamsFingerprint = 0; // sans lui, deux piles de la même strate se volaient leurs colonnes + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : ÉTAGÈRE D'OVERHANG / OVERHANG SHELF (le seul op vraiment 3D) + //========================================================================= + // Pour les voxels d'AIR dans une fenêtre juste au-dessus d'une pente raide, ré-échantillonne le + // heightfield EN AMONT (vers la falaise) d'une distance qui CROÎT avec la hauteur, et fait + // l'union de cette roche → la roche du haut de falaise déborde AU-DESSUS du vide, avec de l'air + // EN DESSOUS : un vrai surplomb. + // + // ⚠️ Il dépend de Z de façon essentielle — `Frac` fait varier la portée avec l'altitude. C'est + // le seul op de SurfaceWorld qui ne pouvait PAS vivre en espace-hauteur, et c'est exactement + // pour ça que la frontière entre les deux espaces est utile : elle est passée là où le code + // change de nature, pas là où c'était commode. + // + // The one op here that genuinely depends on Z (the uphill reach grows with height), which is + // precisely why it could not live in height space. The boundary between the two spaces falls + // where the code changes nature. + class FOverhangShelfMod final : public IVoxelDensityOp + { + public: + FOverhangShelfMod(const FSurfaceGenerationParams& InP, int32 Seed, + const FSurfaceColumnSource* InColumn) + : P(InP), SeedU((uint32)Seed), Column(InColumn) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::SurfaceColumn; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + bool IsXYPure() const override { return false; } // franchement non : voir `Frac` + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (Column == nullptr || P.OverhangHeight <= 0.0f) { return; } + + // Même colonne que la source, garantie par le mémo : pas une seconde évaluation. + const FSurfaceColumnSource::FColumn& C = Column->GetColumn(WorldX, WorldY); + if (C.OverhangAmp <= 0.0f) { return; } + + // Gate dur, transcrit : seulement les voxels d'air dans `OverhangHeight` du sol local. + if (!(WorldZ > C.TerrainZ && WorldZ <= C.TerrainZ + P.OverhangHeight)) { return; } + + const float f = P.OverhangFrequency; + // Bruit de forme d'étagère [0,1] ; le terme en Z fait onduler la portée avec la hauteur + // (déchiqueté, pas une lèvre lisse). + const float Ns = HFractal3D(FVector3f( + WorldX * f + VoxelHash::SeedOffset(SeedU, 17.3f), + WorldY * f + VoxelHash::SeedOffset(SeedU, 23.9f), + WorldZ * f * P.OverhangZScale + VoxelHash::SeedOffset(SeedU, 5.1f)), 3) * 0.5f + 0.5f; // [0,1] + + // LA CLÉ : la portée amont CROÎT avec la hauteur dans la fenêtre (Frac : 0 au sol → 1 + // au plafond de la fenêtre). En bas le décalage est minuscule ⇒ on emprunte de la roche + // basse voisine ⇒ ça reste de l'AIR au-dessus du vide ; en haut le décalage atteint la + // falaise ⇒ solide ⇒ la lèvre se pose dessus avec de l'air DESSOUS = un vrai surplomb. + const float Frac = (WorldZ - C.TerrainZ) / P.OverhangHeight; + const float ShiftV = P.OverhangReach * C.OverhangAmp * Frac * Ns; + if (ShiftV > 0.5f) + { + // On emprunte la hauteur STRUCTURELLE amont (pas la surface complète avec ops) : + // le dessous de l'étagère n'a pas besoin du raffinement cliff/terrace, et ça évite + // de relancer les 4 resamples du cliff par voxel de lèvre. + const float ShiftedTZ = Column->SampleStructural(WorldX + C.DirX * ShiftV, + WorldY + C.DirY * ShiftV); + InOut.Density = FMath::Max(InOut.Density, ShiftedTZ - WorldZ); // union + } + } + + // N'ajoute que du solide (`Max`) ⇒ tue AllAir, jamais AllSolid. Conservateur : on ne sait + // pas sans échantillonner si une colonne d'overhang touche la boîte, donc FillOnly partout + // où l'archétype peut en produire, Identity quand il est éteint. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.OverhangStrength > 0.0f && P.OverhangHeight > 0.0f) + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + private: + FSurfaceGenerationParams P; + uint32 SeedU; + const FSurfaceColumnSource* Column; // NON possédant : la pile possède la source + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE SDF + //========================================================================= + // La variante SDF (Maze / VerticalShafts / FloatingIslands) : `Sdf += bruit · échelle · force`. + // Déplace la SURFACE. La variante densité de TunnelNetwork est un opérateur DIFFÉRENT (fade + // quadratique, clamp anti-remplissage, 4 types de bruit) — voir OPSTACK-DECOMPOSITION §1. + class FSdfRoughnessMod final : public IVoxelDensityOp + { + public: + FSdfRoughnessMod(float InStrength, float InFrequency, int32 InBaseOctaves, float InApplyWithin) + : Strength(InStrength), Frequency(InFrequency) + , BaseOctaves(InBaseOctaves), ApplyWithin(InApplyWithin) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Sdf; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } // gate depends on the SDF it writes + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (Strength <= 0.0f || InOut.Sdf >= ApplyWithin) { return; } + + // Keep hot noise coordinates in float. The old FVector round-trip only preserved + // legacy rounding; output compatibility with that field is intentionally abandoned. + const FVector3f NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency); + InOut.Sdf += VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, + VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f) + * VOXEL_NOISE_SCALE * Strength; + } + + // This modifier does not touch density itself. Its interval update is the only box-query + // responsibility; the downstream converter consumes the resulting interval. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Identity; + } + + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + if (!InOut.IsKnown()) { return; } + if (!VoxelMath::IsFinite(Strength) || !VoxelMath::IsFinite(Frequency) + || !VoxelMath::IsFinite(ApplyWithin) || BaseOctaves <= 0) + { + InOut.SetUnknown(); + return; + } + if (Strength <= 0.0f) { return; } + + // If an authored frequency/box product overflows, Eval can publish a non-finite SDF + // and no finite interval is a proof. Unknown costs the skip and protects the geometry. + const float MaxAbsCoord = FMath::Max3( + FMath::Max(FMath::Abs((float)VoxelBox.Min.X), FMath::Abs((float)VoxelBox.Max.X)), + FMath::Max(FMath::Abs((float)VoxelBox.Min.Y), FMath::Abs((float)VoxelBox.Max.Y)), + FMath::Max(FMath::Abs((float)VoxelBox.Min.Z), FMath::Abs((float)VoxelBox.Max.Z))); + if (!VoxelMath::IsFinite(MaxAbsCoord) || !VoxelMath::IsFinite(MaxAbsCoord * FMath::Abs(Frequency))) + { + InOut.SetUnknown(); + return; + } + + // The gate is `Sdf < ApplyWithin`. If the whole input interval is outside it, the + // modifier is exactly the identity. Otherwise add the proven FBM envelope to both + // sides; conditional untouched points are still covered by [Min, Max] + envelope. + if (InOut.Min >= ApplyWithin) { return; } + + const float Amplitude = FMath::Abs(Strength) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound; + InOut.Min = VF_SaturatingAdd(InOut.Min, -Amplitude); + InOut.Max = VF_SaturatingAdd(InOut.Max, Amplitude); + } + + private: + float Strength, Frequency; + int32 BaseOctaves; + float ApplyWithin; + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : COLONNES SUR GRILLE MONDE / WORLD-GRID COLUMNS + //========================================================================= + // Des cylindres de hauteur infinie posés sur une grille de + // `ColumnSpacing`, un tirage d'existence et un jitter par cellule. Le champ de vide décide déjà + // où est le solide, donc la colonne n'a qu'à AJOUTER de la densité le long de son XY — elle + // n'est visible que là où le vide avait creusé autour d'elle. + // + // Le cache 3×3 par cellule est `thread_local`, ce que la note de threading de VoxelDensityOp.h + // autorise. Sa clé + // contient tous les paramètres qui influent sur le résultat + le seed, donc un changement de + // layout qui change un param invalide bien ; un changement qui n'en touche aucun produirait + // des colonnes identiques (cf. AUDIT C2 — la clé est complète, pas seulement le coord). + class FGridColumnMod final : public IVoxelDensityOp + { + public: + explicit FGridColumnMod(const FSlabGenerationParams& P, int32 InSeed) + : Seed((uint32)InSeed) + , Spacing(P.ColumnSpacing) + , ColDensity(P.ColumnDensity) + , MinRadius(P.ColumnMinRadius) + , MaxRadius(P.ColumnMaxRadius) + , BaseDensity(P.BaseDensity) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + bool IsXYPure() const override { return true; } // cylindres de hauteur infinie + + void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override + { + if (ColDensity <= 0.0f || Spacing <= 0.0f) { return; } + + const int32 ColCX = FMath::FloorToInt(WorldX / Spacing); + const int32 ColCY = FMath::FloorToInt(WorldY / Spacing); + + const TArray>& Cols = GetCells(ColCX, ColCY); + + float ColumnSDF = FLT_MAX; + for (const FSlabColumn& Col : Cols) + { + const float DX2D = WorldX - Col.X; + const float DY2D = WorldY - Col.Y; + ColumnSDF = FMath::Min(ColumnSDF, FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - Col.R); + } + + if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX) + { + float Fill = FMath::Clamp((ColBlend - ColumnSDF) / (ColBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + InOut.Density += Fill * BaseDensity * 1.5f; + } + } + + // N'AJOUTE que du solide ⇒ tue AllAir, jamais AllSolid. `Identity` dès qu'aucune colonne + // n'atteint la boîte — ce qui, pour un `ColumnDensity` de 0.08, est l'écrasante majorité du + // volume. C'est cet `Identity` qui laisse survivre le verdict AllAir de la source. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (ColDensity <= 0.0f || Spacing <= 0.0f) { return EVoxelOpEffect::Identity; } + + // Marge : l'enveloppe de `Lerp(MinRadius, MaxRadius, t)` est max(MinRadius, MaxRadius), + // pas `MaxRadius` seul si l'asset inverse les paramètres. The bound must cover both + // endpoints; using `MaxRadius` alone would leave a hole when the asset reverses them. + const float Reach = FMath::Max3(MinRadius, MaxRadius, 0.0f) + ColBlend; + + const int32 CX0 = FMath::FloorToInt(((float)VoxelBox.Min.X - Reach) / Spacing); + const int32 CX1 = FMath::FloorToInt(((float)VoxelBox.Max.X + Reach) / Spacing); + const int32 CY0 = FMath::FloorToInt(((float)VoxelBox.Min.Y - Reach) / Spacing); + const int32 CY1 = FMath::FloorToInt(((float)VoxelBox.Max.Y + Reach) / Spacing); + + for (int32 CY = CY0; CY <= CY1; ++CY) + { + for (int32 CX = CX0; CX <= CX1; ++CX) + { + FSlabColumn Col; + if (!RollColumn(CX, CY, Col)) { continue; } + + // Cercle (rayon + blend) contre le rectangle XY de la boîte. + const float R = Col.R + ColBlend; + const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Col.X, + Col.X - (float)VoxelBox.Max.X)); + const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Col.Y, + Col.Y - (float)VoxelBox.Max.Y)); + if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; } + } + } + return EVoxelOpEffect::Identity; + } + + private: + struct FSlabColumn { float X, Y, R; }; + + static constexpr float ColBlend = 2.0f; + + /** Le tirage d'une cellule : existence, jitter, rayon. Fonction PURE de (cellule, seed, + * params) — donc `Eval` et `EffectOverBox` voient forcément la même colonne. */ + bool RollColumn(int32 CX, int32 CY, FSlabColumn& Out) const + { + const uint32 H = VoxelHash::Cell(CX, CY, Seed ^ 0xC01C01u); + if (VoxelHash::ToFloat01(H) > ColDensity) { return false; } + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + + Out.X = (CX + 0.15f + JX * 0.7f) * Spacing; + Out.Y = (CY + 0.15f + JY * 0.7f) * Spacing; + Out.R = FMath::Lerp(MinRadius, MaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + return true; + } + + /** Le voisinage 3×3 de la cellule centrale, mémoïsé par worker. */ + const TArray>& GetCells(int32 ColCX, int32 ColCY) const + { + thread_local TArray> SC_Cols; + thread_local int32 SC_CX = INT32_MAX, SC_CY = INT32_MAX; + thread_local uint32 SC_Seed = 0xFFFFFFFFu; + thread_local float SC_Spacing = -1.0f, SC_Dens = -1.0f, SC_MinR = -1.0f, SC_MaxR = -1.0f; + + if (ColCX != SC_CX || ColCY != SC_CY || Seed != SC_Seed || Spacing != SC_Spacing || + ColDensity != SC_Dens || MinRadius != SC_MinR || MaxRadius != SC_MaxR) + { + SC_CX = ColCX; SC_CY = ColCY; SC_Seed = Seed; SC_Spacing = Spacing; + SC_Dens = ColDensity; SC_MinR = MinRadius; SC_MaxR = MaxRadius; + SC_Cols.Reset(); + + for (int32 DY = -1; DY <= 1; DY++) + { + for (int32 DX = -1; DX <= 1; DX++) + { + FSlabColumn Col; + if (RollColumn(ColCX + DX, ColCY + DY, Col)) { SC_Cols.Add(Col); } + } + } + } + return SC_Cols; + } + + uint32 Seed; + float Spacing, ColDensity, MinRadius, MaxRadius, BaseDensity; + }; + + //========================================================================= + // RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE et FILL) + //========================================================================= + // Les six mêmes lignes dans TunnelNetwork, Maze, VerticalShafts — et FloatingIslands, où le + // SEUL changement est `Density += Fill·Base·2` au lieu de `Density -= Carve·Base·2`. + // + // Un archétype qui CREUSE dans du roc et un archétype qui REMPLIT du vide sont donc le même + // opérateur au signe près, exactement comme la source constante au-dessus. + // + // ⚠️ `Sign` vaut ±1.0f et rien d'autre. La multiplication par ±1 est EXACTE en IEEE-754, donc + // `D += (-1·F)·B·2` rend bit pour bit ce que `D -= F·B·2` rendait — l'égalité binaire des trois + // portages déjà verts en dépend. + // + // Same operator, opposite sign. Multiplying by ±1 is exact in IEEE-754, so the carve path is + // bit-for-bit what it was before this generalisation — the three green ports depend on that. + class FSdfConvertOp final : public IVoxelDensityOp + { + public: + FSdfConvertOp(float InBlend, float InBaseDensity, float InSign, float InMinDivisor) + : Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign), MinDivisor(InMinDivisor) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float, float, float, FVoxelOpSample& InOut) const override + { + if (InOut.Sdf >= Blend) { return; } + // ⚠️ `MinDivisor` n'est PAS une précaution ajoutée : TunnelNetwork écrit + // `/ FMath::Max(SDFBlendRadius * 2, 1.0f)` là où Maze/Shafts/Islands écrivent `/ (Blend*2)`. + // Les deux formules DIVERGENT dès que `Blend·2 < 1`, donc les confondre serait une faute + // de portage silencieuse. Avec `MinDivisor = 0` et un Blend positif, `Max(x, 0) == x` + // exactement — les trois portages déjà verts ne bougent pas d'un bit. + // Not a safety tweak: TunnelNetwork genuinely floors this divisor at 1 and the others + // do not. Max(x, 0) is exactly x for positive Blend, so existing ports are untouched. + float T = FMath::Clamp((Blend - InOut.Sdf) / FMath::Max(Blend * 2.0f, MinDivisor), 0.0f, 1.0f); + T = SmoothStep01(T); + InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!VoxelMath::IsFinite(Coefficient) || !VoxelMath::IsFinite(Blend) + || !VoxelMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return EVoxelOpEffect::Both; + } + if (Coefficient < 0.0f) { return EVoxelOpEffect::CarveOnly; } + if (Coefficient > 0.0f) { return EVoxelOpEffect::FillOnly; } + return EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); + if (Intrinsic == EVoxelOpEffect::Both || Intrinsic == EVoxelOpEffect::Identity) + { + return Intrinsic; + } + return H.Sdf.IsKnown() && H.Sdf.Min >= Blend + ? EVoxelOpEffect::Identity : Intrinsic; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!VoxelMath::IsFinite(Coefficient) || !VoxelMath::IsFinite(Blend) + || !VoxelMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + return Coefficient < 0.0f ? FMath::Abs(Coefficient) : 0.0f; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!VoxelMath::IsFinite(Coefficient) || !VoxelMath::IsFinite(Blend) + || !VoxelMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + return Coefficient > 0.0f ? FMath::Abs(Coefficient) : 0.0f; + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!VoxelMath::IsFinite(Coefficient)) { return FLT_MAX; } + if (!VoxelMath::IsFinite(Blend) || !VoxelMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + if (Coefficient >= 0.0f) { return 0.0f; } + return IsInactive(H) ? 0.0f : FMath::Abs(Coefficient) * MaxFactor(H); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const float Coefficient = Sign * BaseDensity * 2.0f; + if (!VoxelMath::IsFinite(Coefficient)) { return FLT_MAX; } + if (!VoxelMath::IsFinite(Blend) || !VoxelMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return FLT_MAX; + } + if (Coefficient <= 0.0f) { return 0.0f; } + return IsInactive(H) ? 0.0f : FMath::Abs(Coefficient) * MaxFactor(H); + } + + const TCHAR* DebugName() const override { return TEXT("SdfConvertOp"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + bool IsInactive(const FVoxelBoxHypotheses& H) const + { + return !H.Sdf.IsKnown() ? false : H.Sdf.Min >= Blend; + } + + float MaxFactor(const FVoxelBoxHypotheses& H) const + { + if (!H.Sdf.IsKnown() || !VoxelMath::IsFinite(Blend) + || !VoxelMath::IsFinite(MinDivisor) || Blend <= 0.0f) + { + return 1.0f; + } + + const float Denom = FMath::Max(Blend * 2.0f, MinDivisor); + if (!(Denom > 0.0f) || !VoxelMath::IsFinite(Denom)) { return 1.0f; } + + const float T = FMath::Clamp((Blend - H.Sdf.Min) / Denom, 0.0f, 1.0f); + return SmoothStep01(T); + } + + float Blend, BaseDensity, Sign, MinDivisor; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : SPINE (0,0) + //========================================================================= + class FOriginSpineOp final : public IVoxelDensityOp + { + public: + FOriginSpineOp(float InTopZ, float InBotZ, float InSeal, float InBase, float InRadius) + : TopZ(InTopZ), BotZ(InBotZ), Seal(InSeal), Base(InBase), Radius(InRadius) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override + { + VF_ApplyOriginSpine(InOut.Density, X, Y, Z, TopZ, BotZ, Seal, Base, Radius); + } + + // The room kills AllSolid; its support slab also kills AllAir. Identity when the room + // misses the box or the box is wholly outside this strate's safe interior. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override + { + if (VoxelDensityAblation::IsOriginSpineOff()) + { + return EVoxelOpEffect::Identity; + } + const bool bRoomTouches = Ctx.bUseLatticeProof + ? VoxelPassageGeometry::OriginLandingRoomTouchesLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step, + TopZ, BotZ, Seal, Radius) + : VoxelPassageGeometry::OriginLandingRoomTouchesBox( + VoxelBox, TopZ, BotZ, Seal, Radius); + if (!bRoomTouches) + { + return EVoxelOpEffect::Identity; + } + const bool bFloorTouches = Ctx.bUseLatticeProof + ? VoxelPassageGeometry::OriginLandingFloorTouchesLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step, + TopZ, BotZ, Seal, Radius) + : VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BotZ, Seal, Radius); + return bFloorTouches + ? EVoxelOpEffect::Both : EVoxelOpEffect::CarveOnly; + } + + // The landing carve interpolates from the incoming density toward an air target, so its + // delta is unbounded without an input-density interval. It is nevertheless exactly + // inactive outside the geometric influence box; return zero there so an unrelated block + // does not inherit the interface's conservative FLT_MAX sentinel. + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + const EVoxelOpEffect Effect = EffectOverBox(VoxelBox, Ctx); + return (Effect == EVoxelOpEffect::CarveOnly || Effect == EVoxelOpEffect::Both) + ? FLT_MAX : 0.0f; + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Both ? FLT_MAX : 0.0f; + } + + const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + float TopZ, BotZ, Seal, Base, Radius; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : SEAL DE FRONTIÈRE (l'opérateur FORÇANT) + //========================================================================= + class FBoundarySealOp final : public IVoxelDensityOp + { + public: + FBoundarySealOp(float InTopZ, float InBotZ, float InThickness, float InBase) + : TopZ(InTopZ), BotZ(InBotZ), Thickness(InThickness), Base(InBase) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float, float, float Z, FVoxelOpSample& InOut) const override + { + VF_ApplyBoundarySeal(InOut.Density, Z, TopZ, BotZ, Thickness, Base); + } + + /** + * Dans sa bande, le seal fait `Max(D, SealFactor·Base)` avec SealFactor > 0 : le résultat + * est solide GARANTI quelle qu'ait été l'entrée. C'est un opérateur FORÇANT, et la raison + * d'être de `ClassifyBox` (voir VoxelDensityOp.h). + * + * ⚠️ MARGE DE SÛRETÉ DÉLIBÉRÉE. Au bord INTÉRIEUR de la bande, `1 - Dist/Thickness` peut + * arrondir à exactement 0.0f en float ; SealFactor·Base vaut alors 0, la densité interne + * finit à 0, et le mesher (`D >= IsoLevel`) compte ce point du côté AIR. Prétendre AllSolid + * là serait un TROU. On exige donc que la boîte soit dans la bande avec 1 voxel de marge + * avant de forcer ; sinon on retombe sur le FillOnly, qui est toujours sûr. + * + * (Le `ClassifyTile` actuel n'a pas cette marge — il exclut simplement ces z du test de + * colonne. La fenêtre est infime et demande que l'archétype produise de l'air pile à ce z, + * mais elle est réelle.) + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (VoxelDensityAblation::IsBoundarySealOff()) + { + return EVoxelTileClass::Mixed; + } + if (Thickness <= 0.0f || Base <= 0.0f) { return EVoxelTileClass::Mixed; } + + constexpr float SafetyMargin = 1.0f; + const float Usable = Thickness - SafetyMargin; + if (Usable <= 0.0f) { return EVoxelTileClass::Mixed; } + + const float MinDistTop = TopZ - (float)VoxelBox.Min.Z; // plus petite distance au plafond + const float MaxDistTop = TopZ - (float)VoxelBox.Max.Z; + const bool bWhollyInTopBand = (MaxDistTop >= 0.0f) && (MinDistTop < Usable); + + const float MinDistBot = (float)VoxelBox.Min.Z - BotZ; + const float MaxDistBot = (float)VoxelBox.Max.Z - BotZ; + const bool bWhollyInBotBand = (MinDistBot >= 0.0f) && (MaxDistBot < Usable); + + return (bWhollyInTopBand || bWhollyInBotBand) ? EVoxelTileClass::AllSolid + : EVoxelTileClass::Mixed; + } + + // Hors de sa bande, le seal ne fait rien du tout ; à cheval, il ne peut qu'ajouter du solide. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + { + if (VoxelDensityAblation::IsBoundarySealOff()) + { + return EVoxelOpEffect::Identity; + } + if (Thickness <= 0.0f) { return EVoxelOpEffect::Identity; } + const bool bTouchesTopBand = ((float)VoxelBox.Max.Z >= TopZ - Thickness) && ((float)VoxelBox.Min.Z <= TopZ); + const bool bTouchesBotBand = ((float)VoxelBox.Min.Z <= BotZ + Thickness) && ((float)VoxelBox.Max.Z >= BotZ); + if (!bTouchesTopBand && !bTouchesBotBand) { return EVoxelOpEffect::Identity; } + return EVoxelOpEffect::FillOnly; + } + + // A partial seal can raise an arbitrarily negative incoming density, so its fill delta is + // genuinely unbounded through this API. Outside both bands it is an exact identity. + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity ? 0.0f : FLT_MAX; + } + + const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + float TopZ, BotZ, Thickness, Base; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : SCELLEMENT DE LA LIMITE XY (opérateur FORÇANT) + //========================================================================= + class FXYEdgeSealOp final : public IVoxelDensityOp + { + public: + explicit FXYEdgeSealOp(float InBase) + : Base(InBase) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + + void PrepareChunk(const FVoxelOpContext& Ctx) override + { + // The settings are global, but PrepareChunk is the stack's one per-chunk hand-off. + // Keeping the prepared copy makes Eval hot and keeps the op independent of the asset. + WorldRadius = Ctx.WorldRadiusVoxels; + Thickness = Ctx.EdgeSealThickness; + } + + void Eval(float X, float Y, float, FVoxelOpSample& InOut) const override + { + VF_ApplyXYEdgeSeal(InOut.Density, X, Y, WorldRadius, Thickness, Base); + } + + /** + * The seal is forcing, not merely FillOnly: once every point in the box is at least one + * voxel into the ramp (or beyond the radius), the result is positive-solid regardless of + * all preceding source/carve operators. + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + if (VoxelDensityAblation::IsXYEdgeSealOff()) + { + return EVoxelTileClass::Mixed; + } + return VF_XYEdgeSealBoxIsForcedSolid( + VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness, Base) + ? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed; + } + + float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return VF_XYEdgeSealForcedMarginOverBox( + VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness, Base); + } + + // In the inner world it is Identity; a box that reaches the radial band can only gain rock. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + if (VoxelDensityAblation::IsXYEdgeSealOff()) + { + return EVoxelOpEffect::Identity; + } + return VF_XYEdgeSealBoxTouchesBand( + VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness) + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity ? 0.0f : FLT_MAX; + } + + bool IsXYPure() const override { return true; } + const TCHAR* DebugName() const override { return TEXT("XYEdgeSealOp"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + float Base = 8.0f; + mutable float WorldRadius = 8192.0f; + mutable float Thickness = 64.0f; + }; + + //========================================================================= + // RÔLE 4 — STRUCTUREL : CARVE DE PASSAGE + //========================================================================= + class FPassageCarveOp final : public IVoxelDensityOp + { + public: + FPassageCarveOp(const UVoxelStrateManager* InManager, float InBase, float InSeal, + float InTopZ, float InBotZ, float InSpineRadius) + : Manager(InManager), Base(InBase), Seal(InSeal), + TopZ(InTopZ), BotZ(InBotZ), SpineRadius(InSpineRadius) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const override + { + if (const UVoxelStrateManager* LiveManager = Manager.Get()) + { + // Landing/tunnel support is a shared MC-space post after disturbances. Calling the + // legacy full modifier here repeats those same writers before the shared post. + LiveManager->ApplyPassageCarvingOnly(InOut.Density, X, Y, Z, Base, Seal); + } + // Keep the source and op-stack operation order identical: the origin room is carved + // before the boundary seal, then its support floor is reasserted after the passage + // post so no passage-air post can remove the standing surface. + VF_ApplyOriginLandingFloor( + InOut.Density, X, Y, Z, TopZ, BotZ, Seal, Base, SpineRadius); + } + + // ≡ la garde `AnyPassageNearBox` écrite à la main dans ClassifyTile — déjà écrite, ici + // simplement branchée au bon endroit au lieu d'être un cas particulier du classifieur. + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override + { + if (VoxelDensityAblation::IsPassageCarvingOff() + && VoxelDensityAblation::IsLandingPostsOff() + && VoxelDensityAblation::IsPassageStructuralPostsOff() + && VoxelDensityAblation::IsOriginSpineOff()) + { + return EVoxelOpEffect::Identity; + } + const UVoxelStrateManager* LiveManager = Manager.Get(); + if (!LiveManager) { return EVoxelOpEffect::Identity; } + const bool bOriginFloor = !VoxelDensityAblation::IsLandingPostsOff() + && (Ctx.bUseLatticeProof + ? VoxelPassageGeometry::OriginLandingFloorTouchesLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step, + TopZ, BotZ, Seal, SpineRadius) + : VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BotZ, Seal, SpineRadius)); + const bool bPassageFloor = !VoxelDensityAblation::IsLandingPostsOff() + && (Ctx.bUseLatticeProof + ? LiveManager->AnyPassageLandingFloorNearLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step) + : LiveManager->AnyPassageLandingFloorNearBox( + VoxelBox.Min, VoxelBox.Max)); + if (bOriginFloor || bPassageFloor) + { + // The room carves air, while its support slab force-writes solid. Both + // hypotheses must therefore be killed for a box touching that slab. + return EVoxelOpEffect::Both; + } + const bool bPassageCarve = !VoxelDensityAblation::IsPassageCarvingOff() + && (Ctx.bUseLatticeProof + ? LiveManager->AnyPassageNearLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step) + : LiveManager->AnyPassageNearBox(VoxelBox.Min, VoxelBox.Max)); + return bPassageCarve + ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; + } + + // The current-density Lerp/Min passage carve has no finite delta bound for an arbitrary + // incoming density. Once the preceding stack has proved a positive solid margin, + // however, the monotone carve can be bounded exactly on the MC lattice: evaluate the + // largest carve factor, apply it to the lower input bound, and keep the resulting positive + // margin. This preserves the old conservative fallback for non-lattice callers. + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + if (VoxelDensityAblation::IsPassageCarvingOff()) + { + return 0.0f; + } + const UVoxelStrateManager* LiveManager = Manager.Get(); + if (!LiveManager) { return 0.0f; } + if (!Ctx.bUseLatticeProof) + { + const EVoxelOpEffect Effect = EffectOverBox(VoxelBox, Ctx); + return (Effect == EVoxelOpEffect::CarveOnly || Effect == EVoxelOpEffect::Both) + ? FLT_MAX : 0.0f; + } + + const float MaxFactor = LiveManager->MaxPassageCarveFactorNearLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step); + if (!(MaxFactor > 0.0f)) { return 0.0f; } + if (!(H.SolidMargin > 0.0f) || !VoxelMath::IsFinite(H.SolidMargin) + || !VoxelMath::IsFinite(Base) || !VoxelMath::IsFinite(Seal)) + { + return FLT_MAX; + } + + const float AirTarget = -(Base * 2.0f + Seal + 4.0f); + if (!VoxelMath::IsFinite(AirTarget)) { return FLT_MAX; } + const float LowerAfterCarve = FMath::Min( + H.SolidMargin, + FMath::Lerp(H.SolidMargin, AirTarget, FMath::Clamp(MaxFactor, 0.0f, 1.0f))); + if (!VoxelMath::IsFinite(LowerAfterCarve)) { return FLT_MAX; } + return FMath::Max(0.0f, H.SolidMargin - LowerAfterCarve); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Both ? FLT_MAX : 0.0f; + } + + const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + TWeakObjectPtr Manager; + float Base, Seal, TopZ, BotZ, SpineRadius; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : PUITS VERTICAUX / VERTICAL SHAFTS + //========================================================================= + // Cylindres infinis sur une grille XY jitterée + un arbre de drainage déterministe, complété + // par des connecteurs horizontaux entre paires proches ouverts par un hash de paire. Écrit le + // canal SDF uniquement. + // + // ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §6`, qui suggérait DEUX sources (colonnes XY-pures + // + connecteurs) pour que la moitié cylindrique reçoive le traitement du cache de colonne et un + // `ClassifyBox` exact en XY. Gardé en UN opérateur, et voici pourquoi : + // • les connecteurs se dérivent de la MÊME liste inner 3×3 que les puits (il faut les paires), donc + // séparer imposerait soit de rouler les cellules deux fois, soit un cache partagé entre + // deux ops — c'est-à-dire la complexité qu'on voulait éviter ; + // • le `FShaftLedgeMod` en aval a de toute façon besoin de la liste des puits, donc il faut + // l'exposer depuis une source ; l'exposer depuis deux serait pire. + // Ce qui est perdu : le verdict de boîte exact sur la seule moitié cylindrique. Le compromis + // actuel est une propagation d'intervalle qui couvre cercles ET capsules, tandis que le + // `EffectOverBox` intrinsèque reste Identity : le convertisseur ou consommateur aval décide + // séparément de son propre effet. + // + // Kept as ONE op against §6's suggestion: the per-voxel connectors derive from the same inner + // 3×3 roll as the shafts, while a rebuild-only 9×9 collection resolves each 5×5 tree window + // and fixed ±3 fallback; + // the downstream ledge mod needs the inner shaft list anyway. What is forfeited is an exact XY + // box verdict on the cylinder half alone. + class FShaftFieldSource final : public IVoxelDensityOp + { + public: + FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InSpineRadius) + : P(InP), Salt((uint32)Seed ^ 0x53686674u) // 'Shft' + , SpineRadius(InSpineRadius) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + EVoxelOpResourceMask ProvidedResources() const override { return VoxelOpResources::ShaftGeometry; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + struct FShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; + struct FConn + { + FVector A, B; + float Radius = 0.0f; + float FloorZ = 0.0f; + bool bWalkableFloor = false; + }; + + // ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie. Un type imbriqué doit exister au + // moment où le COMPILATEUR lit la SIGNATURE — les corps de méthodes sont différés, pas les + // types de retour. La mettre en bas de la classe donne un C4430 « int par défaut » suivi + // d'une cascade illisible, ce qui masque une cause pourtant triviale. + // Declared here, before any function returning it: a nested type must exist when the + // compiler reads the SIGNATURE — bodies are deferred, return types are not. + struct FCells + { + TArray> Shafts; + TArray> Conns; + }; + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const FCells& C = GetCells(WorldX, WorldY); + + float CaveSDF = FLT_MAX; + for (const FShaft& Sh : C.Shafts) + { + if (Sh.bOriginSpine) continue; // VF_ApplyOriginSpine owns the structural column. + const float DX = WorldX - Sh.X; + const float DY = WorldY - Sh.Y; + CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R); + } + const FVector Pos(WorldX, WorldY, WorldZ); + for (const FConn& Cn : C.Conns) + { + CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, Cn.A, Cn.B, Cn.Radius)); + } + InOut.Sdf = CaveSDF; + } + + /** La liste des puits proches — `FShaftLedgeMod` doit trouver le PLUS PROCHE pour ne + * poser d'étagère que sur sa moitié +X/+Y. Même motif que colonne → overhang. */ + const FCells& GetCellsAt(float WorldX, float WorldY) const { return GetCells(WorldX, WorldY); } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + // This source writes SDF only. A converter or other SDF consumer must inspect the + // propagated interval; the source has no density effect in isolation. + return EVoxelOpEffect::Identity; + } + + /** + * Propagate the source's own SDF interval. Eval selects the inner 3x3 cell neighbourhood, + * so enumerating every cell touched by the box and asking GetCells at that cell centre is an + * exact superset. Shaft axes are infinite in Z; connector capsules use their segment AABB + * as a conservative distance lower bound. + */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + if (!VoxelMath::IsFinite((float)VoxelBox.Min.X) || !VoxelMath::IsFinite((float)VoxelBox.Min.Y) + || !VoxelMath::IsFinite((float)VoxelBox.Min.Z) + || !VoxelMath::IsFinite((float)VoxelBox.Max.X) || !VoxelMath::IsFinite((float)VoxelBox.Max.Y) + || !VoxelMath::IsFinite((float)VoxelBox.Max.Z) + || VoxelBox.Min.X > VoxelBox.Max.X || VoxelBox.Min.Y > VoxelBox.Max.Y + || VoxelBox.Min.Z > VoxelBox.Max.Z + || !VoxelMath::IsFinite(P.ShaftSpacing) || P.ShaftSpacing <= 0.0f) + { + InOut.SetUnknown(); + return; + } + + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing); + const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing); + const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing); + const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing); + constexpr int64 MaxCellsScanned = 64 * 64; + const int64 CellCount = ((int64)CX1 - CX0 + 1) * ((int64)CY1 - CY0 + 1); + if (CellCount <= 0 || CellCount > MaxCellsScanned) + { + InOut.SetUnknown(); + return; + } + + float Lower = FLT_MAX; + bool bAnyPrimitive = false; + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + const FCells& C = GetCells((cx + 0.5f) * Spacing, (cy + 0.5f) * Spacing); + for (const FShaft& Sh : C.Shafts) + { + if (Sh.bOriginSpine) { continue; } + bAnyPrimitive = true; + Lower = FMath::Min(Lower, + VF_DistanceBoxToPointXY(VoxelBox, Sh.X, Sh.Y) - FMath::Abs(Sh.R)); + } + for (const FConn& Conn : C.Conns) + { + bAnyPrimitive = true; + const FVector ConnMin( + FMath::Min(Conn.A.X, Conn.B.X), FMath::Min(Conn.A.Y, Conn.B.Y), + FMath::Min(Conn.A.Z, Conn.B.Z)); + const FVector ConnMax( + FMath::Max(Conn.A.X, Conn.B.X), FMath::Max(Conn.A.Y, Conn.B.Y), + FMath::Max(Conn.A.Z, Conn.B.Z)); + Lower = FMath::Min(Lower, + VF_DistanceBetweenBoxes(VoxelBox.Min, VoxelBox.Max, ConnMin, ConnMax) + - FMath::Abs(Conn.Radius)); + } + } + + if (bAnyPrimitive) { InOut.Set(Lower, FLT_MAX); } + else { InOut.Set(FLT_MAX, FLT_MAX); } + } + + private: + /** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne + * peuvent pas voir des puits différents. */ + bool RollShaft(int32 nx, int32 ny, FShaft& Out) const + { + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const uint32 Hh = VoxelHash::Cell(nx, ny, Salt); + if (VoxelHash::ToFloat01(Hh) > P.ShaftDensity) { return false; } + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + Out.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); + Out.CellX = nx; + Out.CellY = ny; + Out.bOriginSpine = false; + return true; + } + + /** Spine links use a radius above the repository's proven sup|FBM|=1.5 roughness + * envelope, so the roughness pass cannot pinch their centreline shut. */ + float SpineConnectorRadius() const + { + constexpr float RoughnessAbsBound = 1.5f; + const float RoughnessReach = FMath::Max(P.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE * RoughnessAbsBound; + return FMath::Max(P.ConnectorRadius, RoughnessReach + 1.0f); + } + + /** Le voisinage inner 3×3 + ses connecteurs, mémoïsés par worker. Le rebuild collecte un + * halo 9×9 émetteur / 15×15 total pour résoudre les fenêtres parent 5×5 et le fallback + * fixe ±3. Clé = cellule + tous les params qui influent : stable à travers les + * reconstructions de pile, ce qui est la leçon retenue du mémo de colonne de SurfaceWorld. */ + const FCells& GetCells(float WorldX, float WorldY) const + { + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const int32 CX = FMath::FloorToInt(WorldX / Spacing); + const int32 CY = FMath::FloorToInt(WorldY / Spacing); + + thread_local FCells Cache; + thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX; + thread_local uint32 VS_Salt = 0xFFFFFFFFu; + thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, + VS_MaxR = -1.0f, VS_Cross = -1.0f, VS_ConnR = -1.0f, + VS_Rough = -1.0f, VS_SpineR = -1.0f, + VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX, VS_Seal = -1.0f, + VS_LedgeSpacing = -1.0f, VS_LedgeDepth = -1.0f; + + if (CX != VS_CX || CY != VS_CY || Salt != VS_Salt || Spacing != VS_Spacing || + P.ShaftDensity != VS_Dens || P.ShaftMinRadius != VS_MinR || P.ShaftMaxRadius != VS_MaxR || + P.CrossConnectChance != VS_Cross || P.ConnectorRadius != VS_ConnR || + P.SurfaceRoughness != VS_Rough || SpineRadius != VS_SpineR || + P.StrateBottomWorldZ != VS_BotZ || P.StrateTopWorldZ != VS_TopZ || + P.BoundarySealThickness != VS_Seal || P.LedgeSpacing != VS_LedgeSpacing || + P.LedgeDepth != VS_LedgeDepth) + { + VS_CX = CX; VS_CY = CY; VS_Salt = Salt; VS_Spacing = Spacing; + VS_Dens = P.ShaftDensity; VS_MinR = P.ShaftMinRadius; VS_MaxR = P.ShaftMaxRadius; + VS_Cross = P.CrossConnectChance; VS_ConnR = P.ConnectorRadius; + VS_Rough = P.SurfaceRoughness; VS_SpineR = SpineRadius; + VS_BotZ = P.StrateBottomWorldZ; VS_TopZ = P.StrateTopWorldZ; + VS_Seal = P.BoundarySealThickness; + VS_LedgeSpacing = P.LedgeSpacing; VS_LedgeDepth = P.LedgeDepth; + Cache.Shafts.Reset(); + Cache.Conns.Reset(); + + constexpr int32 EmitRadius = 1; // 3×3: shaft cylinders and random links + constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft + constexpr int32 FallbackRadius = 3; // fixed fallback window around an emitting shaft + constexpr uint32 TreeSalt = 0x7A11u; + + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; + const float TreeRadius = SpineConnectorRadius(); + // A tree capsule can affect a cell even when neither endpoint is in that cell. + // Keep a rebuild-only child halo for that geometric reach, then add the + // parent-search halo. With the defaults this is 4 + 3 cells = 15×15 rolls. + const int32 ConnectorCellPad = FMath::Max( + 1, FMath::CeilToInt(TreeRadius / Spacing)); + const int32 TreeEmitRadius = FallbackRadius + ConnectorCellPad; + const int32 CollectRadius = TreeEmitRadius + FallbackRadius; + + const int32 CollectSide = CollectRadius * 2 + 1; + TArray> ShaftGrid; + TArray> ShaftPresent; + TArray> TreeEmitShafts; + ShaftGrid.SetNum(CollectSide * CollectSide); + ShaftPresent.Init(0, CollectSide * CollectSide); + // Rebuild-only wide collection. `Cache.Shafts` remains the inner 3×3 working set + // used by Eval and the ledge modifier; the wider tree work is not paid per voxel. + for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++) + for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++) + { + FShaft Sh; + if (!RollShaft(CX + dx, CY + dy, Sh)) { continue; } + const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius); + ShaftGrid[GridIndex] = Sh; + ShaftPresent[GridIndex] = 1; + if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius) + { + Cache.Shafts.Add(Sh); + } + if (FMath::Abs(dx) <= TreeEmitRadius && FMath::Abs(dy) <= TreeEmitRadius) + { + TreeEmitShafts.Add(Sh); + } + } + + auto FindCollectedShaft = [&](int32 CellX, int32 CellY) -> const FShaft* + { + const int32 dx = CellX - CX; + const int32 dy = CellY - CY; + if (FMath::Abs(dx) > CollectRadius || FMath::Abs(dy) > CollectRadius) + { + return nullptr; + } + const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius); + return ShaftPresent[GridIndex] ? &ShaftGrid[GridIndex] : nullptr; + }; + + const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1; + if (bOriginNearby && SpineRadius > 0.0f) + { + Cache.Shafts.Add({0.0f, 0.0f, SpineRadius, 0, 0, true}); + } + + const float CellMinX = static_cast(CX) * Spacing; + const float CellMaxX = static_cast(CX + 1) * Spacing; + const float CellMinY = static_cast(CY) * Spacing; + const float CellMaxY = static_cast(CY + 1) * Spacing; + auto ConnectorMayReachCell = [&](const FConn& Conn) + { + const float MinX = FMath::Min(Conn.A.X, Conn.B.X) - Conn.Radius; + const float MaxX = FMath::Max(Conn.A.X, Conn.B.X) + Conn.Radius; + const float MinY = FMath::Min(Conn.A.Y, Conn.B.Y) - Conn.Radius; + const float MaxY = FMath::Max(Conn.A.Y, Conn.B.Y) + Conn.Radius; + const float GapX = FMath::Max3(CellMinX - MaxX, MinX - CellMaxX, 0.0f); + const float GapY = FMath::Max3(CellMinY - MaxY, MinY - CellMaxY, 0.0f); + return GapX == 0.0f && GapY == 0.0f; + }; + + auto EmitTreeConnector = [&](const FShaft& Child, const FShaft* Parent) + { + const float Zc = VoxelPassageGeometry::VerticalShaftConnectorCenterZ( + P.StrateBottomWorldZ, + BottomZ, + TopZ, + P.LedgeSpacing, + P.LedgeDepth, + TreeRadius); + const float FloorZ = VoxelPassageGeometry::VerticalShaftConnectorFloorZ( + P.StrateBottomWorldZ, + BottomZ, + TopZ, + P.LedgeSpacing, + P.LedgeDepth, + TreeRadius); + const FVector ParentPoint = Parent != nullptr + ? FVector(Parent->X, Parent->Y, Zc) + : FVector(0.0f, 0.0f, Zc); + const FConn Conn{ + FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius, + FloorZ, true }; + if (ConnectorMayReachCell(Conn)) + { + Cache.Conns.Add(Conn); + } + }; + + // Structural drainage tree. Every real shaft in the rebuild-only child window + // first chooses the nearest strictly more-central shaft in its complete 5×5 + // window. A local minimum falls back to the nearest lower-origin shaft in the + // deterministic halo; a window touching the origin uses the spine directly, with + // a final direct-spine fallback for an unusually empty finite halo. Every parent + // edge therefore either strictly decreases origin distance or terminates at the + // spine: no cycles and no orphans. Spatial culling makes the same edge visible + // throughout its capsule, independent of which cache cell evaluates it. + for (const FShaft& Child : TreeEmitShafts) + { + if (Child.bOriginSpine) continue; + + const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); + const FShaft* Parent = nullptr; + float BestDistanceSq = FLT_MAX; + for (int32 dy = -CandidateRadius; dy <= CandidateRadius; ++dy) + for (int32 dx = -CandidateRadius; dx <= CandidateRadius; ++dx) + { + const FShaft* Candidate = FindCollectedShaft( + Child.CellX + dx, Child.CellY + dy); + if (Candidate == nullptr + || (Candidate->CellX == Child.CellX && Candidate->CellY == Child.CellY)) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate->X) + + FMath::Square(Candidate->Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate->X) + + FMath::Square(Child.Y - Candidate->Y); + const bool bLowerCell = Parent == nullptr + || Candidate->CellY < Parent->CellY + || (Candidate->CellY == Parent->CellY + && Candidate->CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = Candidate; + } + } + + if (Parent == nullptr + && !(FMath::Abs(Child.CellX) <= CandidateRadius + && FMath::Abs(Child.CellY) <= CandidateRadius)) + { + // Specified neighbour-shaft fallback. The lower-origin restriction is the + // proof that this fallback cannot create a cycle. + for (int32 dy = -FallbackRadius; dy <= FallbackRadius; ++dy) + for (int32 dx = -FallbackRadius; dx <= FallbackRadius; ++dx) + { + const FShaft* Candidate = FindCollectedShaft( + Child.CellX + dx, Child.CellY + dy); + if (Candidate == nullptr + || (Candidate->CellX == Child.CellX + && Candidate->CellY == Child.CellY)) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate->X) + + FMath::Square(Candidate->Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate->X) + + FMath::Square(Child.Y - Candidate->Y); + const bool bLowerCell = Parent == nullptr + || Candidate->CellY < Parent->CellY + || (Candidate->CellY == Parent->CellY + && Candidate->CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = Candidate; + } + } + } + + EmitTreeConnector(Child, Parent); + } + + // Existing random links remain the texture/loop layer and stay on the inner 3×3 + // set. Only the deterministic tree build uses the wider 9×9/15×15 collection. + if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2) + { + for (int32 i = 0; i < Cache.Shafts.Num(); i++) + for (int32 j = i + 1; j < Cache.Shafts.Num(); j++) + { + const FShaft& A = Cache.Shafts[i]; + const FShaft& B = Cache.Shafts[j]; + const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); + if (DSq > FMath::Square(Spacing * 1.6f)) { continue; } + + const uint32 PH = VoxelHash::Pair( + FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), + FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu); + const bool bSpineConnector = A.bOriginSpine || B.bOriginSpine; + if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) + { + continue; + } + + const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); + const float ConnectorR = bSpineConnector + ? SpineConnectorRadius() : P.ConnectorRadius; + const FConn Conn{ + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR, + 0.0f, false }; + if (ConnectorMayReachCell(Conn)) + { + Cache.Conns.Add(Conn); + } + } + } + } + return Cache; + } + + FVerticalShaftParams P; + uint32 Salt; + float SpineRadius; + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : ÉTAGÈRES DE PUITS / SHAFT LEDGES + //========================================================================= + // Des tablettes fines à intervalles réguliers en Z, posées UNIQUEMENT sur la moitié +X/+Y du + // puits le plus proche — pour que la moitié opposée reste libre et le puits franchissable. + // C'est un op FORÇANT (`Max`) : il ne peut qu'ajouter du solide. + class FShaftLedgeMod final : public IVoxelDensityOp + { + public: + FShaftLedgeMod(const FVerticalShaftParams& InP, const FShaftFieldSource* InField) + : P(InP), Field(InField) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::ShaftGeometry; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (P.LedgeSpacing <= 0.0f || P.LedgeDepth <= 0.0f || Field == nullptr) { return; } + // ⚠️ La garde est `Sdf < 0` — le SDF APRÈS rugosité, tel que la pile l'a laissé. C'est + // bien `InOut.Sdf` ici, pas une re-évaluation : re-calculer donnerait le SDF SANS + // rugosité et déplacerait les étagères. + if (InOut.Sdf >= 0.0f) { return; } + + const float Phase = FMath::Frac((WorldZ - P.StrateBottomWorldZ) / P.LedgeSpacing); + const float BandT = FMath::Min(Phase, 1.0f - Phase) * P.LedgeSpacing; + if (BandT >= P.LedgeDepth) { return; } + + const FShaftFieldSource::FCells& C = Field->GetCellsAt(WorldX, WorldY); + if (C.Shafts.Num() == 0) { return; } + + const FShaftFieldSource::FShaft* Near = nullptr; + float BestSq = FLT_MAX; + for (const FShaftFieldSource::FShaft& Sh : C.Shafts) + { + if (Sh.bOriginSpine) continue; // ledges belong to real shafts, not the post. + const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); + if (D2 < BestSq) { BestSq = D2; Near = &Sh; } + } + // The shaft axis is the structural tree's terminal point. Keep the mathematical + // half-plane boundary open: tiny cancellation error must not turn an exact axis + // landing into a solid ledge cap and sever the parent capsule. + if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 1.0e-3f) + { + const float Shelf = 1.0f - SmoothStep01(BandT / P.LedgeDepth); + InOut.Density = FMath::Max(InOut.Density, Shelf * P.BaseDensity); + } + } + + // N'ajoute que du solide ⇒ tue AllAir, jamais AllSolid. + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (Field != nullptr && P.LedgeSpacing > 0.0f && P.LedgeDepth > 0.0f) + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + if (Field == nullptr || P.LedgeSpacing <= 0.0f || P.LedgeDepth <= 0.0f) + { + return EVoxelOpEffect::Identity; + } + return H.Sdf.IsKnown() && H.Sdf.Min >= 0.0f + ? EVoxelOpEffect::Identity : EVoxelOpEffect::FillOnly; + } + + private: + FVerticalShaftParams P; + const FShaftFieldSource* Field; // NON possédant : la pile possède la source + }; + + // The deterministic shaft tree is a walkable network as well as an air topology. Reassert + // its air core after partial shaft ledges, then add the same inset floor band as the native + // path at the common first ledge level. This lets a player-fit route cross between shafts + // without restoring the removed landing-to-origin roads. + class FShaftConnectorAirMod final : public IVoxelDensityOp + { + public: + explicit FShaftConnectorAirMod(const FShaftFieldSource* InField, float InBaseDensity) + : Field(InField), BaseDensity(InBaseDensity) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::ShaftGeometry; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, + FVoxelOpSample& InOut) const override + { + if (Field == nullptr) return; + const FShaftFieldSource::FCells& Cells = Field->GetCellsAt(WorldX, WorldY); + for (const FShaftFieldSource::FConn& Conn : Cells.Conns) + { + if (!Conn.bWalkableFloor) continue; + VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorAir( + InOut.Density, WorldX, WorldY, WorldZ, + Conn.A, Conn.B, Conn.Radius, Conn.FloorZ, BaseDensity); + } + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return Field != nullptr ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("ShaftConnectorAirOp"); } + + private: + const FShaftFieldSource* Field = nullptr; // NON possédant : la pile possède la source + float BaseDensity = 8.0f; + }; + + class FShaftConnectorFloorMod final : public IVoxelDensityOp + { + public: + FShaftConnectorFloorMod(const FShaftFieldSource* InField, float InBaseDensity) + : Field(InField), BaseDensity(InBaseDensity) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::ShaftGeometry; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, + FVoxelOpSample& InOut) const override + { + if (Field == nullptr) return; + const FShaftFieldSource::FCells& Cells = Field->GetCellsAt(WorldX, WorldY); + for (const FShaftFieldSource::FConn& Conn : Cells.Conns) + { + if (!Conn.bWalkableFloor) continue; + VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorFloor( + InOut.Density, WorldX, WorldY, WorldZ, + Conn.A, Conn.B, Conn.Radius, Conn.FloorZ, BaseDensity); + } + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return Field != nullptr ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("ShaftConnectorFloorOp"); } + + private: + const FShaftFieldSource* Field = nullptr; // NON possédant : la pile possède la source + float BaseDensity = 8.0f; + }; + + //========================================================================= + // RÔLE 1 — SOURCE : ÎLES FLOTTANTES / FLOATING ISLAND BLOBS + //========================================================================= + // Le SEUL archétype dont la source est de l'AIR : `FConstantFieldSource(-BaseDensity)` pose un + // grand vide, et cet opérateur y suspend des blobs. C'est ce qui en fait le bon test de + // composition — tous les autres portages partent de roc et creusent. + // + // FORME D'UNE ÎLE : une dalle assez plate au-dessus du centre (`TopHalf = 0.20·Rxy`) et un + // dessous qui s'effile vers une pointe (`ThicknessRatio·Rxy`). C'est l'asymétrie qui se lit + // comme une île flottante plutôt que comme une sphère. + // + // ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §7`, qui décrivait un `FRAME IslandWarp` enveloppant + // la source. Le warp reste À L'INTÉRIEUR de l'opérateur, et c'est délibéré : `§7` compte trois + // usages de frames (îles, caves de TunnelNetwork, tunnels), et aucun n'en a besoin. Voir + // `BuildTunnelNetworkStack` : `CaveWarp` n'enveloppe qu'UN opérateur (donc c'est une variable + // locale, pas un frame) et `VerticalScale` est une fonction pure d'un scalaire. **Zéro frame + // sur trois candidats.** Ne pas rouvrir : le warp reste local ICI pour la même raison qu'il + // est resté local là-bas. On n'abstrait qu'à la deuxième occurrence réelle (cf. + // `IVoxelBiomeField`, né d'un besoin réel). + // + // The warp stays INSIDE the op against §7's FRAME suggestion. TunnelNetwork is ported, and the + // second real use dissolved the question rather than settling it. See BuildTunnelNetworkStack: + // CaveWarp wraps exactly ONE operator (a local variable, not a frame) and VerticalScale is a + // pure function of a scalar. ZERO frames out of three candidates. Do not reopen. + class FIslandBlobSource final : public IVoxelDensityOp + { + public: + FIslandBlobSource(const FFloatingIslandParams& InP, int32 Seed) + : P(InP), Salt((uint32)Seed ^ 0x49736C64u) // 'Isld' + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + struct FIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; }; + + // ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie — même piège que `FShaftFieldSource` + // (C4430 : les corps de méthodes sont différés, les types de retour non). + struct FCells { TArray> Islands; }; + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); + const FCells& C = GetCells(WorldX, WorldY); + + // CONTOUR IRRÉGULIER : on déforme la requête HORIZONTALE pour que les bords des îles + // soient lobés au lieu d'être des cercles parfaits. Calculé une fois par voxel et + // partagé par toutes les îles proches — chacune échantillonne une autre partie du champ, + // d'où des silhouettes distinctes. + const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f; + const float WX = WorldX + HFractal3D(FVector3f(WorldX * 0.04f + VoxelHash::SeedOffset(Salt, 0.0007f), + WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + * VOXEL_NOISE_SCALE * WarpAmp; + const float WY = WorldY + HFractal3D(FVector3f(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, + WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + * VOXEL_NOISE_SCALE * WarpAmp; + + float IslandSDF = FLT_MAX; + for (const FIsland& Isl : C.Islands) + { + // Distance horizontale dans le repère DÉFORMÉ, donc le contour n'est pas un cercle. + const float Dxw = WX - Isl.X, Dyw = WY - Isl.Y; + const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw); + + // Enveloppe de rayon par la hauteur : pleine largeur en haut, resserrée jusqu'à une + // pointe en bas (taper SmoothStep). + const float Hgt = FMath::Clamp((WorldZ - Isl.BotZ) / FMath::Max(Isl.TopZ - Isl.BotZ, 1.0f), + 0.0f, 1.0f); + const float Taper = SmoothStep01(FMath::Clamp(Hgt / Isl.TaperEnd, 0.0f, 1.0f)); + const float Env = Isl.Rxy * Taper; + + // Surface du dessus : plate par défaut ; les bords retombent en dôme si TopFlatten < 1. + float TopSurf = Isl.TopZ; + if (P.TopFlatten < 1.0f) + { + const float Edge = FMath::Clamp(DistXY / FMath::Max(Isl.Rxy, 1.0f), 0.0f, 1.0f); + TopSurf = Isl.TopZ - (1.0f - P.TopFlatten) * Isl.TopHalf * 2.0f * Edge * Edge; + } + + // Pseudo-SDF : dehors si au-delà de l'enveloppe radiale OU au-dessus du dessus. + const float Sdf = FMath::Max(DistXY - Env, WorldZ - TopSurf); + + IslandSDF = VoxelSDF::SmoothMin(IslandSDF, Sdf, BlendK); + } + + InOut.Sdf = IslandSDF; + } + + /** + * Cette source écrit uniquement le canal SDF : son `EffectOverBox` intrinsèque est donc + * `Identity`. Le convertisseur de remplissage consomme ensuite l'intervalle publié ; sur une + * strate d'îles, l'absence d'île prouvée est le cas COURANT et, combinée à l'`AllAir` de la + * source constante, permet enfin de prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). + * + * BORNE, et pourquoi elle est sûre dans les deux directions : + * • en XY, `Sdf ≥ DistXY − Rxy` (l'enveloppe ne dépasse jamais `Rxy`), et le warp déplace + * le POINT de `WarpAmp · VOXEL_NOISE_SCALE · √2` au plus (la borne prouvée de chaque + * FBM est 1.5 sur DEUX axes indépendants — voir la note √2 dans le corps) ; + * • en Z, `Sdf ≥ WorldZ − TopSurf ≥ WorldZ − TopZ`, donc au-dessus du sommet + marge il + * n'y a plus rien à faire. **En dessous, il n'y a PAS de borne** : sous une île, le SDF + * vaut ≈ `DistXY` à toute profondeur, donc un mince fil de matière descend le long de + * l'axe. Le confondre avec « rien en dessous » serait un TROU, et c'est pourquoi seule + * la borne HAUTE est testée. + * • cette source ne couvre pas l'aval : elle publie seulement son propre intervalle SDF. + * La rugosité, le blend du fill et le creux du SmoothMin sont bornés par leurs propres + * opérateurs dans le pliage, chacun avec son contrat isolé. + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + // This source writes SDF only. Island filling is the converter's responsibility. + return EVoxelOpEffect::Identity; + } + + /** + * Propagate a source-only lower bound. The exact 3x3 neighbourhood used by Eval is + * enumerated for every XY cell touched by the box. The warp, authored radius endpoints, + * top envelope, and the bounded SmoothMin dip are all included in the bound. + */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const override + { + if (!VoxelMath::IsFinite((float)VoxelBox.Min.X) || !VoxelMath::IsFinite((float)VoxelBox.Min.Y) + || !VoxelMath::IsFinite((float)VoxelBox.Min.Z) + || !VoxelMath::IsFinite((float)VoxelBox.Max.X) || !VoxelMath::IsFinite((float)VoxelBox.Max.Y) + || !VoxelMath::IsFinite((float)VoxelBox.Max.Z) + || VoxelBox.Min.X > VoxelBox.Max.X || VoxelBox.Min.Y > VoxelBox.Max.Y + || VoxelBox.Min.Z > VoxelBox.Max.Z + || !VoxelMath::IsFinite(P.IslandSpacing) || !VoxelMath::IsFinite(P.IslandDensity) + || !VoxelMath::IsFinite(P.IslandMinRadius) || !VoxelMath::IsFinite(P.IslandMaxRadius) + || !VoxelMath::IsFinite(P.ThicknessRatio) || !VoxelMath::IsFinite(P.VerticalJitter) + || !VoxelMath::IsFinite(P.TopFlatten) || !VoxelMath::IsFinite(P.SDFBlendRadius) + || P.IslandDensity < 0.0f) + { + InOut.SetUnknown(); + return; + } + + if (P.IslandDensity <= 0.0f) + { + InOut.Set(FLT_MAX, FLT_MAX); + return; + } + + const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); + const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing); + const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing); + const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing); + const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing); + constexpr int64 MaxCellsScanned = 64 * 64; + const int64 CellCount = ((int64)CX1 - CX0 + 1) * ((int64)CY1 - CY0 + 1); + if (CellCount <= 0 || CellCount > MaxCellsScanned) + { + InOut.SetUnknown(); + return; + } + + constexpr float Sqrt2 = 1.4142136f; + const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f; + const float WarpDiag = FMath::Abs(WarpAmp) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound * Sqrt2; + const float RadiusUpper = FMath::Max3(P.IslandMinRadius, P.IslandMaxRadius, 0.0f); + // For the authored TopFlatten range [0,1], TopSurf never exceeds TopZ. Keeping the + // positive general-range term makes the proof remain safe if a data asset is widened. + const float TopSurfExtra = FMath::Max(P.TopFlatten - 1.0f, 0.0f) + * RadiusUpper * 0.20f * 2.0f; + + float Lower = FLT_MAX; + bool bAnyIsland = false; + const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + const FCells& C = GetCells((cx + 0.5f) * Spacing, (cy + 0.5f) * Spacing); + for (const FIsland& Isl : C.Islands) + { + bAnyIsland = true; + const float RadialLower = VF_DistanceBoxToPointXY(VoxelBox, Isl.X, Isl.Y) + - WarpDiag + - FMath::Max(Isl.Rxy, 0.0f); + const float TopLower = (float)VoxelBox.Min.Z - Isl.TopZ - TopSurfExtra; + const float IslandLower = FMath::Max(RadialLower, TopLower); + Lower = FMath::Min(Lower, IslandLower); + } + } + + if (!bAnyIsland) + { + InOut.Set(FLT_MAX, FLT_MAX); + return; + } + + // SmoothMin can dip below the smallest raw term, but its total deficit is bounded by + // one blend radius: once the running minimum is a blend radius below another term, + // the next penalty is zero. This is a lower bound for any number of islands. + InOut.Set(VF_SaturatingAdd(Lower, -BlendK), FLT_MAX); + } + + private: + /** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne + * peuvent pas voir des îles différentes. */ + bool RollIsland(int32 nx, int32 ny, FIsland& Out) const + { + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); + const float MidZ = (P.StrateTopWorldZ + P.StrateBottomWorldZ) * 0.5f; + + const uint32 Hh = VoxelHash::Cell(nx, ny, Salt); + if (VoxelHash::ToFloat01(Hh) > P.IslandDensity) { return false; } + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + + Out.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au))); + + // PROFIL ASYMÉTRIQUE : dalle de terre au-dessus, dessous qui s'effile en pointe. + Out.TopHalf = Out.Rxy * 0.20f; + const float UnderDepth = Out.Rxy * FMath::Max(P.ThicknessRatio, 0.25f); + + const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(Out.TopHalf, UnderDepth) + - P.BoundarySealThickness, 0.0f) * P.VerticalJitter; + const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ; + Out.TopZ = Cz + Out.TopHalf; + Out.BotZ = Cz - UnderDepth; + + // Netteté du taper par île (point d'arrivée du SmoothStep) → silhouettes variées. + Out.TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu))); + return true; + } + + /** + * Le voisinage 3×3, mémoïsé par worker (`thread_local`). + * + * ⚠️ LA CLÉ INCLUT `BoundarySealThickness` : `SpreadZ` s'en sert + * (`H·0.5 − max(TopHalf, UnderDepth) − Seal`), donc sans lui une édition à chaud qui ne + * change QUE l'épaisseur de seal servirait des îles périmées. Même famille que `AUDIT §C2` : + * une clé de cache incomplète ne se voit pas, elle produit du terrain plausible. + * + * The key includes BoundarySealThickness because SpreadZ reads it; without it a live edit + * of the seal thickness alone would serve stale islands. + */ + const FCells& GetCells(float WorldX, float WorldY) const + { + const float Spacing = FMath::Max(P.IslandSpacing, 1.0f); + const int32 CX = FMath::FloorToInt(WorldX / Spacing); + const int32 CY = FMath::FloorToInt(WorldY / Spacing); + + thread_local FCells Cache; + thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX; + thread_local uint32 FI_Salt = 0xFFFFFFFFu; + thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f, + FI_Thick = -1.0f, FI_VJit = -1.0f, FI_Seal = -1.0f, + FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX; + + if (CX != FI_CX || CY != FI_CY || Salt != FI_Salt || Spacing != FI_Spacing || + P.IslandDensity != FI_Dens || P.IslandMinRadius != FI_MinR || + P.IslandMaxRadius != FI_MaxR || P.ThicknessRatio != FI_Thick || + P.VerticalJitter != FI_VJit || P.BoundarySealThickness != FI_Seal || + P.StrateBottomWorldZ != FI_BotZ || P.StrateTopWorldZ != FI_TopZ) + { + FI_CX = CX; FI_CY = CY; FI_Salt = Salt; FI_Spacing = Spacing; + FI_Dens = P.IslandDensity; FI_MinR = P.IslandMinRadius; FI_MaxR = P.IslandMaxRadius; + FI_Thick = P.ThicknessRatio; FI_VJit = P.VerticalJitter; + FI_Seal = P.BoundarySealThickness; + FI_BotZ = P.StrateBottomWorldZ; FI_TopZ = P.StrateTopWorldZ; + Cache.Islands.Reset(); + + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + FIsland Isl; + if (RollIsland(CX + dx, CY + dy, Isl)) { Cache.Islands.Add(Isl); } + } + } + return Cache; + } + + FFloatingIslandParams P; + uint32 Salt; + }; + + // The mesher's coarse samples are sparse enough that one sample can land in a different + // exact 32-voxel chunk every time. Keeping one SDF cache inside every exact-chunk op-stack + // therefore turns L4 into thousands of BuildChunkCache calls. These worker-local entries + // share the immutable room graph cache by its complete deterministic input key. A wider + // region is safe because BuildChunkCache's collect/store construction is window-invariant; + // the key still includes every value that can change the graph or its terrain-op rolls. + // A coarse tile traverses several deterministic XY search windows while LODs are generated + // concurrently. One mixed table made fine and coarse windows evict one another before the + // next tile could reuse them. Keep a bounded table per region-size class: this is a cache + // partition only, not a change to the graph key or to graph construction. + constexpr int32 RoomGraphCacheSlotCount = 128; + constexpr int32 RoomGraphCacheBankCount = 3; + + struct FRoomGraphCacheEntry + { + bool bValid = false; + uint32 Seed = 0; + int32 StrateIndex = INT32_MIN; + uint32 ParamsFingerprint = 0xFFFFFFFFu; + uint32 LayoutVersion = 0xFFFFFFFFu; + uint64 ManagerLifetimeId = 0; + const TArray* TerrainOps = nullptr; + int32 RegionMinX = 0; + int32 RegionMinY = 0; + int32 RegionSize = 0; + uint32 LastUse = 0; + FChunkSDFCache Cache; + }; + + static thread_local FRoomGraphCacheEntry GRoomGraphCache[ + RoomGraphCacheBankCount][RoomGraphCacheSlotCount]; + static thread_local uint32 GRoomGraphCacheClock = 0; + + FORCEINLINE int32 RoomGraphCacheBank(int32 RegionSize) + { + return RegionSize <= CHUNK_SIZE ? 0 + : (RegionSize <= 2 * CHUNK_SIZE ? 1 : 2); + } + + FORCEINLINE int32 RoomGraphCacheSlot( + uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, + uint64 ManagerLifetimeId, int32 RegionMinX, int32 RegionMinY, int32 RegionSize) + { + uint32 Hash = Seed * 0x9E3779B9u; + Hash ^= static_cast(StrateIndex) * 0x85EBCA6Bu; + Hash ^= ParamsFingerprint * 0xC2B2AE35u; + Hash ^= LayoutVersion * 0x27D4EB2Fu; + Hash ^= static_cast(ManagerLifetimeId) + ^ static_cast(ManagerLifetimeId >> 32); + Hash ^= static_cast(RegionMinX) * 0x165667B1u; + Hash ^= static_cast(RegionMinY) * 0xD3A2646Cu; + Hash ^= static_cast(RegionSize) * 0xFD7046C5u; + Hash ^= Hash >> 16; + return static_cast(Hash & (RoomGraphCacheSlotCount - 1)); + } + + FRoomGraphCacheEntry* FindRoomGraphCacheEntry( + uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, + uint64 ManagerLifetimeId, const TArray* TerrainOps, + int32 RegionMinX, int32 RegionMinY, int32 RegionSize) + { + auto SelectForReuse = [](FRoomGraphCacheEntry* Selected, uint32 Use) + -> FRoomGraphCacheEntry* + { + if (Selected != nullptr && Selected->bValid) + { + // Reset() deliberately preserves hot-path capacity. That is useful while the + // same window is reused, but not when this slot is about to become a different + // key: every worker could otherwise retain one full graph window per historical + // streaming key. Release before invalidating the key so a long-lived game has a + // bounded working set instead of a capacity leak. + Selected->Cache.Release(); + Selected->bValid = false; + } + if (Selected != nullptr) + { + Selected->LastUse = Use; + } + return Selected; + }; + + const int32 Bank = RoomGraphCacheBank(RegionSize); + const uint32 Use = ++GRoomGraphCacheClock; + const int32 StartSlot = RoomGraphCacheSlot( + Seed, StrateIndex, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, + RegionMinX, RegionMinY, RegionSize); + FRoomGraphCacheEntry* ReuseEntry = nullptr; + FRoomGraphCacheEntry* LeastRecentlyUsed = nullptr; + for (int32 Probe = 0; Probe < RoomGraphCacheSlotCount; ++Probe) + { + FRoomGraphCacheEntry& Candidate = GRoomGraphCache[Bank][ + (StartSlot + Probe) & (RoomGraphCacheSlotCount - 1)]; + if (!Candidate.bValid) + { + FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr ? ReuseEntry : &Candidate; + return SelectForReuse(Selected, Use); + } + if (Candidate.Seed == Seed + && Candidate.StrateIndex == StrateIndex + && Candidate.ParamsFingerprint == ParamsFingerprint + && Candidate.LayoutVersion == LayoutVersion + && Candidate.ManagerLifetimeId == ManagerLifetimeId + && Candidate.TerrainOps == TerrainOps + && Candidate.RegionMinX == RegionMinX + && Candidate.RegionMinY == RegionMinY + && Candidate.RegionSize == RegionSize) + { + Candidate.LastUse = Use; + return &Candidate; + } + if (ReuseEntry == nullptr && Candidate.ManagerLifetimeId != ManagerLifetimeId) + { + ReuseEntry = &Candidate; + } + if (LeastRecentlyUsed == nullptr || Candidate.LastUse < LeastRecentlyUsed->LastUse) + { + LeastRecentlyUsed = &Candidate; + } + } + FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr + ? ReuseEntry : LeastRecentlyUsed; + return SelectForReuse( + Selected != nullptr ? Selected : &GRoomGraphCache[Bank][StartSlot], Use); + } + + // Classification is dispatched across many workers before most of those workers have + // evaluated a density sample. A thread-local cache prevents races, but it also makes every + // worker rebuild the same deterministic collect/NN graph. Keep a small process-local set of + // immutable classifier caches: construction is serialized, publication happens only after + // BuildChunkCache has completed, and each consumer holds a shared reference while refining. + // This cache never stores mutable manager state; all manager/terrain data are read while the + // entry is built and the resulting FChunkSDFCache is read-only thereafter. + struct FSharedRoomGraphCacheEntry + { + uint32 Seed = 0; + int32 StrateIndex = INT32_MIN; + uint32 ParamsFingerprint = 0xFFFFFFFFu; + uint32 LayoutVersion = 0xFFFFFFFFu; + uint64 ManagerLifetimeId = 0; + const TArray* TerrainOps = nullptr; + int32 RegionMinX = 0; + int32 RegionMinY = 0; + int32 RegionSize = 0; + uint32 LastUse = 0; + bool bBuilding = false; + FEvent* ReadyEvent = nullptr; + TSharedPtr Cache; + uint64 AllocatedBytes = 0; + + ~FSharedRoomGraphCacheEntry() + { + if (ReadyEvent != nullptr) + { + FPlatformProcess::ReturnSynchEventToPool(ReadyEvent); + } + } + }; + + static FCriticalSection GSharedRoomGraphCacheMutex; + static TArray> + GSharedRoomGraphCache; + static uint32 GSharedRoomGraphCacheClock = 0; + static uint64 GSharedRoomGraphCacheResidentBytes = 0; + static FCriticalSection GSharedRoomGraphCacheBuildGateMutex; + static int32 GSharedRoomGraphCacheActiveBuilds[2] = {0, 0}; + // The byte budget is the actual memory guard. A 128-entry count is too small for the + // player's streaming radius: it turns otherwise reusable 128-voxel windows into transient + // rebuilds while only a few MiB are resident. Keep a generous bounded index so the budget, + // rather than an arbitrary key count, controls retention. + constexpr int32 SharedRoomGraphCacheMaxEntries = 4096; + // Limit the number of graph allocations that can exist before their size is + // known and published into the byte budget. This is the guard against a + // burst of distinct coarse tiles retaining one large build per worker. + // Fine LOD0 proof windows are small and latency-sensitive. Keep their build slots separate + // from coarse streaming windows: a pair of 250 ms LOD4 graph builds must not make a 32/64 + // voxel empty tile wait behind them. The fine slots are still bounded, and their graph + // allocations are tiny compared with the coarse builds. + constexpr int32 SharedRoomGraphCacheBuildBankCount = 2; + constexpr int32 SharedRoomGraphCacheBuildConcurrency[SharedRoomGraphCacheBuildBankCount] = {4, 2}; + // This is a proof-cache budget, not a geometry budget. A miss rebuilds an immutable cache; + // unknown/evicted proof data therefore falls back to sampling and cannot change the mesh. + constexpr uint64 SharedRoomGraphCacheBudgetBytes = 256ull * 1024ull * 1024ull; + + static TSharedPtr + SnapshotSharedRoomGraphCache( + const TSharedPtr& Entry) + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + return Entry.IsValid() + ? Entry->Cache + : TSharedPtr(); + } + + int32 SharedRoomGraphCacheBuildBank(int32 RegionSize) + { + return RegionSize <= 2 * CHUNK_SIZE ? 0 : 1; + } + + void AcquireSharedRoomGraphCacheBuildSlot(int32 RegionSize) + { + const int32 Bank = SharedRoomGraphCacheBuildBank(RegionSize); + for (;;) + { + { + FScopeLock Lock(&GSharedRoomGraphCacheBuildGateMutex); + if (GSharedRoomGraphCacheActiveBuilds[Bank] + < SharedRoomGraphCacheBuildConcurrency[Bank]) + { + ++GSharedRoomGraphCacheActiveBuilds[Bank]; + return; + } + } + FPlatformProcess::Yield(); + } + } + + void ReleaseSharedRoomGraphCacheBuildSlot(int32 RegionSize) + { + const int32 Bank = SharedRoomGraphCacheBuildBank(RegionSize); + FScopeLock Lock(&GSharedRoomGraphCacheBuildGateMutex); + check(GSharedRoomGraphCacheActiveBuilds[Bank] > 0); + --GSharedRoomGraphCacheActiveBuilds[Bank]; + } + + void EvictSharedRoomGraphCacheLocked(const FSharedRoomGraphCacheEntry* ProtectedEntry = nullptr) + { + while (GSharedRoomGraphCache.Num() > SharedRoomGraphCacheMaxEntries + || GSharedRoomGraphCacheResidentBytes > SharedRoomGraphCacheBudgetBytes) + { + int32 OldestIndex = INDEX_NONE; + for (int32 Index = 0; Index < GSharedRoomGraphCache.Num(); ++Index) + { + const TSharedPtr& Candidate = + GSharedRoomGraphCache[Index]; + if (Candidate->bBuilding || Candidate.Get() == ProtectedEntry) + { + continue; + } + if (OldestIndex == INDEX_NONE + || Candidate->LastUse < GSharedRoomGraphCache[OldestIndex]->LastUse) + { + OldestIndex = Index; + } + } + if (OldestIndex == INDEX_NONE) + { + // All remaining entries are in-flight or the protected oversized entry. Keep + // them alive until their consumers finish; the next completed publish can evict. + break; + } + + const uint64 Bytes = GSharedRoomGraphCache[OldestIndex]->AllocatedBytes; + GSharedRoomGraphCacheResidentBytes = Bytes >= GSharedRoomGraphCacheResidentBytes + ? 0 + : GSharedRoomGraphCacheResidentBytes - Bytes; + GSharedRoomGraphCache.RemoveAtSwap(OldestIndex, 1, EAllowShrinking::No); + } + } + + void RemoveStaleSharedRoomGraphCachesLocked(uint64 KeepManagerLifetimeId) + { + for (int32 Index = GSharedRoomGraphCache.Num() - 1; Index >= 0; --Index) + { + const TSharedPtr& Candidate = + GSharedRoomGraphCache[Index]; + if (Candidate->bBuilding + || (KeepManagerLifetimeId != 0 + && Candidate->ManagerLifetimeId == KeepManagerLifetimeId)) + { + continue; + } + + const uint64 Bytes = Candidate->AllocatedBytes; + GSharedRoomGraphCacheResidentBytes = Bytes >= GSharedRoomGraphCacheResidentBytes + ? 0 + : GSharedRoomGraphCacheResidentBytes - Bytes; + GSharedRoomGraphCache.RemoveAtSwap(Index, 1, EAllowShrinking::No); + } + EvictSharedRoomGraphCacheLocked(); + } + + TSharedPtr FindOrBuildSharedRoomGraphCache( + uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, + uint64 ManagerLifetimeId, const TArray* TerrainOps, + int32 RegionMinX, int32 RegionMinY, int32 RegionSize, + float SearchMinX, float SearchMinY, float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + ERoomGraphBuildSite BuildSite) + { + for (;;) + { + TSharedPtr Entry; + bool bBuild = false; + bool bBuildTransient = false; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + const uint32 Use = ++GSharedRoomGraphCacheClock; + for (const TSharedPtr& Candidate + : GSharedRoomGraphCache) + { + if (Candidate->Seed == Seed + && Candidate->StrateIndex == StrateIndex + && Candidate->ParamsFingerprint == ParamsFingerprint + && Candidate->LayoutVersion == LayoutVersion + && Candidate->ManagerLifetimeId == ManagerLifetimeId + && Candidate->TerrainOps == TerrainOps + && Candidate->RegionMinX == RegionMinX + && Candidate->RegionMinY == RegionMinY + && Candidate->RegionSize == RegionSize) + { + Candidate->LastUse = Use; + Entry = Candidate; + break; + } + } + + if (!Entry.IsValid()) + { + // Evict completed entries before admitting a new key. If + // the table is full of in-flight keys, do not append an + // unbounded waiting entry: build this request transiently + // under the build gate instead. The caller still gets the + // same immutable cache, but it is released with that + // classifier state rather than retained globally. + EvictSharedRoomGraphCacheLocked(); + if (GSharedRoomGraphCache.Num() >= SharedRoomGraphCacheMaxEntries) + { + bBuildTransient = true; + } + else + { + Entry = MakeShared(); + Entry->Seed = Seed; + Entry->StrateIndex = StrateIndex; + Entry->ParamsFingerprint = ParamsFingerprint; + Entry->LayoutVersion = LayoutVersion; + Entry->ManagerLifetimeId = ManagerLifetimeId; + Entry->TerrainOps = TerrainOps; + Entry->RegionMinX = RegionMinX; + Entry->RegionMinY = RegionMinY; + Entry->RegionSize = RegionSize; + Entry->LastUse = Use; + Entry->bBuilding = true; + Entry->ReadyEvent = FPlatformProcess::GetSynchEventFromPool(true); + GSharedRoomGraphCache.Add(Entry); + bBuild = true; + } + } + } + + if (bBuild || bBuildTransient) + { + const double CacheBuildCallStart = FPlatformTime::Seconds(); + AcquireSharedRoomGraphCacheBuildSlot(RegionSize); + const double CacheBuildSlotWait = FPlatformTime::Seconds() - CacheBuildCallStart; + TSharedPtr BuiltCache = + MakeShared(); + VoxelCaveMorphology::BuildChunkCache( + *BuiltCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, Seed, StrateIndex, TerrainOps, BuildSite); + const double CacheBuildSeconds = FPlatformTime::Seconds() - CacheBuildCallStart; + + const uint64 AllocatedBytes = static_cast(sizeof(FSharedRoomGraphCacheEntry)) + + static_cast(sizeof(FChunkSDFCache)) + + static_cast(BuiltCache->GetAllocatedSize()); + if (bBuildTransient) + { + Entry = MakeShared(); + Entry->Seed = Seed; + Entry->StrateIndex = StrateIndex; + Entry->ParamsFingerprint = ParamsFingerprint; + Entry->LayoutVersion = LayoutVersion; + Entry->ManagerLifetimeId = ManagerLifetimeId; + Entry->TerrainOps = TerrainOps; + Entry->RegionMinX = RegionMinX; + Entry->RegionMinY = RegionMinY; + Entry->RegionSize = RegionSize; + Entry->LastUse = 0; + Entry->AllocatedBytes = AllocatedBytes; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + Entry->Cache = BuiltCache; + } + } + else + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + Entry->Cache = BuiltCache; + Entry->AllocatedBytes = AllocatedBytes; + Entry->bBuilding = false; + Entry->ReadyEvent->Trigger(); + if (AllocatedBytes <= SharedRoomGraphCacheBudgetBytes) + { + GSharedRoomGraphCacheResidentBytes += Entry->AllocatedBytes; + EvictSharedRoomGraphCacheLocked(Entry.Get()); + } + else + { + // Publish to any waiters through the shared entry, but do not retain an + // oversized graph in the process-wide table. The caller's shared pointer + // keeps this one immutable build alive only for the current work item. + for (int32 Index = 0; Index < GSharedRoomGraphCache.Num(); ++Index) + { + if (GSharedRoomGraphCache[Index].Get() == Entry.Get()) + { + GSharedRoomGraphCache.RemoveAtSwap( + Index, 1, EAllowShrinking::No); + break; + } + } + EvictSharedRoomGraphCacheLocked(); + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeSharedCacheMemory] oversized_entry_bytes=%llu " + "budget_bytes=%llu retained=0"), + static_cast(AllocatedBytes), + static_cast(SharedRoomGraphCacheBudgetBytes)); + } + } + ReleaseSharedRoomGraphCacheBuildSlot(RegionSize); + if (!bBuildTransient) + { + VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + } + if (RegionSize <= 2 * CHUNK_SIZE + || CacheBuildSeconds >= 0.25 || CacheBuildSlotWait >= 0.25) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeSharedCacheTiming] action=build transient=%d " + "region=(%d,%d,%d) slot_wait=%.6f build=%.6f bytes=%llu"), + bBuildTransient ? 1 : 0, RegionMinX, RegionMinY, RegionSize, + CacheBuildSlotWait, CacheBuildSeconds, + static_cast(AllocatedBytes)); + } + return Entry; + } + + if (SnapshotSharedRoomGraphCache(Entry).IsValid()) + { + return Entry; + } + + // A different worker is building this exact immutable key. Waiting on its event + // avoids duplicate graph work without serializing unrelated region keys. + const double CacheWaitStart = FPlatformTime::Seconds(); + Entry->ReadyEvent->Wait(); + const double CacheWaitSeconds = FPlatformTime::Seconds() - CacheWaitStart; + if (Entry->RegionSize <= 2 * CHUNK_SIZE || CacheWaitSeconds >= 0.25) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeSharedCacheTiming] action=wait region=(%d,%d,%d) wait=%.6f"), + Entry->RegionMinX, Entry->RegionMinY, Entry->RegionSize, CacheWaitSeconds); + } + } + } + + //========================================================================= + // RÔLE 1 — SOURCE : GRAPHE DE SALLES / ROOM GRAPH (TunnelNetwork) + //========================================================================= + // ⚠️⚠️ CET OPÉRATEUR N'A PAS RÉÉCRIT `BuildChunkCache` / `EvaluateSDFCached` : IL LES APPELLE. + // + // C'est LA décision de ce portage, et elle mérite d'être dite explicitement parce que la + // tentation inverse est forte : les six autres portages sont des transcriptions littérales. + // Celui-ci ne peut pas l'être. `BuildChunkCache` porte la discipline d'invariance de fenêtre à + // deux régions (ARCHITECTURE §8.4) — la région COLLECT (plus large, décide QUELLES primitives + // existent) et la région STORE (ce qu'on garde) — et c'est le code le plus délicat du plugin. + // Le transcrire, ce serait le FORKER : deux copies d'un invariant qui dérivent, dont l'une n'est + // testée que par un test d'équivalence qui compare... la copie à l'original. + // + // Ce qui EST transcrit ici, c'est la glu autour : la mémo d'index de strate, la clé de cache par + // BOÎTE DE RECHERCHE (pas par chunk — voir plus bas), le warp, et les boucles pits/cheminées. + // ~60 lignes déjà relues, contre ~400 lignes d'algorithme qu'on ne touche pas. + // + // This op CALLS the morphology cache rather than transcribing it: BuildChunkCache carries the + // two-region window-invariance discipline (§8.4) and forking it would be the worst possible + // outcome of a refactor whose whole point is to have ONE definition of each idea. + class FRoomGraphSource final : public IVoxelDensityOp + { + public: + FRoomGraphSource(const FStrateGenerationParams& InP, int32 InSeed, + const UVoxelStrateManager* InManager) + : P(InP), Seed(InSeed), SeedU((uint32)InSeed), Manager(InManager) + , ManagerLifetimeId(InManager ? InManager->GetCacheLifetimeId() : 0) + , ParamsFingerprint(FCrc::MemCrc32(&InP, sizeof(InP))) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Sdf; } + EVoxelOpResourceMask ProvidedResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return false; } + + void PrepareChunk(const FVoxelOpContext& Ctx) override + { + // La seule chose vraiment constante par chunk ET dépendante du contexte. Le reste + // (index de strate, pool d'ops) est résolu paresseusement dans `Eval` comme l'original, + // parce que le cache SDF se ré-clé sur une BOÎTE, pas sur un chunk. + LayoutVersion = Ctx.LayoutVersion; + } + + //--------------------------------------------------------------------- + // L'ÉTAT PAR WORKER, ET POURQUOI IL EST SORTI DE `Eval` + //--------------------------------------------------------------------- + // Les modificateurs de détail de l'étape B ont besoin de ce que CETTE source a produit pour + // CE voxel : le cache SDF (les terrasses le ré-interrogent en Z±1, les colonnes le + // parcourent) et l'index de la salle la plus proche (arches, dômes, pincement, biais de sol). + // Dans l'original tout cela vit dans des `thread_local` d'une seule fonction de 1080 lignes ; + // ici la source les possède et les expose. + // + // The state belongs to this source instance. The outer tunnel cache retains the immutable + // op stack, so retaining the source's prepared SDF window with that stack avoids rebuilding + // the room graph every time a coarse tile switches between cached chunk keys. It remains + // worker-local because each op stack lives in the thread-local density cache. + struct FState + { + // Fallback only for the invalid-owner path. Normal evaluations point at the worker-local + // shared cache bank below, so switching exact chunk op-stacks does not rebuild the room + // graph merely because the stack object changed. + FChunkSDFCache Cache; + const FChunkSDFCache* ActiveCache = nullptr; + // Coarse density samples use the process-local immutable graph cache. Keep the entry + // alive for the whole sequence of detail operators that consume ActiveCache. + TSharedPtr + SharedCacheEntry; + + /** La salle de SDF minimal pour le dernier voxel évalué. -1 = aucune. */ + int32 NearestRoom = -1; + + /** The warped SDF-space position belonging to the current voxel. */ + FVector LastWarpedPosition = FVector::ZeroVector; + + /** The unwarped position belonging to the current voxel. */ + FVector LastWorldPosition = FVector::ZeroVector; + + // The common generator tail runs after disturbances. The tunnel structural posts + // already evaluate this immutable result inside the stack; retain the exact same + // sample's answer so the tail does not scan the tunnel list a second time. + FTunnelCoreWorldEvaluation LastTunnelCoreWorldEvaluation; + FVector LastTunnelCoreEvaluationPosition = FVector::ZeroVector; + bool bLastTunnelCoreWorldEvaluationValid = false; + bool bLastRoomOwnsBottom = false; + bool bLastRoomOwnsBottomValid = false; + + /** ÉTAPE C1 — les params de la strate avec l'op de CETTE salle appliqué par-dessus. + * Mémo par voxel : invalidé au début de chaque `Eval`, calculé au PREMIER modificateur + * qui le demande. C'est ce qui reproduit le coût de l'original (une copie de struct par + * voxel PRÈS D'UNE SURFACE, pas partout) sans que onze opérateurs la refassent chacun. */ + FStrateGenerationParams LocalParams; + bool bLocalParamsValid = false; + + // Window invariance is a cache-policy proof, not a sample evaluator. Keep its result + // beside the worker-local source state so LOD0 does not pay the full reach walk once + // per voxel. + bool bTileCachePolicyValid = false; + bool bTileCachePolicyResult = false; + bool bTileCachePolicyHasContext = false; + int32 TileCachePolicySampleStep = 0; + FIntVector TileCachePolicyOrigin = FIntVector::ZeroValue; + int32 TileCachePolicyStep = 0; + int32 TileCachePolicyCells = 0; + uint32 TileCachePolicyFingerprint = 0xFFFFFFFFu; + uint32 TileCachePolicyLayout = 0xFFFFFFFFu; + uint64 TileCachePolicyManagerLifetimeId = 0; + const TArray* TileCachePolicyTerrainOps = nullptr; + }; + + FState& State() const + { + // The prepared SDF data lives in the worker-local keyed cache bank above. This state + // is only scratch for the current EvalSample: Eval resets NearestRoom and the local + // parameter memo before its detail ops consume them. A single TLS slot is therefore + // safe even when this worker switches between immutable source instances, and avoids + // a map lookup on every sample. A mutable member is not safe because stacks can be + // shared by concurrent readers during generation. + static thread_local FState Scratch; + return Scratch; + } + + /** Le cache que la source vient de bâtir/servir pour ce voxel. Lu par les colonnes (4d). */ + const FChunkSDFCache& GetCache() const + { + return State().ActiveCache != nullptr ? *State().ActiveCache : State().Cache; + } + + /** L'index de la salle la plus proche pour le dernier voxel évalué. -1 = aucune. + * Lu par les arches, les dômes, le pincement et le biais de sol. */ + int32 GetNearestRoomIdx() const { return State().NearestRoom; } + + /** Restore the source-owned state belonging to one sample before a later block op reads it. */ + void RestoreBlockSample(const FVoxelOpSample& Sample) const + { + FState& S = State(); + S.ActiveCache = Sample.RoomCache; + S.NearestRoom = Sample.NearestRoomIndex; + S.bLocalParamsValid = false; + S.bLastRoomOwnsBottomValid = false; + } + + bool TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const override + { + if (VoxelDensityAblation::IsTunnelCoreOff()) + { + return false; + } + FState& S = State(); + if (!S.bLastTunnelCoreWorldEvaluationValid + || S.LastTunnelCoreEvaluationPosition != S.LastWorldPosition) + { + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) + { + return false; + } + + // The common generator tail runs after EvalMC. The stack has already evaluated + // the graph source for this exact sample, but the native tunnel-core post is + // intentionally outside the stack because it must remain after disturbances. On + // demand, publish the one combined result here so the tail can reuse it without + // building/scanning a second native cache. + const FVector& Position = S.LastWorldPosition; + S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + Position.X, Position.Y, Position.Z, GetCache(), nullptr, + VoxelGenLOD::ShouldUseSpatialIndex(false), + &S.LastWarpedPosition, + S.bLastRoomOwnsBottomValid ? &S.bLastRoomOwnsBottom : nullptr); + S.LastTunnelCoreEvaluationPosition = Position; + S.bLastTunnelCoreWorldEvaluationValid = true; + } + OutEvaluation = S.LastTunnelCoreWorldEvaluation; + return true; + } + + /** + * ÉTAPE C1 — L'OVERRIDE D'OP PAR SALLE. Les params de la strate avec l'op de terrain tiré + * pour la salle la plus proche appliqué par-dessus. **ONZE des douze modificateurs de détail + * lisent ceci au lieu de leurs propres params.** + * + * ⚠️ POURQUOI ÇA VIT ICI ET PAS DANS CHAQUE MODIFICATEUR. + * `OPSTACK-DECOMPOSITION §2` disait que cette pièce n'a « pas de domicile propre » et + * proposait de donner à chaque modificateur un prédicat « seulement dans la salle N ». La + * difficulté venait d'une hypothèse : que chaque modificateur doive POSSÉDER ses params. Dès + * qu'UN opérateur possède l'état partagé et que les autres le LISENT, elle disparaît — et ce + * motif est déjà celui de `FOverhangShelfMod` ← `FSurfaceColumnSource` et de + * `FShaftLedgeMod` ← `FShaftFieldSource`. C'est la résolution des pits/cheminées une + * deuxième fois : ne pas inventer de mécanisme de portée, laisser une source publier. + * + * ⚠️ LA COPIE DE ~74 CHAMPS PAR VOXEL EST TRANSCRITE TELLE QUELLE. L'original écrit + * `FStrateGenerationParams LocalTerrainParams = Params;` dans le bloc par voxel. C'est un + * poste de perf réel, noté dans OPSTACK-PROGRESS ; le mémo ci-dessous garantit seulement + * qu'on ne la fait pas ONZE fois là où l'original la fait une. + * + * ⚠️ **LA RUGOSITÉ (4b) N'APPELLE PAS CECI**, et c'est la lecture du code, pas une + * simplification : dans l'original le shadow `const FStrateGenerationParams& Params = + * LocalTerrainParams;` est déclaré DANS le bloc `if (bNearCaveSurface)` qui commence APRÈS + * l'étape 4b. La rugosité lit les params de la strate. Douze modificateurs, onze lecteurs. + * + * One op owns the shared state and the rest read it — the same pattern the surface and shaft + * ports already use, and the reason §2's "no clean home" problem evaporates. The ~74-field + * per-voxel copy is the original's, kept. Roughness (4b) deliberately does NOT read this. + */ + const FStrateGenerationParams& LocalParams() const + { + FState& S = State(); + if (!S.bLocalParamsValid) + { + S.LocalParams = P; + const FChunkSDFCache& Cache = GetCache(); + if (S.NearestRoom >= 0 && Cache.Rooms.IsValidIndex(S.NearestRoom)) + { + const FCachedRoom& NR = Cache.Rooms[S.NearestRoom]; + if (NR.RoomOp) + { + // N'écrit que les champs propres au type de l'op ; tout le reste garde la + // valeur de la strate. Exactement l'appel de l'original. + NR.RoomOp->ApplyTo(S.LocalParams, NR.RoomOpWeight); + } + } + S.bLocalParamsValid = true; + } + return S.LocalParams; + } + + /** + * Une requête SDF supplémentaire dans le cache courant, pour les sondes de gradient des + * terrasses. + * + * ⚠️ TRANSCRIT TEL QUEL, Y COMPRIS CE QUI SEMBLE INCOHÉRENT : l'original sonde en + * `(WorldX, WorldY, WorldZ ± 1)` — coordonnées NON warpées et Z NON divisé par + * `VerticalScale` — alors que le champ qu'il sonde a été évalué en coordonnées WARPÉES et en + * Z effectif. La sonde ne voit donc pas exactement le champ dont elle mesure la pente, et + * elle ignore aussi les pits et les cheminées. C'est un écart réel de l'original ; le + * corriger changerait le monde, donc il est NOTÉ (OPSTACK-PROGRESS) et porté à l'identique. + * + * Transcribed as-is including what looks wrong: the probe uses unwarped X/Y and raw Z while + * the field it probes was evaluated warped, and it excludes pits/chimneys. + */ + float ProbeSdfUnwarped(float X, float Y, float Z) const + { + return VoxelCaveMorphology::EvaluateSDFCached( + X, Y, Z, GetCache(), P.SDFBlendRadius, nullptr, + VoxelGenLOD::ShouldUseSpatialIndex(false)); + } + + /** Le Z « effectif » : `VerticalScale` étire le monde AVANT le bruit. Pure fonction de Z et + * d'un param — c'est pourquoi ce portage n'a PAS eu besoin d'un opérateur « frame » + * (voir la note de conception dans BuildTunnelNetworkStack). */ + FORCEINLINE float EffZ(float WorldZ) const + { + return (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) ? (WorldZ / P.VerticalScale) + : WorldZ; + } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::RoomGraphSource); + FState& S = State(); + S.LastWorldPosition = FVector(WorldX, WorldY, WorldZ); + S.bLastTunnelCoreWorldEvaluationValid = false; + S.bLastRoomOwnsBottomValid = false; + + // ⚠️ REMIS À -1 INCONDITIONNELLEMENT, ce que l'original ne fait pas : chez lui + // `NearestRoomIdx` est un `thread_local` qui, quand `RoomDensity <= 0`, garde la valeur + // du voxel PRÉCÉDENT. Inobservable là-bas (sans salles, `CaveSDF` reste FLT_MAX, donc + // `bNearCaveSurface` est faux et aucun consommateur ne tourne) — mais ici les + // consommateurs sont des objets séparés, et une valeur périmée qui traverse une frontière + // d'opérateur est le genre de chose qu'on ne retrouve pas. On paie une écriture. + S.NearestRoom = -1; + // ÉTAPE C1 — le mémo d'override est PAR VOXEL. L'invalider ici, avant tout early-out, + // est ce qui garantit qu'aucun modificateur ne lira les params de la salle du voxel + // précédent. (Le mémo n'est PAS recalculé ici : le faire coûterait une copie de struct + // sur chaque voxel de roc profond, ce que l'original ne paie pas.) + S.bLocalParamsValid = false; + + const UVoxelStrateManager* LiveManager = Manager.Get(); + // A cached stack may outlive its UObject owner when an editor world is stopped. The + // generator's manager-lifetime key prevents selecting it normally; this guard is the + // final boundary for a task that is already inside an old stack. Keep commandlet stacks + // (which deliberately have no manager) functional, but never evaluate stale room data. + if (ManagerLifetimeId != 0 && LiveManager == nullptr) + { + S.Cache = FChunkSDFCache(); + S.SharedCacheEntry.Reset(); + S.ActiveCache = &S.Cache; + InOut.Sdf = FLT_MAX; + return; + } + + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) + { + S.SharedCacheEntry.Reset(); + S.ActiveCache = &S.Cache; + return; // Sdf reste FLT_MAX + } + + const float EffectiveZ = EffZ(WorldZ); + + //--------------------------------------------------------------- + // WARP DE CAVE — coordonnées de REQUÊTE uniquement + //--------------------------------------------------------------- + // ⚠️ Le warp ne s'applique QU'À la requête du graphe de salles. Les pits et les cheminées + // plus bas lisent les coordonnées RÉELLES, et c'est délibéré dans l'original : leurs + // ancres viennent de centres de salles NON warpés. C'est aussi pourquoi le warp n'est pas + // un « frame » : sa portée est exactement UN opérateur, donc elle appartient à cet + // opérateur. + float WarpedX = WorldX, WarpedY = WorldY, WarpedZ = EffectiveZ; + if (!VoxelDensityAblation::IsCaveWarpOff() && P.CaveWarpStrength > 0.0f) + { + const float WF = P.CaveWarpFrequency; + const float WS = P.CaveWarpStrength; + float WarpX, WarpY, WarpZ; + VoxelNoise::Perlin3D_x3( + WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f), + WorldY * WF + 1.3f, + EffectiveZ * WF + 5.7f, + WorldX * WF + 7.1f, + WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f), + EffectiveZ * WF + 2.3f, + WorldX * WF + 11.3f, + WorldY * WF + 9.7f, + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f), + WarpX, WarpY, WarpZ); + WarpedX += WarpX * VOXEL_NOISE_SCALE * WS; + WarpedY += WarpY * VOXEL_NOISE_SCALE * WS; + WarpedZ += WarpZ * VOXEL_NOISE_SCALE * WS; + } + S.LastWarpedPosition = FVector(WarpedX, WarpedY, WarpedZ); + + //--------------------------------------------------------------- + // LE CACHE PAR BOÎTE DE RECHERCHE + //--------------------------------------------------------------- + // ⚠️ CLÉ PAR BOÎTE, PAS PAR CHUNK, et c'est un INVARIANT DE PERF (§8.10) : les + // échantillons de gradient interrogent `WorldX ± 1` et le warp déplace encore, donc une + // clé « égalité de chunk » se retournait à chaque cellule de bord et reconstruisait le + // cache (coûteux) en boucle. Comme le cache couvre la boîte + MaxInfluence, toute requête + // DANS la boîte est correcte. Ne pas « simplifier » en clé de chunk. + // (Les champs de cache vivent dans `FState`, au-dessus, pour que les modificateurs de + // détail puissent les lire ; ce sont des `thread_local` partagés entre instances.) + // ⚠️ LA CLÉ INCLUT LES PARAMS, CONTRAIREMENT À L'ORIGINAL. + // L'original ne clé QUE sur (boîte, strate, seed) : deux jeux de params différents dans + // la MÊME strate au MÊME seed se servent mutuellement leur cache. En production + // `RebuildStrates` masque le trou en bougeant la strate ; en test, deux piles construites + // côte à côte le déclenchent immédiatement. Empreinte CRC des params + LayoutVersion. + // `FStrateGenerationParams` est du POD pur (aucun TArray/FString/pointeur), donc une CRC + // mémoire ne peut pas donner un FAUX POSITIF ; au pire un padding donne un faux MANQUE, + // c'est-à-dire un recalcul. On se trompe du côté du CPU, jamais du côté d'une salle fausse. + + // Index de strate — mémo (chunk-Z, version de layout), transcrit tel quel. La requête + // vise le CENTRE de la bande, donc le résultat est une fonction pure de la clé. + int32 StrateIdx = 0; + if (LiveManager) + { + thread_local int32 SI_ChunkZ = INT32_MAX; + thread_local uint32 SI_Version = 0xFFFFFFFFu; + thread_local int32 SI_Index = 0; + const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + const uint32 LV = LiveManager->GetLayoutVersion(); + if (QZ != SI_ChunkZ || LV != SI_Version) + { + SI_ChunkZ = QZ; + SI_Version = LV; + SI_Index = LiveManager->GetStrateIndex( + ((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); + } + StrateIdx = SI_Index; + } + + // The graph is window-invariant, so a tile worker can use the exact requesting tile as + // its deterministic XY cache region. The step/origin/cell extent is supplied by the + // mesher through VoxelGenLOD TLS; ordinary point queries keep the historical region + // policy. The cache key retains the exact params/strate/op-pool identity, so no + // blended chunk can borrow another chunk's room graph. + const int32 SampleStep = FMath::Max(VoxelGenLOD::SampleStep, 1); + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = SampleStep; + int32 RequestedTileCells = 0; + const TArray* TerrainOps = nullptr; + if (LiveManager) + { + const int32 ChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); + const int32 ChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); + const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + UVoxelStrateDefinition* Def = LiveManager->GetStrateForChunk( + FIntVector(ChunkX, ChunkY, ChunkZ)); + if (Def) { TerrainOps = &Def->TerrainOperations; } + } + const bool bTileWindowEligible = VoxelGenLOD::IsTileCacheWindowEnabled(false); + const bool bHasTileWindowContext = VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells); + const bool bTilePolicyKeyChanged = + !S.bTileCachePolicyValid + || S.bTileCachePolicyHasContext + != (bTileWindowEligible && bHasTileWindowContext) + || S.TileCachePolicySampleStep != RequestedTileStep + || S.TileCachePolicyOrigin != RequestedTileOrigin + || S.TileCachePolicyStep != RequestedTileStep + || S.TileCachePolicyCells != RequestedTileCells + || S.TileCachePolicyFingerprint != ParamsFingerprint + || S.TileCachePolicyLayout != LayoutVersion + || S.TileCachePolicyManagerLifetimeId != ManagerLifetimeId + || S.TileCachePolicyTerrainOps != TerrainOps; + if (bTilePolicyKeyChanged) + { + S.bTileCachePolicyValid = true; + S.bTileCachePolicyHasContext = bTileWindowEligible && bHasTileWindowContext; + S.TileCachePolicySampleStep = RequestedTileStep; + S.TileCachePolicyOrigin = RequestedTileOrigin; + S.TileCachePolicyStep = RequestedTileStep; + S.TileCachePolicyCells = RequestedTileCells; + S.TileCachePolicyFingerprint = ParamsFingerprint; + S.TileCachePolicyLayout = LayoutVersion; + S.TileCachePolicyManagerLifetimeId = ManagerLifetimeId; + S.TileCachePolicyTerrainOps = TerrainOps; + S.bTileCachePolicyResult = S.bTileCachePolicyHasContext + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(P, TerrainOps); + } + const bool bUseTileCacheWindow = S.bTileCachePolicyResult; + int32 RegionSize = 0; + int32 RegionMinX = 0; + int32 RegionMinY = 0; + if (bUseTileCacheWindow) + { + RegionSize = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + RegionMinX = RequestedTileOrigin.X; + RegionMinY = RequestedTileOrigin.Y; + } + else + { + // Fine requests retain the historical four-chunk worker window. The op-stack + // tile-sized window is gated to LOD4+ (the fused path starts at LOD3), so + // LOD0-LOD2 and the op-stack's LOD3 floor do not pay a full tile rebuild on their + // critical path while still reusing neighbouring chunks on the same worker. + const int32 RegionChunks = 4; + RegionSize = CHUNK_SIZE * RegionChunks; + const int32 RegionCellX = FMath::FloorToInt(WorldX / (float)RegionSize); + const int32 RegionCellY = FMath::FloorToInt(WorldY / (float)RegionSize); + RegionMinX = RegionCellX * RegionSize; + RegionMinY = RegionCellY * RegionSize; + } + const float CacheExpansion = FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + + (bUseTileCacheWindow ? static_cast(SampleStep) : 0.0f) + + 2.0f; + + if (SampleStep > 1) + { + TSharedPtr SharedRoomGraphCache = + S.SharedCacheEntry.IsValid() + ? SnapshotSharedRoomGraphCache(S.SharedCacheEntry) + : TSharedPtr(); + const bool bSharedCacheMatches = + S.SharedCacheEntry.IsValid() + && SharedRoomGraphCache.IsValid() + && S.SharedCacheEntry->Seed == SeedU + && S.SharedCacheEntry->StrateIndex == StrateIdx + && S.SharedCacheEntry->ParamsFingerprint == ParamsFingerprint + && S.SharedCacheEntry->LayoutVersion == LayoutVersion + && S.SharedCacheEntry->ManagerLifetimeId == ManagerLifetimeId + && S.SharedCacheEntry->TerrainOps == TerrainOps + && S.SharedCacheEntry->RegionMinX == RegionMinX + && S.SharedCacheEntry->RegionMinY == RegionMinY + && S.SharedCacheEntry->RegionSize == RegionSize; + if (!bSharedCacheMatches) + { + S.SharedCacheEntry = FindOrBuildSharedRoomGraphCache( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, + ManagerLifetimeId, TerrainOps, + RegionMinX, RegionMinY, RegionSize, + (float)RegionMinX - CacheExpansion, + (float)RegionMinY - CacheExpansion, + (float)(RegionMinX + RegionSize) + CacheExpansion, + (float)(RegionMinY + RegionSize) + CacheExpansion, + P, + ERoomGraphBuildSite::OpShared); + SharedRoomGraphCache = SnapshotSharedRoomGraphCache(S.SharedCacheEntry); + } + S.ActiveCache = SharedRoomGraphCache.Get(); + } + else + { + S.SharedCacheEntry.Reset(); + FRoomGraphCacheEntry* LocalCache = FindRoomGraphCacheEntry( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, + ManagerLifetimeId, TerrainOps, + RegionMinX, RegionMinY, RegionSize); + const bool bCacheMatches = LocalCache->bValid + && LocalCache->Seed == SeedU + && LocalCache->StrateIndex == StrateIdx + && LocalCache->ParamsFingerprint == ParamsFingerprint + && LocalCache->LayoutVersion == LayoutVersion + && LocalCache->ManagerLifetimeId == ManagerLifetimeId + && LocalCache->TerrainOps == TerrainOps + && LocalCache->RegionMinX == RegionMinX + && LocalCache->RegionMinY == RegionMinY + && LocalCache->RegionSize == RegionSize; + if (!bCacheMatches) + { + LocalCache->bValid = false; + LocalCache->Seed = SeedU; + LocalCache->StrateIndex = StrateIdx; + LocalCache->ParamsFingerprint = ParamsFingerprint; + LocalCache->LayoutVersion = LayoutVersion; + LocalCache->ManagerLifetimeId = ManagerLifetimeId; + LocalCache->TerrainOps = TerrainOps; + LocalCache->RegionMinX = RegionMinX; + LocalCache->RegionMinY = RegionMinY; + LocalCache->RegionSize = RegionSize; + VoxelCaveMorphology::BuildChunkCache( + LocalCache->Cache, + (float)RegionMinX - CacheExpansion, + (float)RegionMinY - CacheExpansion, + (float)(RegionMinX + RegionSize) + CacheExpansion, + (float)(RegionMinY + RegionSize) + CacheExpansion, + P, SeedU, StrateIdx, TerrainOps, + ERoomGraphBuildSite::OpLocal); + LocalCache->bValid = true; + VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + } + S.ActiveCache = &LocalCache->Cache; + } + const bool bUseSpatialIndex = VoxelGenLOD::ShouldUseSpatialIndex(false); + const FVector WorldTunnelPosition(WorldX, WorldY, WorldZ); + float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( + WarpedX, WarpedY, WarpedZ, GetCache(), P.SDFBlendRadius, + &S.NearestRoom, bUseSpatialIndex, &WorldTunnelPosition, + &S.bLastRoomOwnsBottom); + S.bLastRoomOwnsBottomValid = true; + + //--------------------------------------------------------------- + // PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF + //--------------------------------------------------------------- + // C'est le point que `OPSTACK-DECOMPOSITION §2` annonçait comme « le plus retors de toute + // la décomposition » : deux primitives qui écrivent le MÊME canal que le graphe de salles + // mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir + // du frame tout en gardant le canal — exprimable, mais tordu. Dans un opérateur unique la + // difficulté disparaît : le warp est une variable locale, pas un contexte hérité. + if (!VoxelDensityAblation::IsPitChimneySDFOff()) + { + auto EvaluatePit = [&](int32 PitIndex) + { + const FCachedPit& Pit = GetCache().Pits[PitIndex]; + const float DZ = WorldZ - Pit.TopZ; + if (DZ >= Pit.BlendK) { return; } + if (-DZ > Pit.Depth + Pit.BlendK) { return; } + + const float DX = WorldX - Pit.CenterX; + const float DY = WorldY - Pit.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) { return; } + + float PitSDF; + if (DZ <= 0.0f) + { + const float DepthBelow = -DZ; + float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + const float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor; + PitSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); + }; + if (!VoxelDensityAblation::IsPitChimneySDFOff()) + { + VF_ForEachChunkSDFSpatialCandidate( + GetCache().PitSpatialIndex, GetCache().Pits.Num(), + WorldX, WorldY, EvaluatePit, bUseSpatialIndex); + } + + auto EvaluateChimney = [&](int32 ChimneyIndex) + { + const FCachedChimney& Chim = GetCache().Chimneys[ChimneyIndex]; + const float DZ = WorldZ - Chim.BottomZ; + if (-DZ >= Chim.BlendK) { return; } + if (DZ > Chim.Height + Chim.BlendK) { return; } + + const float DX = WorldX - Chim.CenterX; + const float DY = WorldY - Chim.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chim.BoundXYRadiusSq) { return; } + + float ChmSDF; + if (DZ >= 0.0f) + { + float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + const float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor; + ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK); + }; + if (!VoxelDensityAblation::IsPitChimneySDFOff()) + { + VF_ForEachChunkSDFSpatialCandidate( + GetCache().ChimneySpatialIndex, GetCache().Chimneys.Num(), + WorldX, WorldY, EvaluateChimney, bUseSpatialIndex); + } + } + + InOut.Sdf = CaveSDF; + + // Publish the same continuous tunnel-core field that the common generator tail will + // reassert after generic writers. The stack does not publish or protect a separate + // support slab. + if (!VoxelDensityAblation::IsTunnelCoreOff()) + { + S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, GetCache(), nullptr, + bUseSpatialIndex, &S.LastWarpedPosition, + &S.bLastRoomOwnsBottom); + S.LastTunnelCoreEvaluationPosition = S.LastWorldPosition; + S.bLastTunnelCoreWorldEvaluationValid = true; + + // Keep the room-owned bottom hand-off for block evaluation compatibility. The + // authored floor remains part of the tunnel shape, but it must not win inside a + // room's own volume. + InOut.bHasTunnelCoreWorldEvaluation = true; + InOut.TunnelCoreWorldSDF = S.LastTunnelCoreWorldEvaluation.SDF; + InOut.bTunnelCoreRoomFloor = + S.LastTunnelCoreWorldEvaluation.bRoomFloor; + InOut.bHasTunnelCoreSweptFloor = + S.LastTunnelCoreWorldEvaluation.bHasSweptFloor; + InOut.TunnelCoreSweptFloorZ = + S.LastTunnelCoreWorldEvaluation.SweptFloorZ; + InOut.TunnelCoreSweptFloorRadius = + S.LastTunnelCoreWorldEvaluation.SweptFloorRadius; + } + + // These cache hand-offs remain valid when tunnel core is off; detail operators still + // need the room graph and nearest-room ownership. + InOut.RoomCache = &GetCache(); + InOut.NearestRoomIndex = S.NearestRoom; + } + + //--------------------------------------------------------------------- + // L'ÉTAT PAR BOÎTE — SÉPARÉ DE `FState`, ET DÉLIBÉRÉMENT + //--------------------------------------------------------------------- + // `EffectOverBox` construit un cache pour la boîte INTERROGÉE, qui n'est pas la boîte de + // recherche que `Eval` construit pour le voxel courant. Les faire partager `FState::Cache` + // serait *correct* — la discipline d'invariance de fenêtre de §8.4 garantit qu'un cache bâti + // sur une boîte PLUS LARGE donne le même SDF par voxel — mais ça rendrait `ClassifyTile` + // capable de perturber le cache chaud d'une génération en cours, et un jour quelqu'un + // paierait cette élégance très cher. Un deuxième cache par worker coûte une allocation + // amortie ; on la paie. + // + // Le cache et l'intervalle sont mémoïsés, et ce n'est pas du confort : les DOUZE + // modificateurs de détail consultent le même intervalle pour la même boîte. Sans mémo, + // une tuile coûterait treize `BuildChunkCache` au lieu d'un. + // + // Second per-worker cache, on purpose: sharing FState::Cache would be sound but would let + // tile classification disturb a live generation's hot cache. The verdict is memoised because + // all twelve detail modifiers ask the same question about the same box. + struct FBoxState + { + FChunkSDFCache Cache; + const FChunkSDFCache* ActiveCache = nullptr; + FBox KeyBox = FBox(ForceInit); + int32 KeyStrate = INT32_MIN; + uint32 KeySeed = 0; + uint32 KeyFingerprint = 0xFFFFFFFFu; + uint32 KeyLayout = 0xFFFFFFFFu; + uint64 KeyManagerLifetimeId = 0; + bool KeyUsesLatticeProof = false; + bool KeyTightenWarpProof = false; + FIntVector KeyLatticeOrigin = FIntVector::ZeroValue; + int32 KeyLatticeStep = 1; + bool bValid = false; + + // A refined tile proof asks the same source about a root box and then many child + // boxes. The morphology cache is window-invariant, so keep the root's superset + // cache alive for all children instead of rebuilding the graph once per child. + FBox CacheWindowBox = FBox(ForceInit); + int32 CacheWindowStrate = INT32_MIN; + uint32 CacheWindowSeed = 0; + uint32 CacheWindowFingerprint = 0xFFFFFFFFu; + uint32 CacheWindowLayout = 0xFFFFFFFFu; + uint64 CacheWindowManagerLifetimeId = 0; + const TArray* CacheWindowTerrainOps = nullptr; + bool CacheWindowUsesLatticeProof = false; + bool CacheWindowTightenWarpProof = false; + int32 CacheWindowLatticeStep = 1; + TSharedPtr CacheWindowSharedEntry; + int32 CacheWindowSharedRegionMinX = 0; + int32 CacheWindowSharedRegionMinY = 0; + int32 CacheWindowSharedRegionSize = 0; + bool bCacheWindowUsesShared = false; + bool CacheWindowUsesTileCacheWindow = false; + FIntVector CacheWindowTileOrigin = FIntVector::ZeroValue; + int32 CacheWindowTileStep = 0; + int32 CacheWindowTileCells = 0; + bool bCacheWindowValid = false; + + // Tight warp bounds are proofs over a box. Refinement asks about nested child boxes, + // so one bound for the current root is a sound superset for all descendants. + FBox TightWarpEnvelopeBox = FBox(ForceInit); + FVector TightWarpEnvelope = FVector::ZeroVector; + bool bTightWarpEnvelopeValid = false; + + // Exact lattice warp queries are shared by all refined children of one classifier + // root. The old path rebuilt the same Perlin warp for every small child (64 times on + // a uniform LOD0 tile). This is a bounded worker-local scratch array, not a process + // cache: it is keyed by the current box-proof state and never retains data for a + // different tile or parameter set. + TArray ExactWarpedLatticeQueries; + // Four-state results for the exact world-space tunnel-post check. This is a bounded + // per-window point cache: refinement boxes share boundary vertices, but no interval or + // tile verdict is retained here. + TArray ExactTailStates; + // Per-box exact proofs reuse these bounded working arrays. They are scratch only: the + // arrays are reset before every propagation and never retain an interval or verdict. + // Keeping their capacity on the worker removes a heap allocation for every refined + // LOD0 child while preserving the existing 100000-sample safety cap below. + TArray ExactWarpedLatticeScratch; + TArray ExactWorldLatticeScratch; + FBox ExactWarpedLatticeCacheBox = FBox(ForceInit); + int32 ExactWarpedLatticeIX0 = 0; + int32 ExactWarpedLatticeIY0 = 0; + int32 ExactWarpedLatticeIZ0 = 0; + int32 ExactWarpedLatticeNX = 0; + int32 ExactWarpedLatticeNY = 0; + int32 ExactWarpedLatticeNZ = 0; + FIntVector ExactWarpedLatticeOrigin = FIntVector::ZeroValue; + int32 ExactWarpedLatticeStep = 0; + uint32 ExactWarpedLatticeSeed = 0xFFFFFFFFu; + uint32 ExactWarpedLatticeFingerprint = 0xFFFFFFFFu; + uint32 ExactWarpedLatticeLayout = 0xFFFFFFFFu; + uint64 ExactWarpedLatticeManagerLifetimeId = 0; + bool bExactWarpedLatticeCacheValid = false; + + EVoxelOpEffect Verdict = EVoxelOpEffect::Both; + FVoxelBoxSdfInterval SdfInterval; + + /** DIAGNOSTIC — combien de primitives de chaque classe atteignent la dernière boîte + * interrogée, et combien le cache en contenait. Lu par les tests via + * `VoxelDensityOps::GetLastRoomBoxDiagnostic`. N'entre dans aucune décision. */ + int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0; + int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0; + /** Les mêmes comptes avec une dilatation de warp NULLE, et de combien de voxels la + * boîte est dilatée. C'est la mesure qui manquait pendant trois builds : sans elle, + * « les tunnels bloquent » et « ma boîte est 125x trop grosse » sont indiscernables. */ + int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0; + float WarpDilation = 0.0f; + bool bMayHaveTunnelCoreAir = false; + bool bMayHaveTunnelSupportFloor = false; + bool bMayHaveTunnelCoreTail = false; + uint32 ExactTailQueries = 0; + uint32 ExactTailEvaluated = 0; + uint64 ExactTailCycles = 0; + uint64 PropagateCycles = 0; + uint64 ExactPrimitiveCycles = 0; + uint64 CacheWindowCycles = 0; + int32 NumRoomFloorJoins = 0; + }; + + static FBoxState& BoxState() + { + thread_local FBoxState S; + return S; + } + + const FChunkSDFCache& GetBoxCache() const + { + const FBoxState& B = BoxState(); + return B.ActiveCache != nullptr ? *B.ActiveCache : B.Cache; + } + + /** + * ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée. + * + * Ce que ça débloque, en un mot : la source publie un intervalle SDF et `FSdfConvertOp` + * ainsi que les douze modificateurs de détail décident séparément, à partir de cet + * intervalle, s'ils sont identités. Une boîte éloignée peut donc éteindre toute la chaîne, + * mais aucune source ne répond au nom d'un convertisseur ou d'un modificateur. + * + * LE CRITÈRE — **UNE PRIMITIVE NE COMPTE PAS SI ELLE RATE SON CULL *OU* SI SON SDF RESTE + * AU-DESSUS DU SEUIL `T`.** Une disjonction, pas une seule règle, et chaque branche gagne sur + * une classe différente. Le détail de `T` et sa condition de validité sont dans la note + * « LE SEUIL T » à l'intérieur de la fonction — la lire avant toute modification. + * + * • branche CULL — `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive + * qui SURVIT à son cull par voxel. Aucune survivante ⇒ `Sdf` reste `FLT_MAX`. C'est la + * même inégalité que le cull, élevée du point à la boîte. **Meilleure pour les salles** : + * leur cull (`Rmax + 3K`) est plus serré que le seuil (`Rmax + T + K`). + * • branche SEUIL — une primitive peut survivre à son cull et rester malgré tout trop loin + * pour qu'un consommateur s'allume. **Meilleure pour les tunnels**, dont le cull est la + * sphère englobante d'une capsule : rayon ~107 pour un tube de rayon 7 long de 200. + * + * Mélanger les deux est sûr : les primitives de la branche cull ne contribuent RIEN, les + * autres sont toutes ≥ `T + K`, donc le pli vaut ≥ `T` (voir la saturation de `SmoothMin`), + * et les trois consommateurs sont éteints. Une seule raison d'échouer, dans les deux cas. + * + * LES TROIS CHOSES QUI RENDENT LE TEST CONSERVATIF DU BON CÔTÉ : + * 1. le warp déplace la coordonnée de REQUÊTE, donc la boîte est dilatée de sa borne + * prouvable avant d'être confrontée aux salles et aux tunnels ; + * 2. les pits et les cheminées sont interrogés en coordonnées RÉELLES (voir `Eval`), donc + * ils sont confrontés à la boîte NON dilatée — la dilater serait juste plus prudent, ne + * pas la dilater pour eux serait faux ; + * 3. la boîte de recherche du cache est PLUS LARGE que celle de `Eval`, ce qui donne un + * SUR-ensemble de primitives : si rien n'atteint la boîte ici, rien ne l'atteint là-bas. + * + * The criterion is the per-voxel cull lifted from point to box. The source publishes only + * its own SDF interval; the converter and the twelve modifiers consume that interval in + * their own state-aware folds. + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + // This source writes SDF only. It must never answer for a converter or modifier that + // happens to follow it; the interval is the source's complete box-query contract. + return EVoxelOpEffect::Identity; + } + + // This operator publishes SDF and does not change Density. Reporting zero here is + // important: FLT_MAX means "an unknown density delta", whereas this source has no density + // delta at all. The SDF interval remains the source's separate box contract below. + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + /** + * State-aware detail gate support. A room terrain op can replace the strate-level + * activation/strength fields for the room selected by Eval. Until the cache tells us + * that the queried box contains no such override, a detail bound based on P alone would be + * unsound. Returning true on an invalid box state deliberately loses a skip rather than + * manufacturing one. + */ + bool HasRoomTerrainOverrideForLastBox() const + { + const FBoxState& B = BoxState(); + if (!B.bValid) { return true; } + for (const FCachedRoom& Room : GetBoxCache().Rooms) + { + if (Room.RoomOp != nullptr) { return true; } + } + return false; + } + + float RoomColumnFillSupremumForLastBox() const + { + const FBoxState& B = BoxState(); + if (!B.bValid) { return FLT_MAX; } + + float Supremum = 0.0f; + for (const FCachedColumn& Column : GetBoxCache().Columns) + { + if (!VoxelMath::IsFinite(Column.BaseDensity) || Column.BaseDensity < 0.0f) + { + // Eval multiplies by BaseDensity. A negative authored value reverses the + // declared FillOnly direction, so the state-aware caller must fall back to + // Both/unknown rather than pretend this is a fill envelope. + return FLT_MAX; + } + const float Contribution = Column.BaseDensity * 1.5f; + if (!VoxelMath::IsFinite(Contribution) + || Supremum > FLT_MAX - Contribution) + { + return FLT_MAX; + } + Supremum += Contribution; + } + return Supremum; + } + + float FloorBiasFillSupremumForLastBox() const + { + const FBoxState& B = BoxState(); + if (!B.bValid) { return FLT_MAX; } + + // Eval uses NormZ² only below the room centre. The largest value over the queried + // box is therefore the squared distance from the room centre to Box.Min.Z, divided by + // max(RadiusZ, 1)². Taking the maximum over cached rooms is conservative for whichever + // room wins the nearest-SDF query, and is finite without assuming a global room height. + float Supremum = 0.0f; + const float BoxMinZ = static_cast(B.KeyBox.Min.Z); + if (!VoxelMath::IsFinite(BoxMinZ)) { return FLT_MAX; } + for (const FCachedRoom& Room : GetBoxCache().Rooms) + { + const float CenterZ = static_cast(Room.Center.Z); + const float RadiusZ = Room.RadiusZ; + if (!VoxelMath::IsFinite(CenterZ) || !VoxelMath::IsFinite(RadiusZ) + || !VoxelMath::IsFinite(P.FloorBias) || P.FloorBias < 0.0f) + { + return FLT_MAX; + } + const float Denominator = FMath::Max(RadiusZ, 1.0f); + const float Below = FMath::Max(CenterZ - BoxMinZ, 0.0f); + const float Normalized = Below / Denominator; + const float Contribution = Normalized * Normalized * P.FloorBias; + if (!VoxelMath::IsFinite(Contribution)) { return FLT_MAX; } + Supremum = FMath::Max(Supremum, Contribution); + } + return Supremum; + } + + /** + * Publish a conservative interval for this source alone. + * + * The cache is the same morphology implementation used by Eval, but the box proof never + * samples a downstream density op. Rooms and tunnel segments are bounded by the exact + * geometric support that Eval's culls admit; pits and chimneys are bounded in their real + * (unwarped) coordinates. The final SmoothMin can lower the running minimum by at most K. + * + * Soundness is the priority: a failed validation, an overlarge scan, or a non-finite + * intermediate returns Unknown. That can lose a tile skip; it cannot turn a tile into a + * false AllSolid/AllAir result. + */ + void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override + { + FBoxState& B = BoxState(); + const uint64 PropagateStartCycles = FPlatformTime::Cycles64(); + B.ExactTailQueries = 0; + B.ExactTailEvaluated = 0; + B.ExactTailCycles = 0; + B.PropagateCycles = 0; + B.ExactPrimitiveCycles = 0; + B.CacheWindowCycles = 0; + B.NumRoomFloorJoins = 0; + const UVoxelStrateManager* LiveManager = Manager.Get(); + auto InvalidateExactLatticeScratch = [&]() + { + B.bExactWarpedLatticeCacheValid = false; + B.ExactWarpedLatticeQueries.Reset(); + B.ExactTailStates.Reset(); + B.ExactWarpedLatticeScratch.Reset(); + B.ExactWorldLatticeScratch.Reset(); + B.ExactWarpedLatticeCacheBox = FBox(ForceInit); + B.ExactWarpedLatticeIX0 = B.ExactWarpedLatticeIY0 = 0; + B.ExactWarpedLatticeIZ0 = 0; + B.ExactWarpedLatticeNX = B.ExactWarpedLatticeNY = 0; + B.ExactWarpedLatticeNZ = 0; + B.ExactWarpedLatticeOrigin = FIntVector::ZeroValue; + B.ExactWarpedLatticeStep = 0; + B.ExactWarpedLatticeSeed = 0xFFFFFFFFu; + B.ExactWarpedLatticeFingerprint = 0xFFFFFFFFu; + B.ExactWarpedLatticeLayout = 0xFFFFFFFFu; + B.ExactWarpedLatticeManagerLifetimeId = 0; + }; + + auto Unknown = [&]() + { + InvalidateExactLatticeScratch(); + B.bValid = false; + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.CacheWindowSharedRegionMinX = 0; + B.CacheWindowSharedRegionMinY = 0; + B.CacheWindowSharedRegionSize = 0; + B.bCacheWindowUsesShared = false; + B.CacheWindowUsesTileCacheWindow = false; + B.CacheWindowTileOrigin = FIntVector::ZeroValue; + B.CacheWindowTileStep = 0; + B.CacheWindowTileCells = 0; + B.bCacheWindowValid = false; + B.bTightWarpEnvelopeValid = false; + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; + B.SdfInterval.SetUnknown(); + InOut.SetUnknown(); + }; + auto Finite = [](float V) { return VoxelMath::IsFinite(V); }; + + if (ManagerLifetimeId != 0 && LiveManager == nullptr) + { + Unknown(); + return; + } + + const float BoxMinX = (float)VoxelBox.Min.X; + const float BoxMinY = (float)VoxelBox.Min.Y; + const float BoxMinZ = (float)VoxelBox.Min.Z; + const float BoxMaxX = (float)VoxelBox.Max.X; + const float BoxMaxY = (float)VoxelBox.Max.Y; + const float BoxMaxZ = (float)VoxelBox.Max.Z; + if (!Finite(BoxMinX) || !Finite(BoxMinY) || !Finite(BoxMinZ) + || !Finite(BoxMaxX) || !Finite(BoxMaxY) || !Finite(BoxMaxZ) + || BoxMinX > BoxMaxX || BoxMinY > BoxMaxY || BoxMinZ > BoxMaxZ) + { + Unknown(); + return; + } + + // These are the fields that influence the source geometry or its cache window. If an + // authored asset leaves the proven range, the safe answer is Unknown, never a guessed + // "reasonable" radius. Non-positive room density/spacing is an exact Eval no-op. + const float RelevantParams[] = { + P.RoomDensity, P.RoomSpacing, P.MinRoomRadius, P.MaxRoomRadius, P.RoomHeightRatio, + P.RoomFloorCutMin, P.RoomFloorCutMax, P.FloorReliefStrength, P.FloorReliefFrequency, + P.RoomShapeVariety, P.OriginRoomRadius, P.TunnelMinRadius, P.TunnelMaxRadius, + P.TunnelDensity, P.MaxTunnelLength, P.TunnelWarpStrength, P.TunnelHorizontalBias, + P.TunnelEndpointZOffset, P.SDFBlendRadius, P.CaveWarpStrength, P.CaveWarpFrequency, + P.VerticalScale + }; + for (const float V : RelevantParams) + { + if (!Finite(V)) + { + Unknown(); + return; + } + } + + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) + { + InvalidateExactLatticeScratch(); + B.bValid = false; + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.bCacheWindowUsesShared = false; + B.bCacheWindowValid = false; + B.NumRooms = B.NumTunnels = B.NumPits = B.NumChimneys = 0; + B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; + B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; + B.WarpDilation = 0.0f; + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; + B.SdfInterval.Set(FLT_MAX, FLT_MAX); + InOut = B.SdfInterval; + return; + } + + if (P.MinRoomRadius < 0.0f || P.MaxRoomRadius < 0.0f || P.RoomHeightRatio < 0.0f + || P.OriginRoomRadius < 0.0f || P.TunnelMinRadius < 0.0f || P.TunnelMaxRadius < 0.0f + || P.SDFBlendRadius < 0.0f) + { + Unknown(); + return; + } + + const int32 CX0 = FMath::FloorToInt(BoxMinX / (float)CHUNK_SIZE); + const int32 CY0 = FMath::FloorToInt(BoxMinY / (float)CHUNK_SIZE); + const int32 CZ0 = FMath::FloorToInt(BoxMinZ / (float)CHUNK_SIZE); + const int32 CX1 = FMath::FloorToInt(BoxMaxX / (float)CHUNK_SIZE); + const int32 CY1 = FMath::FloorToInt(BoxMaxY / (float)CHUNK_SIZE); + const int32 CZ1 = FMath::FloorToInt(BoxMaxZ / (float)CHUNK_SIZE); + + const int64 SpanX = (int64)CX1 - (int64)CX0 + 1; + const int64 SpanY = (int64)CY1 - (int64)CY0 + 1; + const int64 SpanZ = (int64)CZ1 - (int64)CZ0 + 1; + if (SpanX <= 0 || SpanY <= 0 || SpanZ <= 0) + { + Unknown(); + return; + } + + int32 StrateIdx = 0; + const TArray* TerrainOps = nullptr; + if (LiveManager) + { + StrateIdx = LiveManager->GetStrateIndex( + ((float)CZ0 + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); + for (int32 CZ = CZ0 + 1; CZ <= CZ1; ++CZ) + { + if (LiveManager->GetStrateIndex( + ((float)CZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE) != StrateIdx) + { + Unknown(); + return; + } + } + + // A room operation is part of the graph cache. Every Z slice covered by this + // proof must resolve to the same definition before one cache can represent it. + UVoxelStrateDefinition* Def0 = nullptr; + for (int32 CZ = CZ0; CZ <= CZ1; ++CZ) + { + UVoxelStrateDefinition* Def = + LiveManager->GetStrateForChunk(FIntVector(0, 0, CZ)); + if (CZ == CZ0) + { + Def0 = Def; + } + else if (Def != Def0) + { + Unknown(); + return; + } + } + if (Def0) { TerrainOps = &Def0->TerrainOperations; } + } + + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = FMath::Max(Ctx.Step, 1); + int32 RequestedTileCells = 0; + const bool bUseTileCacheWindow = VoxelGenLOD::IsTileCacheWindowEnabled(false) + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells) + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(P, TerrainOps); + const float RequestedTileExtent = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + // FillChunk includes one sample-step of mesher halo around the requested tile. + // Keep the tile footprint as the shared-cache identity, but let every block in that + // halo reuse the same cache window. The morphology search below includes the same + // coverage, so this widens reuse without changing the set of values evaluated. + const FBox CacheWindowCoverageBox = bUseTileCacheWindow + ? FBox( + FVector( + static_cast(RequestedTileOrigin.X) + - static_cast(RequestedTileStep), + static_cast(RequestedTileOrigin.Y) + - static_cast(RequestedTileStep), + static_cast(RequestedTileOrigin.Z) + - static_cast(RequestedTileStep)), + FVector( + static_cast(RequestedTileOrigin.X) + RequestedTileExtent, + static_cast(RequestedTileOrigin.Y) + RequestedTileExtent, + static_cast(RequestedTileOrigin.Z) + RequestedTileExtent) + + FVector( + static_cast(RequestedTileStep), + static_cast(RequestedTileStep), + static_cast(RequestedTileStep))) + : VoxelBox; + const int64 RequestedTileChunkSpan = bUseTileCacheWindow + ? FMath::Max( + 1, + (static_cast(RequestedTileExtent) + CHUNK_SIZE - 1) + / CHUNK_SIZE) + : 0; + + // A coarse tile is allowed to warm the proof, but never by allocating a morphology + // cache for the whole tile. At LOD3/4 the root can cover hundreds or thousands of + // chunk keys; that is a proof-domain size, not a useful working set. Descendants + // are still queried normally and can use the bounded shared windows below. Unknown + // is deliberately the only answer here: it cannot authorize an empty-tile skip. + constexpr int64 MaxLatticeProofChunkVolume = 512; + if (Ctx.bUseLatticeProof + && SpanX * SpanY * SpanZ > MaxLatticeProofChunkVolume + && !(bUseTileCacheWindow + && RequestedTileChunkSpan <= 64 + && SpanZ <= 16)) + { + Unknown(); + return; + } + + // The ordinary (non-lattice) proof keeps its original bounded query contract. A + // tile proof is different: its first query is the root box and its descendants are + // all inside that box. Build the root cache once even when the root spans more than + // 64 chunk keys, then let the bounded child boxes use that same superset. This is + // what prevents a step-32 tile from paying one graph bake per coarse sample. + constexpr int64 MaxNonLatticeChunkSpan = 64; + if (!Ctx.bUseLatticeProof && SpanX * SpanY * SpanZ > MaxNonLatticeChunkSpan) + { + Unknown(); + return; + } + + const uint32 LV = Ctx.LayoutVersion; + auto IsSharedCacheCurrent = [&]() -> bool + { + const FSharedRoomGraphCacheEntry* Entry = B.CacheWindowSharedEntry.Get(); + return Entry != nullptr + && Entry->Seed == SeedU + && Entry->StrateIndex == StrateIdx + && Entry->ParamsFingerprint == ParamsFingerprint + && Entry->LayoutVersion == LayoutVersion + && Entry->ManagerLifetimeId == ManagerLifetimeId + && Entry->TerrainOps == TerrainOps + && Entry->RegionMinX == B.CacheWindowSharedRegionMinX + && Entry->RegionMinY == B.CacheWindowSharedRegionMinY + && Entry->RegionSize == B.CacheWindowSharedRegionSize; + }; + if (B.bValid && B.KeyBox == VoxelBox && B.KeyStrate == StrateIdx + && B.KeySeed == SeedU && B.KeyFingerprint == ParamsFingerprint && B.KeyLayout == LV + && B.KeyManagerLifetimeId == ManagerLifetimeId + && B.KeyUsesLatticeProof == Ctx.bUseLatticeProof + && (!B.bCacheWindowUsesShared || IsSharedCacheCurrent()) + && B.KeyTightenWarpProof == Ctx.bTightenWarpProof + && (!Ctx.bUseLatticeProof + || (B.KeyLatticeOrigin == Ctx.LatticeOriginVoxels + && B.KeyLatticeStep == Ctx.Step))) + { + InOut = B.SdfInterval; + return; + } + + const float EffectiveMinZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? BoxMinZ / P.VerticalScale : BoxMinZ; + const float EffectiveMaxZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? BoxMaxZ / P.VerticalScale : BoxMaxZ; + const float GlobalWarp = (P.CaveWarpStrength > 0.0f) + ? P.CaveWarpStrength * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + : 0.0f; + if (!Finite(GlobalWarp) || !Finite(EffectiveMinZ) || !Finite(EffectiveMaxZ)) + { + Unknown(); + return; + } + + FVector WarpEnvelope(GlobalWarp, GlobalWarp, GlobalWarp); + // The finite Perlin interval is useful at the root too when the root is one ordinary + // LOD0 tile wide. Compute it once and retain it as a superset for every descendant; + // otherwise each refined child repeats the same finite-domain envelope work. The + // helper falls back to the global theorem for larger/non-finite noise domains, so + // widening this gate cannot weaken soundness. + const bool bUseFiniteWarpEnvelope = Ctx.bTightenWarpProof + && FMath::Max3(BoxMaxX - BoxMinX, BoxMaxY - BoxMinY, BoxMaxZ - BoxMinZ) + <= 32.0f * static_cast(FMath::Max(Ctx.Step, 1)); + if (bUseFiniteWarpEnvelope && P.CaveWarpStrength > 0.0f + && P.CaveWarpFrequency > 0.0f) + { + const bool bCanReuseTightEnvelope = B.bTightWarpEnvelopeValid + && B.TightWarpEnvelopeBox.Min.X <= BoxMinX + && B.TightWarpEnvelopeBox.Min.Y <= BoxMinY + && B.TightWarpEnvelopeBox.Min.Z <= BoxMinZ + && B.TightWarpEnvelopeBox.Max.X >= BoxMaxX + && B.TightWarpEnvelopeBox.Max.Y >= BoxMaxY + && B.TightWarpEnvelopeBox.Max.Z >= BoxMaxZ; + if (bCanReuseTightEnvelope) + { + WarpEnvelope = B.TightWarpEnvelope; + } + else + { + const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; + WarpEnvelope.X = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, VoxelHash::SeedOffset(SeedU, 0.37f), 1.3f, 5.7f); + WarpEnvelope.Y = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, 7.1f, VoxelHash::SeedOffset(SeedU, 0.59f), 2.3f); + WarpEnvelope.Z = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, 11.3f, 9.7f, VoxelHash::SeedOffset(SeedU, 0.41f)); + B.TightWarpEnvelopeBox = VoxelBox; + B.TightWarpEnvelope = WarpEnvelope; + B.bTightWarpEnvelopeValid = true; + } + if (!Finite((float)WarpEnvelope.X) || !Finite((float)WarpEnvelope.Y) + || !Finite((float)WarpEnvelope.Z)) + { + Unknown(); + return; + } + } + // A tile cache is a graph cache, not a proof-result cache. Its search envelope must + // therefore be identical for the root and every block/child that asks about the + // tile. In particular, a finite tight warp envelope is allowed to sharpen the + // interval proof below, but it must not make the first classifier block publish a + // smaller morphology cache for later blocks to reuse. + const float CacheWarpX = bUseTileCacheWindow + ? GlobalWarp : FMath::Max((float)WarpEnvelope.X, 0.0f); + const float CacheWarpY = bUseTileCacheWindow + ? GlobalWarp : FMath::Max((float)WarpEnvelope.Y, 0.0f); + const float CacheWarpZ = bUseTileCacheWindow + ? GlobalWarp : FMath::Max((float)WarpEnvelope.Z, 0.0f); + const float CacheSearchMinX = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.X) + - CacheWarpX + - static_cast(RequestedTileStep) - 2.0f + : BoxMinX - CacheWarpX - 2.0f; + const float CacheSearchMinY = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Y) + - CacheWarpY + - static_cast(RequestedTileStep) - 2.0f + : BoxMinY - CacheWarpY - 2.0f; + const float CacheSearchMaxX = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.X) + RequestedTileExtent + + CacheWarpX + + static_cast(RequestedTileStep) + 2.0f + : BoxMaxX + CacheWarpX + 2.0f; + const float CacheSearchMaxY = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Y) + RequestedTileExtent + + CacheWarpY + + static_cast(RequestedTileStep) + 2.0f + : BoxMaxY + CacheWarpY + 2.0f; + const float CacheSearchMinZ = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Z) + - CacheWarpZ + - static_cast(RequestedTileStep) - 2.0f + : BoxMinZ - CacheWarpZ - 2.0f; + const float CacheSearchMaxZ = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Z) + RequestedTileExtent + + CacheWarpZ + + static_cast(RequestedTileStep) + 2.0f + : BoxMaxZ + CacheWarpZ + 2.0f; + // The search window is a superset of every warped point in the box, with the same + // two-voxel gradient margin used by Eval's cache path. In lattice mode, retain the + // first/root window and reuse it for every refined child. A larger morphology + // window is sound for a child because BuildChunkCache's collect/store contract is + // explicitly window-invariant; it is also the only way to keep refinement from + // turning into one graph build per child box. + const bool bWindowKeyMatches = B.bCacheWindowValid + && B.CacheWindowStrate == StrateIdx + && B.CacheWindowSeed == SeedU + && B.CacheWindowFingerprint == ParamsFingerprint + && B.CacheWindowLayout == LV + && B.CacheWindowManagerLifetimeId == ManagerLifetimeId + && B.CacheWindowTerrainOps == TerrainOps + && B.CacheWindowUsesLatticeProof == Ctx.bUseLatticeProof + && B.CacheWindowUsesTileCacheWindow == bUseTileCacheWindow + && (!bUseTileCacheWindow + || (B.CacheWindowTileOrigin == RequestedTileOrigin + && B.CacheWindowTileStep == RequestedTileStep + && B.CacheWindowTileCells == RequestedTileCells)) + // A non-tight cache is built from the global warp envelope and is therefore a + // superset cache for a later tight retry. The reverse is not safe: a tight cache + // may omit primitives needed by the conservative global-envelope query. + && (!Ctx.bUseLatticeProof + // Tight queries may use either an equally tight cache or the larger global + // envelope cache. A global query must not reuse a tight cache. + || Ctx.bTightenWarpProof + || !B.CacheWindowTightenWarpProof) + // The graph window is independent of which block's lattice origin requested it; + // retain only the step because it controls the conservative finite-envelope gate + // for non-tile proof windows. + && (!Ctx.bUseLatticeProof + || B.CacheWindowLatticeStep == Ctx.Step) + && (!B.bCacheWindowUsesShared || IsSharedCacheCurrent()) + && B.CacheWindowBox.Min.X <= BoxMinX + && B.CacheWindowBox.Min.Y <= BoxMinY + && B.CacheWindowBox.Min.Z <= BoxMinZ + && B.CacheWindowBox.Max.X >= BoxMaxX + && B.CacheWindowBox.Max.Y >= BoxMaxY + && B.CacheWindowBox.Max.Z >= BoxMaxZ; + + if (!bWindowKeyMatches) + { + const uint64 CacheWindowStartCycles = FPlatformTime::Cycles64(); + InvalidateExactLatticeScratch(); + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.CacheWindowSharedRegionMinX = 0; + B.CacheWindowSharedRegionMinY = 0; + B.CacheWindowSharedRegionSize = 0; + B.bCacheWindowUsesShared = false; + B.bTightWarpEnvelopeValid = false; + + // The morphology builder is window-invariant. For the bounded lattice boxes, + // reuse the worker-local graph cache used by Eval, but at a 128/256-voxel region + // granularity. A classifier root otherwise rebuilds the same collect/NN graph + // for every neighbouring tile before the regular density path has a chance to + // populate its cache. The shared window is always built from the global warp + // envelope, so it is a superset of this tight query and can only add conservative + // primitives to the interval. + const bool bFeatureFree = + !VoxelCaveMorphology::MayHaveFeatureInSearchBox( + CacheSearchMinX, + CacheSearchMinY, + CacheSearchMaxX, + CacheSearchMaxY, + P, SeedU, StrateIdx, true, + CacheSearchMinZ, + CacheSearchMaxZ, + TerrainOps); + const int64 MaxSharedChunkSpan = Ctx.Step >= 32 ? 32 : 16; + bool bUsedSharedCache = false; + const bool bTileWithinSharedBudget = bUseTileCacheWindow + && RequestedTileChunkSpan <= 64; + if (!bFeatureFree + && Ctx.bUseLatticeProof + // Fine LOD0/LOD1 requests are latency-sensitive and each worker already has + // a bounded graph cache. Sharing their tiny 64-voxel windows makes otherwise + // sub-millisecond empty proofs wait behind a different worker's build (the + // build itself is cheap). Coarse samples keep the shared window, where its + // larger footprint prevents thousands of per-chunk rebuilds. + && Ctx.Step > 1 + && (bTileWithinSharedBudget + || (SpanX <= MaxSharedChunkSpan + && SpanY <= MaxSharedChunkSpan))) + { + // Coarser LODs retain a shared immutable cache. With the tile policy its key + // is the exact tile footprint; the fallback keeps the historical expanding + // four-chunk working set for point/non-tile callers. + int32 SharedRegionSize = bUseTileCacheWindow + ? static_cast(RequestedTileExtent) + : 4 * CHUNK_SIZE; + int32 SharedRegionMinX = bUseTileCacheWindow + ? RequestedTileOrigin.X : 0; + int32 SharedRegionMinY = bUseTileCacheWindow + ? RequestedTileOrigin.Y : 0; + bool bCoversBox = bUseTileCacheWindow + ? (CacheWindowCoverageBox.Min.X <= BoxMinX + && CacheWindowCoverageBox.Min.Y <= BoxMinY + && CacheWindowCoverageBox.Max.X >= BoxMaxX + && CacheWindowCoverageBox.Max.Y >= BoxMaxY) + : false; + for (int32 Attempt = 0; !bUseTileCacheWindow && Attempt < 6; ++Attempt) + { + SharedRegionMinX = FMath::FloorToInt( + BoxMinX / (float)SharedRegionSize) * SharedRegionSize; + SharedRegionMinY = FMath::FloorToInt( + BoxMinY / (float)SharedRegionSize) * SharedRegionSize; + bCoversBox = BoxMaxX <= (float)(SharedRegionMinX + SharedRegionSize) + && BoxMaxY <= (float)(SharedRegionMinY + SharedRegionSize); + if (bCoversBox) { break; } + SharedRegionSize *= 2; + } + + if (bCoversBox + && (bUseTileCacheWindow + || SharedRegionSize + <= static_cast(MaxSharedChunkSpan) * CHUNK_SIZE)) + { + TSharedPtr SharedCache = + FindOrBuildSharedRoomGraphCache( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, + ManagerLifetimeId, TerrainOps, + SharedRegionMinX, SharedRegionMinY, SharedRegionSize, + bUseTileCacheWindow + ? CacheSearchMinX + : (float)SharedRegionMinX + - (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + bUseTileCacheWindow + ? CacheSearchMinY + : (float)SharedRegionMinY + - (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + bUseTileCacheWindow + ? CacheSearchMaxX + : (float)(SharedRegionMinX + SharedRegionSize) + + (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + bUseTileCacheWindow + ? CacheSearchMaxY + : (float)(SharedRegionMinY + SharedRegionSize) + + (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + P, + ERoomGraphBuildSite::ClassifierShared); + const TSharedPtr + SharedRoomGraphCache = SnapshotSharedRoomGraphCache(SharedCache); + B.ActiveCache = SharedRoomGraphCache.Get(); + B.CacheWindowSharedEntry = SharedCache; + B.CacheWindowSharedRegionMinX = SharedRegionMinX; + B.CacheWindowSharedRegionMinY = SharedRegionMinY; + B.CacheWindowSharedRegionSize = SharedRegionSize; + B.bCacheWindowUsesShared = true; + bUsedSharedCache = true; + } + } + + if (!bUsedSharedCache) + { + if (bFeatureFree) + { + // Keep an empty worker-local cache object as the active + // window. Its arrays retain capacity, but no graph + // build or player-fit bake is paid for this box. + B.Cache.Release(); + } + else + { + VoxelCaveMorphology::BuildChunkCache( + B.Cache, + CacheSearchMinX, + CacheSearchMinY, + CacheSearchMaxX, + CacheSearchMaxY, + P, SeedU, StrateIdx, TerrainOps, + ERoomGraphBuildSite::ClassifierLocal); + } + B.ActiveCache = &B.Cache; + } + + B.CacheWindowBox = CacheWindowCoverageBox; + B.CacheWindowStrate = StrateIdx; + B.CacheWindowSeed = SeedU; + B.CacheWindowFingerprint = ParamsFingerprint; + B.CacheWindowLayout = LV; + B.CacheWindowManagerLifetimeId = ManagerLifetimeId; + B.CacheWindowTerrainOps = TerrainOps; + B.CacheWindowUsesLatticeProof = Ctx.bUseLatticeProof; + B.CacheWindowUsesTileCacheWindow = bUseTileCacheWindow; + B.CacheWindowTileOrigin = bUseTileCacheWindow + ? RequestedTileOrigin : FIntVector::ZeroValue; + B.CacheWindowTileStep = bUseTileCacheWindow ? RequestedTileStep : 0; + B.CacheWindowTileCells = bUseTileCacheWindow ? RequestedTileCells : 0; + B.CacheWindowTightenWarpProof = bUseTileCacheWindow + ? false + : bUsedSharedCache + ? false : Ctx.bTightenWarpProof; + B.CacheWindowLatticeStep = Ctx.Step; + B.bCacheWindowValid = true; + B.CacheWindowCycles = FPlatformTime::Cycles64() - CacheWindowStartCycles; + } + else if (B.bCacheWindowUsesShared) + { + B.ActiveCache = SnapshotSharedRoomGraphCache( + B.CacheWindowSharedEntry).Get(); + } + // Large root boxes are only a cache warm-up. Their interval is intentionally still + // unknown; proof resumes at bounded descendants where all bounds below are finite. + if (SpanX * SpanY * SpanZ > 64) + { + // Keep the freshly built window alive for the descendants. This is deliberately + // different from Unknown(): the root is not invalid, it is merely too large to + // publish a finite interval in one pass. + B.bValid = false; + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; + B.SdfInterval.SetUnknown(); + InOut.SetUnknown(); + return; + } + + const FVector QMin(BoxMinX - (float)WarpEnvelope.X, + BoxMinY - (float)WarpEnvelope.Y, + EffectiveMinZ - (float)WarpEnvelope.Z); + const FVector QMax(BoxMaxX + (float)WarpEnvelope.X, + BoxMaxY + (float)WarpEnvelope.Y, + EffectiveMaxZ + (float)WarpEnvelope.Z); + const FVector RMin(BoxMinX, BoxMinY, BoxMinZ); + const FVector RMax(BoxMaxX, BoxMaxY, BoxMaxZ); + if (!Finite((float)QMin.X) || !Finite((float)QMin.Y) || !Finite((float)QMin.Z) + || !Finite((float)QMax.X) || !Finite((float)QMax.Y) || !Finite((float)QMax.Z)) + { + Unknown(); + return; + } + + // In lattice mode the queried box is a proof domain for the exact MC samples, not + // for every continuous point between them. The room/tunnel bounds below therefore + // use the nearest lattice coordinate in each axis and subtract the same per-axis warp + // envelope as the continuous proof. A sub-box containing no lattice point has no + // samples to constrain and contributes the exact SDF identity. + const bool bUseLatticeProof = Ctx.bUseLatticeProof; + const float LatticeOriginX = static_cast(Ctx.LatticeOriginVoxels.X); + const float LatticeOriginY = static_cast(Ctx.LatticeOriginVoxels.Y); + const float LatticeOriginZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? static_cast(Ctx.LatticeOriginVoxels.Z) / P.VerticalScale + : static_cast(Ctx.LatticeOriginVoxels.Z); + const float LatticeStepXY = static_cast(Ctx.Step); + const float LatticeStepZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? static_cast(Ctx.Step) / P.VerticalScale + : static_cast(Ctx.Step); + if (bUseLatticeProof) + { + auto HasAxisSample = [](float Min, float Max, float Origin, float Step) -> bool + { + return Step > 0.0f + && FMath::CeilToInt((Min - Origin) / Step - 1.0e-4f) + <= FMath::FloorToInt((Max - Origin) / Step + 1.0e-4f); + }; + if (!HasAxisSample(BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY) + || !HasAxisSample(BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY) + || !HasAxisSample(BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ)) + { + InvalidateExactLatticeScratch(); + B.bValid = false; + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.bCacheWindowUsesShared = false; + B.bCacheWindowValid = false; + B.SdfInterval.Set(FLT_MAX, FLT_MAX); + InOut = B.SdfInterval; + return; + } + } + + // A tight retry is a proof over the exact lattice the mesher will read. Keep both + // coordinate spaces: the room graph is queried in warped SDF space, while pits and + // chimneys deliberately use the original world-space sample. The old implementation + // retained only an AABB at LOD0; that still admitted a tunnel when the warped samples + // themselves missed it. The exact primitive minima below remove that false overlap + // without changing the lattice or the density evaluator used by the mesher. + TArray& ExactLatticeWarpedQueries = B.ExactWarpedLatticeScratch; + TArray& ExactLatticeWorldQueries = B.ExactWorldLatticeScratch; + ExactLatticeWarpedQueries.Reset(); + ExactLatticeWorldQueries.Reset(); + FVector ExactLatticeQueryMin(FLT_MAX, FLT_MAX, FLT_MAX); + FVector ExactLatticeQueryMax(-FLT_MAX, -FLT_MAX, -FLT_MAX); + int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0; + int32 ExactNX = 0, ExactNY = 0, ExactNZ = 0; + struct FExactLatticeBlock + { + int32 IX0 = 0, IX1 = -1; + int32 IY0 = 0, IY1 = -1; + int32 IZ0 = 0, IZ1 = -1; + FVector WarpedMin = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector WarpedMax = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + FVector WorldMin = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector WorldMax = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + }; + TArray> ExactLatticeBlocks; + const float MaxBoxExtent = FMath::Max3( + BoxMaxX - BoxMinX, BoxMaxY - BoxMinY, BoxMaxZ - BoxMinZ); + const float ExactChildExtent = 16.0f * (float)FMath::Max(Ctx.Step, 1); + const bool bTryExactLatticeWarp = bUseLatticeProof && Ctx.bTightenWarpProof + // The root is a cache warm-up. Exact warped lattice distances are only needed + // once the refinement has reduced the domain to a child; this avoids rebuilding + // a 33^3 query list before the eight 17^3 child proofs that actually decide the + // residual solid tile. A non-exact root remains conservative and cannot create a + // skip on its own. + // Include a box exactly one coarse child wide. This lets a depth-one LOD0 node + // use the exact warped lattice certificate before it fans out into eight smaller + // proofs; the certificate remains conservative because it samples only the same + // lattice points the mesher reads. + && MaxBoxExtent <= ExactChildExtent; + if (bTryExactLatticeWarp) + { + const int32 IX0 = FMath::CeilToInt( + (BoxMinX - LatticeOriginX) / LatticeStepXY - 1.0e-4f); + const int32 IY0 = FMath::CeilToInt( + (BoxMinY - LatticeOriginY) / LatticeStepXY - 1.0e-4f); + const int32 IZ0 = FMath::CeilToInt( + (BoxMinZ - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) - 1.0e-4f); + const int32 IX1 = FMath::FloorToInt( + (BoxMaxX - LatticeOriginX) / LatticeStepXY + 1.0e-4f); + const int32 IY1 = FMath::FloorToInt( + (BoxMaxY - LatticeOriginY) / LatticeStepXY + 1.0e-4f); + const int32 IZ1 = FMath::FloorToInt( + (BoxMaxZ - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) + 1.0e-4f); + const int64 Count = ((int64)IX1 - IX0 + 1) + * ((int64)IY1 - IY0 + 1) + * ((int64)IZ1 - IZ0 + 1); + if (IX1 >= IX0 && IY1 >= IY0 && IZ1 >= IZ0 + && Count > 0 && Count <= 100000) + { + ExactIX0 = IX0; + ExactIY0 = IY0; + ExactIZ0 = IZ0; + ExactNX = IX1 - IX0 + 1; + ExactNY = IY1 - IY0 + 1; + ExactNZ = IZ1 - IZ0 + 1; + ExactLatticeWarpedQueries.Reserve(static_cast(Count)); + ExactLatticeWorldQueries.Reserve(static_cast(Count)); + const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; + auto MakeWarpedQuery = [&](int32 QueryIX, int32 QueryIY, + int32 QueryIZ) -> FVector + { + const float WorldX = LatticeOriginX + (float)QueryIX * LatticeStepXY; + const float WorldY = LatticeOriginY + (float)QueryIY * LatticeStepXY; + const float WorldZ = static_cast(Ctx.LatticeOriginVoxels.Z) + + (float)QueryIZ * (float)Ctx.Step; + const float EffectiveZ = (P.VerticalScale > 0.0f + && P.VerticalScale != 1.0f) + ? WorldZ / P.VerticalScale : WorldZ; + FVector Query(WorldX, WorldY, EffectiveZ); + if (!VoxelDensityAblation::IsCaveWarpOff() && P.CaveWarpStrength > 0.0f) + { + const float Frequency = P.CaveWarpFrequency; + float WarpX, WarpY, WarpZ; + VoxelNoise::Perlin3D_x3( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 0.37f), + WorldY * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f, + WorldX * Frequency + 7.1f, + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 0.59f), + EffectiveZ * Frequency + 2.3f, + WorldX * Frequency + 11.3f, + WorldY * Frequency + 9.7f, + EffectiveZ * Frequency + VoxelHash::SeedOffset(SeedU, 0.41f), + WarpX, WarpY, WarpZ); + Query.X += WarpX * WarpAmplitude; + Query.Y += WarpY * WarpAmplitude; + Query.Z += WarpZ * WarpAmplitude; + } + return Query; + }; + + // All exact child boxes are contained in the classifier root held by the + // window cache. Cache only the deterministic warp samples, not any interval or + // verdict, so a cache miss can only cost work. The 100000-sample cap bounds + // this worker-local scratch allocation even for an unusually large root. + bool bCanUseWarpedQueryCache = false; + int32 CacheIX0 = 0; + int32 CacheIY0 = 0; + int32 CacheIZ0 = 0; + int32 CacheNX = 0; + int32 CacheNY = 0; + int32 CacheNZ = 0; + if (B.bCacheWindowValid + && Finite((float)B.CacheWindowBox.Min.X) + && Finite((float)B.CacheWindowBox.Min.Y) + && Finite((float)B.CacheWindowBox.Min.Z) + && Finite((float)B.CacheWindowBox.Max.X) + && Finite((float)B.CacheWindowBox.Max.Y) + && Finite((float)B.CacheWindowBox.Max.Z) + && B.CacheWindowBox.Min.X <= B.CacheWindowBox.Max.X + && B.CacheWindowBox.Min.Y <= B.CacheWindowBox.Max.Y + && B.CacheWindowBox.Min.Z <= B.CacheWindowBox.Max.Z) + { + CacheIX0 = FMath::CeilToInt( + ((float)B.CacheWindowBox.Min.X - LatticeOriginX) + / LatticeStepXY - 1.0e-4f); + CacheIY0 = FMath::CeilToInt( + ((float)B.CacheWindowBox.Min.Y - LatticeOriginY) + / LatticeStepXY - 1.0e-4f); + CacheIZ0 = FMath::CeilToInt( + ((float)B.CacheWindowBox.Min.Z + - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) - 1.0e-4f); + const int32 CacheIX1 = FMath::FloorToInt( + ((float)B.CacheWindowBox.Max.X - LatticeOriginX) + / LatticeStepXY + 1.0e-4f); + const int32 CacheIY1 = FMath::FloorToInt( + ((float)B.CacheWindowBox.Max.Y - LatticeOriginY) + / LatticeStepXY + 1.0e-4f); + const int32 CacheIZ1 = FMath::FloorToInt( + ((float)B.CacheWindowBox.Max.Z + - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) + 1.0e-4f); + const int64 CacheCount = ((int64)CacheIX1 - CacheIX0 + 1) + * ((int64)CacheIY1 - CacheIY0 + 1) + * ((int64)CacheIZ1 - CacheIZ0 + 1); + if (CacheIX1 >= CacheIX0 && CacheIY1 >= CacheIY0 && CacheIZ1 >= CacheIZ0 + && CacheCount > 0 && CacheCount <= 100000 + && IX0 >= CacheIX0 && IY0 >= CacheIY0 && IZ0 >= CacheIZ0 + && IX1 <= CacheIX1 && IY1 <= CacheIY1 && IZ1 <= CacheIZ1) + { + CacheNX = CacheIX1 - CacheIX0 + 1; + CacheNY = CacheIY1 - CacheIY0 + 1; + CacheNZ = CacheIZ1 - CacheIZ0 + 1; + const bool bCacheKeyMatches = B.bExactWarpedLatticeCacheValid + && B.ExactWarpedLatticeCacheBox == B.CacheWindowBox + && B.ExactWarpedLatticeOrigin == Ctx.LatticeOriginVoxels + && B.ExactWarpedLatticeStep == Ctx.Step + && B.ExactWarpedLatticeSeed == SeedU + && B.ExactWarpedLatticeFingerprint == ParamsFingerprint + && B.ExactWarpedLatticeLayout == LV + && B.ExactWarpedLatticeManagerLifetimeId == ManagerLifetimeId + && B.ExactWarpedLatticeIX0 == CacheIX0 + && B.ExactWarpedLatticeIY0 == CacheIY0 + && B.ExactWarpedLatticeIZ0 == CacheIZ0 + && B.ExactWarpedLatticeNX == CacheNX + && B.ExactWarpedLatticeNY == CacheNY + && B.ExactWarpedLatticeNZ == CacheNZ + && B.ExactWarpedLatticeQueries.Num() == CacheCount; + if (!bCacheKeyMatches) + { + B.ExactWarpedLatticeQueries.Reset(); + B.ExactWarpedLatticeQueries.SetNumUninitialized( + static_cast(CacheCount)); + B.ExactTailStates.Init(0, static_cast(CacheCount)); + for (int32 CacheIZ = CacheIZ0; CacheIZ <= CacheIZ1; ++CacheIZ) + for (int32 CacheIY = CacheIY0; CacheIY <= CacheIY1; ++CacheIY) + for (int32 CacheIX = CacheIX0; CacheIX <= CacheIX1; ++CacheIX) + { + const int32 FlatIndex = + ((CacheIZ - CacheIZ0) * CacheNY + + (CacheIY - CacheIY0)) * CacheNX + + (CacheIX - CacheIX0); + B.ExactWarpedLatticeQueries[FlatIndex] = + MakeWarpedQuery(CacheIX, CacheIY, CacheIZ); + } + B.ExactWarpedLatticeCacheBox = B.CacheWindowBox; + B.ExactWarpedLatticeIX0 = CacheIX0; + B.ExactWarpedLatticeIY0 = CacheIY0; + B.ExactWarpedLatticeIZ0 = CacheIZ0; + B.ExactWarpedLatticeNX = CacheNX; + B.ExactWarpedLatticeNY = CacheNY; + B.ExactWarpedLatticeNZ = CacheNZ; + B.ExactWarpedLatticeOrigin = Ctx.LatticeOriginVoxels; + B.ExactWarpedLatticeStep = Ctx.Step; + B.ExactWarpedLatticeSeed = SeedU; + B.ExactWarpedLatticeFingerprint = ParamsFingerprint; + B.ExactWarpedLatticeLayout = LV; + B.ExactWarpedLatticeManagerLifetimeId = ManagerLifetimeId; + B.bExactWarpedLatticeCacheValid = true; + } + else if (B.ExactTailStates.Num() != static_cast(CacheCount)) + { + B.ExactTailStates.Init(0, static_cast(CacheCount)); + } + bCanUseWarpedQueryCache = B.bExactWarpedLatticeCacheValid; + } + } + + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float WorldX = LatticeOriginX + (float)IX * LatticeStepXY; + const float WorldY = LatticeOriginY + (float)IY * LatticeStepXY; + const float WorldZ = static_cast(Ctx.LatticeOriginVoxels.Z) + + (float)IZ * (float)Ctx.Step; + ExactLatticeWorldQueries.Add(FVector(WorldX, WorldY, WorldZ)); + if (bCanUseWarpedQueryCache) + { + const int32 FlatIndex = + ((IZ - CacheIZ0) * CacheNY + (IY - CacheIY0)) * CacheNX + + (IX - CacheIX0); + ExactLatticeWarpedQueries.Add( + B.ExactWarpedLatticeQueries[FlatIndex]); + } + else + { + ExactLatticeWarpedQueries.Add(MakeWarpedQuery(IX, IY, IZ)); + } + const FVector& Query = ExactLatticeWarpedQueries.Last(); + ExactLatticeQueryMin.X = FMath::Min(ExactLatticeQueryMin.X, (float)Query.X); + ExactLatticeQueryMin.Y = FMath::Min(ExactLatticeQueryMin.Y, (float)Query.Y); + ExactLatticeQueryMin.Z = FMath::Min(ExactLatticeQueryMin.Z, (float)Query.Z); + ExactLatticeQueryMax.X = FMath::Max(ExactLatticeQueryMax.X, (float)Query.X); + ExactLatticeQueryMax.Y = FMath::Max(ExactLatticeQueryMax.Y, (float)Query.Y); + ExactLatticeQueryMax.Z = FMath::Max(ExactLatticeQueryMax.Z, (float)Query.Z); + } + + // One envelope contains every warped lattice point in this refined child. + // The lower-bound helpers below are valid over any enclosing AABB, so using + // one envelope removes the repeated overlapping block traversal that made a + // single LOD0 classifier visit the same primitive geometry dozens of times. + // It is deliberately looser than the old sub-block decomposition: a looser + // lower bound can only retain Mixed and send work to the mesher; it cannot + // authorize a false empty-tile skip. The exact lattice itself is unchanged. + FExactLatticeBlock& Block = ExactLatticeBlocks.AddDefaulted_GetRef(); + Block.IX0 = ExactIX0; + Block.IX1 = IX1; + Block.IY0 = ExactIY0; + Block.IY1 = IY1; + Block.IZ0 = ExactIZ0; + Block.IZ1 = IZ1; + Block.WarpedMin = ExactLatticeQueryMin; + Block.WarpedMax = ExactLatticeQueryMax; + Block.WorldMin = FVector( + LatticeOriginX + (float)ExactIX0 * LatticeStepXY, + LatticeOriginY + (float)ExactIY0 * LatticeStepXY, + (float)Ctx.LatticeOriginVoxels.Z + + (float)ExactIZ0 * (float)Ctx.Step); + Block.WorldMax = FVector( + LatticeOriginX + (float)IX1 * LatticeStepXY, + LatticeOriginY + (float)IY1 * LatticeStepXY, + (float)Ctx.LatticeOriginVoxels.Z + + (float)IZ1 * (float)Ctx.Step); + } + } + + const bool bUseExactLatticeWarp = ExactLatticeWarpedQueries.Num() > 0; + const bool bUseExactLatticeBox = !bUseExactLatticeWarp + && ExactLatticeQueryMin.X <= ExactLatticeQueryMax.X + && ExactLatticeQueryMin.Y <= ExactLatticeQueryMax.Y + && ExactLatticeQueryMin.Z <= ExactLatticeQueryMax.Z; + bool bInvalidBound = false; + float ExactSdfLower = FLT_MAX; + float CheapSdfLower = FLT_MAX; + int32 ExactRoomRefinements = 0; + int32 ExactJoinRefinements = 0; + int32 ExactTunnelRefinements = 0; + int32 ExactPitRefinements = 0; + int32 ExactChimneyRefinements = 0; + + auto ExactLatticePointDistance = [&](const FVector& Target) -> float + { + float Distance = FLT_MAX; + // The block AABB contains every warped lattice point in the block. Its distance + // to the target is therefore a conservative lower bound for the exact point + // minimum. Do not rescan all 17^3 points for every room just to obtain this + // broad-phase value; the same 2x2x2 block lattice is already built below for the + // exact primitive walk. + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + Distance = FMath::Min( + Distance, + VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, Target, Target)); + } + return FMath::Max(0.0f, Distance - 0.001f); + }; + + auto ExactLatticeBoxDistance = [&](const FVector& TargetMin, + const FVector& TargetMax) -> float + { + float Distance = FLT_MAX; + // As above, the block AABB distance is no larger than the distance from the + // contained exact points to the target interval. It can lose a skip, but can + // never turn an unproven box into a proof. + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + Distance = FMath::Min( + Distance, + VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, TargetMin, TargetMax)); + } + return FMath::Max(0.0f, Distance - 0.001f); + }; + + auto ForEachExactQueryInWorldBox = [&](const FVector& WorldMin, + const FVector& WorldMax, + auto&& Callback) + { + if (ExactNX <= 0 || ExactNY <= 0 || ExactNZ <= 0) { return; } + const int32 IX0 = FMath::Max( + ExactIX0, + FMath::CeilToInt( + (WorldMin.X - LatticeOriginX) / LatticeStepXY - 1.0e-4f)); + const int32 IY0 = FMath::Max( + ExactIY0, + FMath::CeilToInt( + (WorldMin.Y - LatticeOriginY) / LatticeStepXY - 1.0e-4f)); + const int32 IZ0 = FMath::Max( + ExactIZ0, + FMath::CeilToInt( + (WorldMin.Z - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) - 1.0e-4f)); + const int32 IX1 = FMath::Min( + ExactIX0 + ExactNX - 1, + FMath::FloorToInt( + (WorldMax.X - LatticeOriginX) / LatticeStepXY + 1.0e-4f)); + const int32 IY1 = FMath::Min( + ExactIY0 + ExactNY - 1, + FMath::FloorToInt( + (WorldMax.Y - LatticeOriginY) / LatticeStepXY + 1.0e-4f)); + const int32 IZ1 = FMath::Min( + ExactIZ0 + ExactNZ - 1, + FMath::FloorToInt( + (WorldMax.Z - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) + 1.0e-4f)); + if (IX1 < IX0 || IY1 < IY0 || IZ1 < IZ0) { return; } + + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const int32 FlatIndex = + ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX + + (IX - ExactIX0); + Callback( + ExactLatticeWarpedQueries[FlatIndex], + ExactLatticeWorldQueries[FlatIndex]); + } + }; + + auto DistanceBlockToInfiniteLineLower = [&](const FExactLatticeBlock& Block, + const FVector& A, + const FVector& BPoint) -> float + { + const FVector Direction = BPoint - A; + const float LengthSq = Direction.SizeSquared(); + if (!(LengthSq > KINDA_SMALL_NUMBER)) + { + return VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, A, A); + } + + auto LinearRange = [](float Coefficient, float MinValue, float MaxValue, + float Offset, float& OutMin, float& OutMax) + { + const float AValue = Coefficient * (MinValue - Offset); + const float BValue = Coefficient * (MaxValue - Offset); + OutMin = FMath::Min(AValue, BValue); + OutMax = FMath::Max(AValue, BValue); + }; + auto DistanceToZero = [](float MinValue, float MaxValue) -> float + { + return MinValue > 0.0f ? MinValue + : MaxValue < 0.0f ? -MaxValue : 0.0f; + }; + + float XYMin = 0.0f, XYMax = 0.0f; + float ZYMin = 0.0f, ZYMax = 0.0f; + float XZMin = 0.0f, XZMax = 0.0f; + float YXMin = 0.0f, YXMax = 0.0f; + float ZXMin = 0.0f, ZXMax = 0.0f; + float ZY2Min = 0.0f, ZY2Max = 0.0f; + LinearRange(Direction.Y, Block.WarpedMin.Z, Block.WarpedMax.Z, + (float)A.Z, ZYMin, ZYMax); + LinearRange(Direction.Z, Block.WarpedMin.Y, Block.WarpedMax.Y, + (float)A.Y, YXMin, YXMax); + LinearRange(Direction.Z, Block.WarpedMin.X, Block.WarpedMax.X, + (float)A.X, XZMin, XZMax); + LinearRange(Direction.X, Block.WarpedMin.Z, Block.WarpedMax.Z, + (float)A.Z, ZXMin, ZXMax); + LinearRange(Direction.X, Block.WarpedMin.Y, Block.WarpedMax.Y, + (float)A.Y, XYMin, XYMax); + LinearRange(Direction.Y, Block.WarpedMin.X, Block.WarpedMax.X, + (float)A.X, ZY2Min, ZY2Max); + + const float CrossXMin = ZYMin - YXMax; + const float CrossXMax = ZYMax - YXMin; + const float CrossYMin = XZMin - ZXMax; + const float CrossYMax = XZMax - ZXMin; + const float CrossZMin = XYMin - ZY2Max; + const float CrossZMax = XYMax - ZY2Min; + const float DX = DistanceToZero(CrossXMin, CrossXMax); + const float DY = DistanceToZero(CrossYMin, CrossYMax); + const float DZ = DistanceToZero(CrossZMin, CrossZMax); + return FMath::Sqrt((DX * DX + DY * DY + DZ * DZ) / LengthSq); + }; + + auto LatticePointDistance = [&](const FVector& Target, const FVector& Extra) -> float + { + if (bUseExactLatticeWarp) + { + return ExactLatticePointDistance(Target); + } + if (bUseExactLatticeBox) + { + return VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, Target, Target); + } + const float DX = VF_LatticeAxisDistanceToPoint( + BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY, + static_cast(Target.X), (float)Extra.X); + const float DY = VF_LatticeAxisDistanceToPoint( + BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY, + static_cast(Target.Y), (float)Extra.Y); + const float DZ = VF_LatticeAxisDistanceToPoint( + BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ, + static_cast(Target.Z), (float)Extra.Z); + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + }; + + auto LatticeBoxDistance = [&](const FVector& TargetMin, + const FVector& TargetMax, + const FVector& Extra) -> float + { + if (bUseExactLatticeWarp) + { + return ExactLatticeBoxDistance(TargetMin, TargetMax); + } + if (bUseExactLatticeBox) + { + return VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, TargetMin, TargetMax); + } + const float DX = VF_LatticeAxisDistanceToInterval( + BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY, + static_cast(TargetMin.X), static_cast(TargetMax.X), + (float)Extra.X); + const float DY = VF_LatticeAxisDistanceToInterval( + BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY, + static_cast(TargetMin.Y), static_cast(TargetMax.Y), + (float)Extra.Y); + const float DZ = VF_LatticeAxisDistanceToInterval( + BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ, + static_cast(TargetMin.Z), static_cast(TargetMax.Z), + (float)Extra.Z); + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + }; + + // The exact warped lattice list is also an exact AABB superset. Use that cheap + // distance as a first test and pay the primitive-by-query walk only when this bound is + // close enough to affect the source's active threshold. A far primitive can only + // raise the lower bound; retaining its AABB bound is conservative and avoids scanning + // 729 queries for every cached room/tunnel in every refined child. + auto ExactLatticeAabbPointDistance = [&](const FVector& Target) -> float + { + return bUseExactLatticeWarp + && ExactLatticeQueryMin.X <= ExactLatticeQueryMax.X + && ExactLatticeQueryMin.Y <= ExactLatticeQueryMax.Y + && ExactLatticeQueryMin.Z <= ExactLatticeQueryMax.Z + ? VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, Target, Target) + : FLT_MAX; + }; + auto ExactLatticeAabbBoxDistance = [&](const FVector& TargetMin, + const FVector& TargetMax) -> float + { + return bUseExactLatticeWarp + && ExactLatticeQueryMin.X <= ExactLatticeQueryMax.X + && ExactLatticeQueryMin.Y <= ExactLatticeQueryMax.Y + && ExactLatticeQueryMin.Z <= ExactLatticeQueryMax.Z + ? VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, TargetMin, TargetMax) + : FLT_MAX; + }; + + // Conservative lower bounds for primitive values on each warped lattice block. + // Earlier versions walked every exact point in every refined child here. That was + // sound, but it made an empty LOD0 tile pay thousands of room/tunnel evaluations. + // These bounds are over the whole block AABB, which contains every warped lattice + // point in the block. They are therefore allowed to be looser than the finite-point + // minimum: looser costs a proof, never geometry. The smooth-min fold below remains + // monotone in every primitive lower bound. + auto ExactRoomShapeLower = [&](const FCachedRoom& Room, + const FExactLatticeBlock& Block) -> float + { + auto Invalid = [&]() -> float + { + bInvalidBound = true; + return 0.0f; + }; + const FVector BlockMin = Block.WarpedMin; + const FVector BlockMax = Block.WarpedMax; + if (!Finite((float)BlockMin.X) || !Finite((float)BlockMin.Y) + || !Finite((float)BlockMin.Z) || !Finite((float)BlockMax.X) + || !Finite((float)BlockMax.Y) || !Finite((float)BlockMax.Z)) + { + return Invalid(); + } + + switch (Room.ShapeType) + { + case 0: + { + // VoxelSDF::Ellipsoid uses the scaled Euclidean distance multiplied by + // the average radius. The closest scaled point to an axis-aligned box is + // obtained independently on each axis, so this is a direct lower bound for + // the exact approximation used by Eval. + const FVector& Radii = Room.ShapeA; + if (!Finite((float)Radii.X) || !Finite((float)Radii.Y) + || !Finite((float)Radii.Z) || Radii.X <= 0.0 + || Radii.Y <= 0.0 || Radii.Z <= 0.0) + { + return Invalid(); + } + const float Cx = (float)Room.Center.X; + const float Cy = (float)Room.Center.Y; + const float Cz = (float)Room.Center.Z; + const float Rx = (float)Radii.X; + const float Ry = (float)Radii.Y; + const float Rz = (float)Radii.Z; + const float DX = BlockMin.X > Cx ? BlockMin.X - Cx + : BlockMax.X < Cx ? Cx - BlockMax.X : 0.0f; + const float DY = BlockMin.Y > Cy ? BlockMin.Y - Cy + : BlockMax.Y < Cy ? Cy - BlockMax.Y : 0.0f; + const float DZ = BlockMin.Z > Cz ? BlockMin.Z - Cz + : BlockMax.Z < Cz ? Cz - BlockMax.Z : 0.0f; + const float AverageRadius = (Rx + Ry + Rz) / 3.0f; + const float ScaledDistance = FMath::Sqrt( + (DX / Rx) * (DX / Rx) + + (DY / Ry) * (DY / Ry) + + (DZ / Rz) * (DZ / Rz)); + const float Lower = (ScaledDistance - 1.0f) * AverageRadius; + return Finite(Lower) ? Lower : Invalid(); + } + case 1: + { + // The rounded-box SDF is the exact SDF of a box dilated by a sphere. The + // enclosing sphere gives a cheap, unconditional lower bound for its signed + // distance, including points inside the box. + const FVector& HalfExtent = Room.ShapeA; + const float Rounding = (float)Room.ShapeR; + if (!Finite((float)HalfExtent.X) || !Finite((float)HalfExtent.Y) + || !Finite((float)HalfExtent.Z) || !Finite(Rounding) + || HalfExtent.X < 0.0 || HalfExtent.Y < 0.0 + || HalfExtent.Z < 0.0 || Rounding < 0.0f) + { + return Invalid(); + } + const FVector OuterExtent( + (float)HalfExtent.X + Rounding, + (float)HalfExtent.Y + Rounding, + (float)HalfExtent.Z + Rounding); + const float Lower = VF_DistanceBetweenBoxes( + BlockMin, BlockMax, Room.Center, Room.Center) - OuterExtent.Size(); + return Finite(Lower) ? Lower : Invalid(); + } + case 2: + { + if (!Finite((float)Room.ShapeA.X) || !Finite((float)Room.ShapeA.Y) + || !Finite((float)Room.ShapeA.Z) || !Finite((float)Room.ShapeB.X) + || !Finite((float)Room.ShapeB.Y) || !Finite((float)Room.ShapeB.Z) + || !Finite((float)Room.ShapeR) || Room.ShapeR < 0.0f) + { + return Invalid(); + } + const float Lower = DistanceBlockToInfiniteLineLower( + Block, Room.ShapeA, Room.ShapeB) - FMath::Abs((float)Room.ShapeR); + return Finite(Lower) ? Lower : Invalid(); + } + default: + return Invalid(); + } + }; + + auto ExactRoomLower = [&](const FCachedRoom& Room) -> float + { + float LowerBound = FLT_MAX; + const float CullRadiusSq = Room.CullRadiusSq; + if (!Finite(CullRadiusSq) || CullRadiusSq < 0.0f) + { + bInvalidBound = true; + return LowerBound; + } + const float CullRadius = FMath::Sqrt(CullRadiusSq); + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + const float BlockDistance = VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Room.Center, Room.Center); + if (!Finite(BlockDistance)) + { + bInvalidBound = true; + continue; + } + // Every point in this block fails Eval's sphere cull when the block itself + // is farther than the cull radius. This reject is exact for that cull. + if (BlockDistance > CullRadius) + { + continue; + } + + float RoomLower = ExactRoomShapeLower(Room, Block); + if (bInvalidBound) { continue; } + if (Room.FloorCutZ > -FLT_MAX) + { + if (!Finite((float)Room.FloorCutZ) + || !Finite((float)Room.FloorReliefStrength) + || (Room.FloorReliefStrength > 0.0f + && !Finite((float)Room.FloorReliefFrequency))) + { + bInvalidBound = true; + continue; + } + const float ReliefBound = Room.FloorReliefStrength > 0.0f + ? FMath::Abs((float)Room.FloorReliefStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + : 0.0f; + // SmoothMax(a,b) is never below max(a,b), so independently bounded + // shape and floor terms can be combined with an ordinary Max. + const float FloorLower = (float)Room.FloorCutZ - ReliefBound + - (float)Block.WarpedMax.Z; + RoomLower = FMath::Max(RoomLower, FloorLower); + } + LowerBound = FMath::Min(LowerBound, RoomLower); + } + return LowerBound; + }; + + auto ExactJoinLower = [&](const FCachedRoomFloorJoin& Join) -> float + { + float LowerBound = FLT_MAX; + if (!Finite((float)Join.BoundRadiusSq) || Join.BoundRadiusSq < 0.0f + || !Finite((float)Join.BoundCenter.X) + || !Finite((float)Join.BoundCenter.Y) + || !Finite((float)Join.BoundCenter.Z) + || !Finite((float)Join.Start.X) || !Finite((float)Join.Start.Y) + || !Finite((float)Join.Start.Z) || !Finite((float)Join.End.X) + || !Finite((float)Join.End.Y) || !Finite((float)Join.End.Z) + || !Finite((float)Join.Radius) || !Finite((float)Join.FloorZ) + || !Finite((float)Join.CeilingZ) || Join.Radius < 0.0f) + { + bInvalidBound = true; + return LowerBound; + } + const float BoundRadius = FMath::Sqrt(Join.BoundRadiusSq); + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + const float BlockDistance = VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Join.BoundCenter, Join.BoundCenter); + if (BlockDistance > BoundRadius) + { + continue; + } + const float HorizontalLower = DistanceBlockToInfiniteLineLower( + Block, Join.Start, Join.End) - FMath::Abs(Join.Radius); + const float VerticalLower = FMath::Max( + Join.FloorZ - (float)Block.WarpedMax.Z, + (float)Block.WarpedMin.Z - Join.CeilingZ); + const float Lower = FMath::Max(HorizontalLower, VerticalLower); + if (!Finite(Lower)) + { + bInvalidBound = true; + continue; + } + LowerBound = FMath::Min(LowerBound, Lower); + } + return LowerBound; + }; + + auto ExactTunnelLower = [&](const FCachedTunnel& Tunnel) -> float + { + float LowerBound = FLT_MAX; + if (!Finite((float)Tunnel.BoundRadiusSq) + || Tunnel.BoundRadiusSq < 0.0f + || !Finite((float)Tunnel.BoundCenter.X) + || !Finite((float)Tunnel.BoundCenter.Y) + || !Finite((float)Tunnel.BoundCenter.Z) + || !Finite((float)Tunnel.SDFInfluenceRadius) + || !Finite((float)Tunnel.SDFCenterlineMin.X) + || !Finite((float)Tunnel.SDFCenterlineMin.Y) + || !Finite((float)Tunnel.SDFCenterlineMin.Z) + || !Finite((float)Tunnel.SDFCenterlineMax.X) + || !Finite((float)Tunnel.SDFCenterlineMax.Y) + || !Finite((float)Tunnel.SDFCenterlineMax.Z) + || Tunnel.SDFCenterlineMin.X > Tunnel.SDFCenterlineMax.X + || Tunnel.SDFCenterlineMin.Y > Tunnel.SDFCenterlineMax.Y + || Tunnel.SDFCenterlineMin.Z > Tunnel.SDFCenterlineMax.Z) + { + bInvalidBound = true; + return LowerBound; + } + const float BoundRadius = FMath::Sqrt(Tunnel.BoundRadiusSq); + auto EvaluateSegment = [&](const FVector& A, const FVector& BPoint, + float RadiusA, float RadiusB) + { + if (!Finite((float)A.X) || !Finite((float)A.Y) + || !Finite((float)A.Z) || !Finite((float)BPoint.X) + || !Finite((float)BPoint.Y) || !Finite((float)BPoint.Z) + || !Finite(RadiusA) || !Finite(RadiusB)) + { + bInvalidBound = true; + return; + } + const float SegmentRadius = FMath::Max( + FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + const FVector SegmentMin( + FMath::Min(A.X, BPoint.X), FMath::Min(A.Y, BPoint.Y), + FMath::Min(A.Z, BPoint.Z)); + const FVector SegmentMax( + FMath::Max(A.X, BPoint.X), FMath::Max(A.Y, BPoint.Y), + FMath::Max(A.Z, BPoint.Z)); + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + if (Tunnel.SDFInfluenceRadius > 0.0f + && VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) + > Tunnel.SDFInfluenceRadius) + { + continue; + } + if (VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Tunnel.BoundCenter, Tunnel.BoundCenter) + > BoundRadius) + { + continue; + } + + // Both terms are lower bounds for distance to the finite segment: + // the first uses the infinite supporting line, while the second uses + // the segment's AABB. Their maximum is still a lower bound and is much + // tighter near a segment endpoint than either term alone. + const float LineLower = DistanceBlockToInfiniteLineLower( + Block, A, BPoint); + const float SegmentBoxLower = VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, SegmentMin, SegmentMax); + const float Lower = FMath::Max(LineLower, SegmentBoxLower) + - SegmentRadius; + if (!Finite(Lower)) + { + bInvalidBound = true; + continue; + } + LowerBound = FMath::Min(LowerBound, Lower); + } + }; + + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + EvaluateSegment( + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1]); + } + } + else if (Tunnel.bHasMidpoint) + { + EvaluateSegment(Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid); + EvaluateSegment(Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB); + } + else + { + EvaluateSegment(Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB); + } + return LowerBound; + }; + + auto ExactPitLower = [&](const FCachedPit& Pit) -> float + { + float LowerBound = FLT_MAX; + const float Radius = FMath::Sqrt(FMath::Max(Pit.BoundXYRadiusSq, 0.0f)); + const FVector CandidateMin( + Pit.CenterX - Radius, Pit.CenterY - Radius, + Pit.TopZ - Pit.Depth - Pit.BlendK); + const FVector CandidateMax( + Pit.CenterX + Radius, Pit.CenterY + Radius, + Pit.TopZ + Pit.BlendK); + ForEachExactQueryInWorldBox( + CandidateMin, CandidateMax, + [&](const FVector&, const FVector& Query) + { + const float DZ = (float)Query.Z - Pit.TopZ; + if (DZ >= Pit.BlendK || -DZ > Pit.Depth + Pit.BlendK) + { + return; + } + const float DX = (float)Query.X - Pit.CenterX; + const float DY = (float)Query.Y - Pit.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) { return; } + float PitSDF; + if (DZ <= 0.0f) + { + float FlareFactor = FMath::Clamp( + 1.0f - (-DZ) / Pit.FlareDist, 0.0f, 1.0f); + FlareFactor *= FlareFactor; + PitSDF = FMath::Sqrt(XYDistSq) + - (Pit.Radius + Pit.FlareExtra * FlareFactor); + } + else + { + PitSDF = FMath::Sqrt(XYDistSq) + - (Pit.Radius + Pit.FlareExtra); + } + LowerBound = FMath::Min(LowerBound, PitSDF); + }); + return LowerBound; + }; + + auto ExactChimneyLower = [&](const FCachedChimney& Chimney) -> float + { + float LowerBound = FLT_MAX; + const float Radius = FMath::Sqrt(FMath::Max(Chimney.BoundXYRadiusSq, 0.0f)); + const FVector CandidateMin( + Chimney.CenterX - Radius, Chimney.CenterY - Radius, + Chimney.BottomZ - Chimney.BlendK); + const FVector CandidateMax( + Chimney.CenterX + Radius, Chimney.CenterY + Radius, + Chimney.BottomZ + Chimney.Height + Chimney.BlendK); + ForEachExactQueryInWorldBox( + CandidateMin, CandidateMax, + [&](const FVector&, const FVector& Query) + { + const float DZ = (float)Query.Z - Chimney.BottomZ; + if (-DZ >= Chimney.BlendK || DZ > Chimney.Height + Chimney.BlendK) + { + return; + } + const float DX = (float)Query.X - Chimney.CenterX; + const float DY = (float)Query.Y - Chimney.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chimney.BoundXYRadiusSq) { return; } + float ChimneySDF; + if (DZ >= 0.0f) + { + float FlareFactor = FMath::Clamp( + 1.0f - DZ / Chimney.FlareDist, 0.0f, 1.0f); + FlareFactor *= FlareFactor; + ChimneySDF = FMath::Sqrt(XYDistSq) + - (Chimney.Radius + Chimney.FlareExtra * FlareFactor); + } + else + { + ChimneySDF = FMath::Sqrt(XYDistSq) + - (Chimney.Radius + Chimney.FlareExtra); + } + LowerBound = FMath::Min(LowerBound, ChimneySDF); + }); + return LowerBound; + }; + + auto ConsiderExact = [&](float PrimitiveLower, float BlendRadius) + { + if (!Finite(PrimitiveLower) || !Finite(BlendRadius)) + { + bInvalidBound = true; + return; + } + ExactSdfLower = VoxelSDF::SmoothMin( + ExactSdfLower, PrimitiveLower, BlendRadius); + }; + auto ConsiderCheapExact = [&](float PrimitiveLower, float BlendRadius) + { + if (Finite(PrimitiveLower) && Finite(BlendRadius)) + { + CheapSdfLower = VoxelSDF::SmoothMin( + CheapSdfLower, PrimitiveLower, BlendRadius); + } + }; + + auto LatticeXYPointDistance = [&](float X, float Y) -> float + { + const float DX = VF_LatticeAxisDistanceToPoint( + BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY, X, 0.0f); + const float DY = VF_LatticeAxisDistanceToPoint( + BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY, Y, 0.0f); + return FMath::Sqrt(DX * DX + DY * DY); + }; + + const float K = P.SDFBlendRadius; + const float WormThreshold = VoxelMath::IsFinite(P.WormNetworkRange) + ? FMath::Max(3.0f * K, P.WormNetworkRange) : FLT_MAX; + const float ThresholdWithBlend = VF_SaturatingAdd(WormThreshold, K); + float Lower = FLT_MAX; + bool bAnyPrimitive = false; + + auto Consider = [&](float Candidate) + { + if (!Finite(Candidate)) + { + bInvalidBound = true; + return; + } + bAnyPrimitive = true; + Lower = FMath::Min(Lower, Candidate); + }; + auto CountThreshold = [&](float Candidate, int32& Count) + { + if (Finite(Candidate) && Candidate < ThresholdWithBlend) { ++Count; } + }; + + B.NumRooms = GetBoxCache().Rooms.Num(); + B.NumTunnels = GetBoxCache().Tunnels.Num(); + B.NumRoomFloorJoins = GetBoxCache().RoomFloorJoins.Num(); + B.NumPits = GetBoxCache().Pits.Num(); + B.NumChimneys = GetBoxCache().Chimneys.Num(); + B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; + B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; + B.WarpDilation = FMath::Max3( + (float)WarpEnvelope.X, (float)WarpEnvelope.Y, (float)WarpEnvelope.Z); + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; + + auto MayHaveTunnelCoreTail = [&](const FCachedTunnel& Tunnel) -> uint8 + { + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const FVector& CenterlineMin = bHasWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bHasWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + const float InfluenceRadius = bHasWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + const FVector& BoundCenter = bHasWorldChain + ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; + const float BoundRadiusSq = bHasWorldChain + ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; + if (!Finite((float)CenterlineMin.X) || !Finite((float)CenterlineMin.Y) + || !Finite((float)CenterlineMin.Z) || !Finite((float)CenterlineMax.X) + || !Finite((float)CenterlineMax.Y) || !Finite((float)CenterlineMax.Z) + || !Finite(InfluenceRadius) || InfluenceRadius < 0.0f + || !Finite((float)BoundCenter.X) || !Finite((float)BoundCenter.Y) + || !Finite((float)BoundCenter.Z) || !Finite(BoundRadiusSq) + || BoundRadiusSq < 0.0f) + { + return 3u; + } + + // A post-air sample must lie within a tapered capsule's maximum radius of one + // world-chain segment. The segment AABB expanded by that radius is a cheap + // superset of the exact capsule; unlike the old enclosing sphere it rejects a + // long, thin tunnel from most unrelated LOD0 boxes. The floor is part of this + // same continuous field, so no second support-band candidate is needed. + if (Ctx.bUseLatticeProof) + { + const float WorldLatticeOriginX = + static_cast(Ctx.LatticeOriginVoxels.X); + const float WorldLatticeOriginY = + static_cast(Ctx.LatticeOriginVoxels.Y); + const float WorldLatticeOriginZ = + static_cast(Ctx.LatticeOriginVoxels.Z); + const float WorldLatticeStep = static_cast(Ctx.Step); + if (!(WorldLatticeStep > 0.0f)) { return 3u; } + + auto TouchesLatticeAABB = [&](float MinX, float MaxX, + float MinY, float MaxY, + float MinZ, float MaxZ) -> bool + { + return VF_LatticeAxisDistanceToInterval( + BoxMinX, BoxMaxX, WorldLatticeOriginX, WorldLatticeStep, + MinX, MaxX, 0.0f) <= 0.0f + && VF_LatticeAxisDistanceToInterval( + BoxMinY, BoxMaxY, WorldLatticeOriginY, WorldLatticeStep, + MinY, MaxY, 0.0f) <= 0.0f + && VF_LatticeAxisDistanceToInterval( + BoxMinZ, BoxMaxZ, WorldLatticeOriginZ, WorldLatticeStep, + MinZ, MaxZ, 0.0f) <= 0.0f; + }; + + uint8 Result = 0u; + auto AddSegment = [&](const FVector& A, const FVector& BPoint, + float RadiusA, float RadiusB, + bool bWorldChain, int32 SegmentIndex) + { + if (!Finite((float)A.X) || !Finite((float)A.Y) + || !Finite((float)A.Z) || !Finite((float)BPoint.X) + || !Finite((float)BPoint.Y) || !Finite((float)BPoint.Z) + || !Finite(RadiusA) || !Finite(RadiusB)) + { + Result = 3u; + return; + } + const float SafeRadiusA = FMath::Abs(RadiusA); + const float SafeRadiusB = FMath::Abs(RadiusB); + const float MaxRadius = FMath::Max(SafeRadiusA, SafeRadiusB); + if (!Finite(MaxRadius)) { Result = 3u; return; } + + if (TouchesLatticeAABB( + FMath::Min(A.X, BPoint.X) - MaxRadius, + FMath::Max(A.X, BPoint.X) + MaxRadius, + FMath::Min(A.Y, BPoint.Y) - MaxRadius, + FMath::Max(A.Y, BPoint.Y) + MaxRadius, + FMath::Min(A.Z, BPoint.Z) - MaxRadius, + FMath::Max(A.Z, BPoint.Z) + MaxRadius)) + { + Result |= 1u; + } + + (void)bWorldChain; + (void)SegmentIndex; + }; + + if (bHasWorldChain) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.WorldControlPoints.Num(); + ++SegmentIndex) + { + AddSegment( + Tunnel.WorldControlPoints[SegmentIndex], + Tunnel.WorldControlPoints[SegmentIndex + 1], + Tunnel.WorldControlRadii[SegmentIndex], + Tunnel.WorldControlRadii[SegmentIndex + 1], true, SegmentIndex); + if (Result == 3u) { return Result; } + } + } + else if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + AddSegment( + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1], false, SegmentIndex); + if (Result == 3u) { return Result; } + } + } + else if (Tunnel.bHasMidpoint) + { + AddSegment(Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid, false, INDEX_NONE); + AddSegment(Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB, false, INDEX_NONE); + } + else + { + AddSegment(Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB, false, INDEX_NONE); + } + return Result; + } + + if (InfluenceRadius > 0.0f + && VF_DistanceBetweenBoxes( + VoxelBox.Min, VoxelBox.Max, CenterlineMin, CenterlineMax) + > InfluenceRadius) + { + return 0u; + } + if (BoundRadiusSq > 0.0f + && VF_DistanceBetweenBoxes( + VoxelBox.Min, VoxelBox.Max, BoundCenter, BoundCenter) + > FMath::Sqrt(BoundRadiusSq)) + { + return 0u; + } + return 3u; + }; + + const FVector NoWarpMin(BoxMinX, BoxMinY, EffectiveMinZ); + const FVector NoWarpMax(BoxMaxX, BoxMaxY, EffectiveMaxZ); + + const uint64 ExactPrimitiveStartCycles = FPlatformTime::Cycles64(); + for (const FCachedRoom& Room : GetBoxCache().Rooms) + { + const float CullRadiusSq = Room.CullRadiusSq; + if (!Finite((float)Room.Center.X) || !Finite((float)Room.Center.Y) + || !Finite((float)Room.Center.Z) || !Finite(CullRadiusSq) + || CullRadiusSq < 0.0f) + { + bInvalidBound = true; + continue; + } + + const float RoomRadius = FMath::Sqrt(CullRadiusSq); + const float FastRoomLower = bUseExactLatticeWarp + ? ExactLatticeAabbPointDistance(Room.Center) - RoomRadius + : (bUseLatticeProof + ? LatticePointDistance(Room.Center, WarpEnvelope) - RoomRadius + : VF_DistanceBetweenBoxes(QMin, QMax, Room.Center, Room.Center) - RoomRadius); + const float RoomLower = bUseExactLatticeWarp + && FastRoomLower < ThresholdWithBlend + ? (ExactRoomRefinements++, ExactRoomLower(Room)) : FastRoomLower; + const float RoomLowerNoWarp = VF_DistanceBetweenBoxes( + NoWarpMin, NoWarpMax, Room.Center, Room.Center) - RoomRadius; + Consider(RoomLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastRoomLower, K); } + if (bUseExactLatticeWarp) { ConsiderExact(RoomLower, K); } + CountThreshold(RoomLower, B.HitRooms); + CountThreshold(RoomLowerNoWarp, B.HitRoomsNoWarp); + } + + // Room-floor joins are part of EvaluateSDFCached's same smooth-min union. They were + // absent from the old box proof, which could only lose skips; include them here so the + // exact retry remains a proof of the complete source rather than just rooms/tunnels. + for (const FCachedRoomFloorJoin& Join : GetBoxCache().RoomFloorJoins) + { + const float JoinRadius = FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)); + const float FastJoinLower = bUseExactLatticeWarp + ? ExactLatticeAabbPointDistance(Join.BoundCenter) - JoinRadius + : VF_DistanceBetweenBoxes( + QMin, QMax, Join.BoundCenter, Join.BoundCenter) - JoinRadius; + const float JoinLower = bUseExactLatticeWarp + && FastJoinLower < ThresholdWithBlend + ? (ExactJoinRefinements++, ExactJoinLower(Join)) : FastJoinLower; + Consider(JoinLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastJoinLower, K); } + if (bUseExactLatticeWarp) { ConsiderExact(JoinLower, K); } + } + + for (const FCachedTunnel& Tunnel : GetBoxCache().Tunnels) + { + const uint8 TailMask = MayHaveTunnelCoreTail(Tunnel); + B.bMayHaveTunnelCoreAir = B.bMayHaveTunnelCoreAir + || (TailMask & 1u) != 0; + B.bMayHaveTunnelCoreTail = B.bMayHaveTunnelCoreTail + || TailMask != 0u; + float MaxRadius = FMath::Max( + FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); + float TunnelLower = FLT_MAX; + if (bUseExactLatticeWarp) + { + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Tunnel.ControlPoints[SegmentIndex]; + const FVector& BPoint = Tunnel.ControlPoints[SegmentIndex + 1]; + MaxRadius = FMath::Max( + MaxRadius, + FMath::Max(FMath::Abs(Tunnel.ControlRadii[SegmentIndex]), + FMath::Abs(Tunnel.ControlRadii[SegmentIndex + 1]))); + const FVector SegmentMin( + FMath::Min(A.X, BPoint.X), + FMath::Min(A.Y, BPoint.Y), + FMath::Min(A.Z, BPoint.Z)); + const FVector SegmentMax( + FMath::Max(A.X, BPoint.X), + FMath::Max(A.Y, BPoint.Y), + FMath::Max(A.Z, BPoint.Z)); + TunnelLower = FMath::Min( + TunnelLower, + ExactLatticeAabbBoxDistance(SegmentMin, SegmentMax)); + } + } + else if (Tunnel.bHasMidpoint) + { + const FVector SegmentMin( + FMath::Min3(Tunnel.EndpointA.X, Tunnel.Midpoint.X, + Tunnel.EndpointB.X), + FMath::Min3(Tunnel.EndpointA.Y, Tunnel.Midpoint.Y, + Tunnel.EndpointB.Y), + FMath::Min3(Tunnel.EndpointA.Z, Tunnel.Midpoint.Z, + Tunnel.EndpointB.Z)); + const FVector SegmentMax( + FMath::Max3(Tunnel.EndpointA.X, Tunnel.Midpoint.X, + Tunnel.EndpointB.X), + FMath::Max3(Tunnel.EndpointA.Y, Tunnel.Midpoint.Y, + Tunnel.EndpointB.Y), + FMath::Max3(Tunnel.EndpointA.Z, Tunnel.Midpoint.Z, + Tunnel.EndpointB.Z)); + MaxRadius = FMath::Max( + MaxRadius, + FMath::Max(FMath::Abs(Tunnel.RadiusMid), + FMath::Max(FMath::Abs(Tunnel.RadiusA), + FMath::Abs(Tunnel.RadiusB)))); + TunnelLower = ExactLatticeAabbBoxDistance(SegmentMin, SegmentMax); + } + else + { + const FVector SegmentMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const FVector SegmentMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + TunnelLower = ExactLatticeAabbBoxDistance(SegmentMin, SegmentMax); + } + TunnelLower -= MaxRadius; + const float FastTunnelLower = TunnelLower; + if (TunnelLower < ThresholdWithBlend) + { + ++ExactTunnelRefinements; + TunnelLower = ExactTunnelLower(Tunnel); + } + if (bUseExactLatticeWarp) + { + ConsiderCheapExact(FastTunnelLower, K); + } + } + else if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Tunnel.ControlPoints[SegmentIndex]; + const FVector& BPoint = Tunnel.ControlPoints[SegmentIndex + 1]; + MaxRadius = FMath::Max( + MaxRadius, + FMath::Max(FMath::Abs(Tunnel.ControlRadii[SegmentIndex]), + FMath::Abs(Tunnel.ControlRadii[SegmentIndex + 1]))); + const FVector SegmentMin( + FMath::Min(A.X, BPoint.X), + FMath::Min(A.Y, BPoint.Y), + FMath::Min(A.Z, BPoint.Z)); + const FVector SegmentMax( + FMath::Max(A.X, BPoint.X), + FMath::Max(A.Y, BPoint.Y), + FMath::Max(A.Z, BPoint.Z)); + TunnelLower = FMath::Min( + TunnelLower, + bUseLatticeProof + ? LatticeBoxDistance(SegmentMin, SegmentMax, WarpEnvelope) + : VF_DistanceBetweenBoxes(QMin, QMax, SegmentMin, SegmentMax)); + } + TunnelLower -= MaxRadius; + } + else + { + const FVector SegmentMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const FVector SegmentMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + TunnelLower = (bUseLatticeProof + ? LatticeBoxDistance(SegmentMin, SegmentMax, WarpEnvelope) + : VF_DistanceBetweenBoxes(QMin, QMax, SegmentMin, SegmentMax)) - MaxRadius; + } + + const FVector NoWarpSegmentMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const FVector NoWarpSegmentMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const float TunnelLowerNoWarp = VF_DistanceBetweenBoxes( + NoWarpMin, NoWarpMax, NoWarpSegmentMin, NoWarpSegmentMax) + - MaxRadius; + Consider(TunnelLower); + if (bUseExactLatticeWarp) { ConsiderExact(TunnelLower, K); } + CountThreshold(TunnelLower, B.HitTunnels); + CountThreshold(TunnelLowerNoWarp, B.HitTunnelsNoWarp); + } + + // The pointwise air probe above is intentionally narrower than the final generator + // tail: a tunnel can write a solid swept floor at lattice points where its core SDF + // is not negative. Use the morphology module's shared broad reach predicate for the + // complete world-space core/floor envelope; it is the same bound used by the mesher's + // post-reach decision and keeps this diagnostic conservative for both polarities. + if (!B.bMayHaveTunnelCoreTail) + { + B.bMayHaveTunnelCoreTail = + VoxelCaveMorphology::AnyTunnelCoreWorldNearLattice( + GetBoxCache(), VoxelBox, 1.0f); + } + + if (B.bMayHaveTunnelCoreAir + && bUseLatticeProof + && ExactLatticeWorldQueries.Num() > 0) + { + // The bound test above is only a broad candidate. The actual post is a + // continuous writer: it opens a point when the world-core SDF is negative. This + // is still a proof of the final lattice, never a density shortcut; if an exact + // query is invalid, keep the conservative candidate. + bool bExactAir = false; + bool bExactTailUnknown = false; + TArray> ExactTailDomains; + bool bExactTailDomainCullSafe = true; + const uint64 ExactTailStartCycles = FPlatformTime::Cycles64(); + for (const FCachedTunnel& Tunnel : GetBoxCache().Tunnels) + { + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const FVector& CenterlineMin = bHasWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bHasWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + const float InfluenceRadius = bHasWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + // EvaluateTunnel's first cull is exactly the distance to this AABB. Turning + // it into a point-in-expanded-box test lets the exact tail reject points + // before it builds a support column or scans every tunnel. A disabled, + // inverted, or non-finite bound cannot authorize a reject: fall back to the + // existing fully conservative evaluator for the whole query set. + if (!Finite((float)CenterlineMin.X) || !Finite((float)CenterlineMin.Y) + || !Finite((float)CenterlineMin.Z) || !Finite((float)CenterlineMax.X) + || !Finite((float)CenterlineMax.Y) || !Finite((float)CenterlineMax.Z) + || CenterlineMin.X > CenterlineMax.X + || CenterlineMin.Y > CenterlineMax.Y + || CenterlineMin.Z > CenterlineMax.Z + || !Finite(InfluenceRadius) || InfluenceRadius <= 0.0f) + { + bExactTailDomainCullSafe = false; + break; + } + const FVector Radius(InfluenceRadius, InfluenceRadius, InfluenceRadius); + ExactTailDomains.Emplace(CenterlineMin - Radius, CenterlineMax + Radius); + } + ForEachExactQueryInWorldBox( + VoxelBox.Min, VoxelBox.Max, + [&](const FVector&, const FVector& World) + { + if (bExactAir || bExactTailUnknown) return; + ++B.ExactTailQueries; + if (bExactTailDomainCullSafe && ExactTailDomains.Num() > 0) + { + bool bInsideTailDomain = false; + for (const FBox& Domain : ExactTailDomains) + { + if (World.X >= Domain.Min.X && World.X <= Domain.Max.X + && World.Y >= Domain.Min.Y && World.Y <= Domain.Max.Y + && World.Z >= Domain.Min.Z && World.Z <= Domain.Max.Z) + { + bInsideTailDomain = true; + break; + } + } + if (!bInsideTailDomain) return; + } + + uint8* CachedTailState = nullptr; + if (B.bExactWarpedLatticeCacheValid + && B.ExactTailStates.Num() == B.ExactWarpedLatticeQueries.Num()) + { + const int32 TailIX = FMath::RoundToInt( + (World.X - LatticeOriginX) / LatticeStepXY); + const int32 TailIY = FMath::RoundToInt( + (World.Y - LatticeOriginY) / LatticeStepXY); + const int32 TailIZ = FMath::RoundToInt( + (World.Z - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step)); + if (TailIX >= B.ExactWarpedLatticeIX0 + && TailIX < B.ExactWarpedLatticeIX0 + B.ExactWarpedLatticeNX + && TailIY >= B.ExactWarpedLatticeIY0 + && TailIY < B.ExactWarpedLatticeIY0 + B.ExactWarpedLatticeNY + && TailIZ >= B.ExactWarpedLatticeIZ0 + && TailIZ < B.ExactWarpedLatticeIZ0 + B.ExactWarpedLatticeNZ) + { + const int32 TailIndex = + ((TailIZ - B.ExactWarpedLatticeIZ0) + * B.ExactWarpedLatticeNY + + (TailIY - B.ExactWarpedLatticeIY0)) + * B.ExactWarpedLatticeNX + + (TailIX - B.ExactWarpedLatticeIX0); + CachedTailState = &B.ExactTailStates[TailIndex]; + } + } + if (CachedTailState != nullptr && (*CachedTailState & 0x80u) != 0) + { + const uint8 State = *CachedTailState; + if ((State & 0x04u) != 0) + { + bExactTailUnknown = true; + } + else + { + bExactAir |= (State & 0x01u) != 0; + } + return; + } + + const FTunnelCoreWorldEvaluation Evaluation = + VoxelCaveMorphology::EvaluateTunnelCoreWorld( + static_cast(World.X), static_cast(World.Y), + static_cast(World.Z), GetBoxCache(), nullptr, + VoxelGenLOD::ShouldUseSpatialIndex(false)); + ++B.ExactTailEvaluated; + if (!VoxelMath::IsFinite(Evaluation.SDF)) + { + bExactTailUnknown = true; + if (CachedTailState != nullptr) { *CachedTailState = 0x84u; } + } + else + { + const bool bAir = Evaluation.SDF < 0.0f; + bExactAir |= bAir; + if (CachedTailState != nullptr) + { + *CachedTailState = static_cast( + 0x80u + | (bAir ? 0x01u : 0u)); + } + } + }); + B.ExactTailCycles = FPlatformTime::Cycles64() - ExactTailStartCycles; + if (!bExactTailUnknown) + { + B.bMayHaveTunnelCoreAir = bExactAir; + B.bMayHaveTunnelSupportFloor = false; + // Keep the broad geometric tail candidate even when the exact lattice has + // no core-air vertex. The final generator tail writes both polarities: + // it reopens tunnel air, but it can also write a solid swept floor below an + // otherwise-air stack box. Collapsing this flag to bExactAir erased the + // latter reach and let the classifier certify a false AllAir child. + } + } + + for (const FCachedPit& Pit : GetBoxCache().Pits) + { + if (!(BoxMinZ < Pit.TopZ + Pit.BlendK) + || !(BoxMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) + { + continue; + } + const float MaxRadius = FMath::Abs(Pit.Radius) + FMath::Abs(Pit.FlareExtra); + const float FastPitLower = bUseExactLatticeWarp + ? LatticeXYPointDistance(Pit.CenterX, Pit.CenterY) - MaxRadius + : (bUseLatticeProof + ? LatticeXYPointDistance(Pit.CenterX, Pit.CenterY) + : VF_DistanceBoxToPointXY(FBox(RMin, RMax), Pit.CenterX, Pit.CenterY)) - MaxRadius; + const float PitLower = bUseExactLatticeWarp + && FastPitLower < ThresholdWithBlend + ? (ExactPitRefinements++, ExactPitLower(Pit)) : FastPitLower; + Consider(PitLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastPitLower, Pit.BlendK); } + if (bUseExactLatticeWarp) { ConsiderExact(PitLower, Pit.BlendK); } + CountThreshold(PitLower, B.HitPits); + } + + for (const FCachedChimney& Chimney : GetBoxCache().Chimneys) + { + if (!(BoxMaxZ > Chimney.BottomZ - Chimney.BlendK) + || !(BoxMinZ <= Chimney.BottomZ + Chimney.Height + Chimney.BlendK)) + { + continue; + } + const float MaxRadius = FMath::Abs(Chimney.Radius) + FMath::Abs(Chimney.FlareExtra); + const float FastChimneyLower = bUseExactLatticeWarp + ? LatticeXYPointDistance(Chimney.CenterX, Chimney.CenterY) - MaxRadius + : (bUseLatticeProof + ? LatticeXYPointDistance(Chimney.CenterX, Chimney.CenterY) + : VF_DistanceBoxToPointXY(FBox(RMin, RMax), Chimney.CenterX, Chimney.CenterY)) - MaxRadius; + const float ChimneyLower = bUseExactLatticeWarp + && FastChimneyLower < ThresholdWithBlend + ? (ExactChimneyRefinements++, ExactChimneyLower(Chimney)) : FastChimneyLower; + Consider(ChimneyLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastChimneyLower, Chimney.BlendK); } + if (bUseExactLatticeWarp) { ConsiderExact(ChimneyLower, Chimney.BlendK); } + CountThreshold(ChimneyLower, B.HitChimneys); + } + B.ExactPrimitiveCycles = FPlatformTime::Cycles64() - ExactPrimitiveStartCycles; + + if (bInvalidBound) + { + Unknown(); + return; + } + + FVoxelBoxSdfInterval Own; + if (bUseExactLatticeWarp) + { + // Exact per-primitive scans are a tightening only. Their candidate-domain + // filters can miss a warped lattice query near a primitive, so retain the + // broad lower bound over the AABB that contains every warped sample. The + // minimum stays useful when both agree and remains safe when an exact scan is + // incomplete. + Own.Set(FMath::Min(ExactSdfLower, CheapSdfLower), FLT_MAX); + } + else if (!bAnyPrimitive) + { + Own.Set(FLT_MAX, FLT_MAX); + } + else + { + Own.Set(VF_SaturatingAdd(Lower, -K), FLT_MAX); + } + + B.SdfInterval = Own; + B.Verdict = Own.IsKnown() && Own.Min >= WormThreshold + ? EVoxelOpEffect::Identity : EVoxelOpEffect::Both; + B.KeyBox = VoxelBox; + B.KeyStrate = StrateIdx; + B.KeySeed = SeedU; + B.KeyFingerprint = ParamsFingerprint; + B.KeyLayout = LV; + B.KeyManagerLifetimeId = ManagerLifetimeId; + B.KeyUsesLatticeProof = bUseLatticeProof; + B.KeyTightenWarpProof = Ctx.bTightenWarpProof; + B.KeyLatticeOrigin = Ctx.LatticeOriginVoxels; + B.KeyLatticeStep = Ctx.Step; + B.bValid = true; + InOut = Own; + B.PropagateCycles = FPlatformTime::Cycles64() - PropagateStartCycles; + } + + const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); } + + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + + private: + FStrateGenerationParams P; + int32 Seed; + uint32 SeedU; + TWeakObjectPtr Manager; + uint64 ManagerLifetimeId = 0; + uint32 ParamsFingerprint; + uint32 LayoutVersion = 0; + }; + + //========================================================================= + // LE GATE DE BOÎTE DES MODIFICATEURS DE CAVE + //========================================================================= + // The source publishes an SDF interval; detail modifiers consume that interval here. This is + // deliberately a state test, never a call to the source's EffectOverBox: the latter is the + // source-only answer and must remain valid when the composer chooses a different consumer. + FORCEINLINE bool VF_CaveBoxIsFar(const FVoxelBoxHypotheses& H, float SDFBlendRadius) + { + const float Threshold = SDFBlendRadius * 3.0f; + return VoxelMath::IsFinite(Threshold) && H.Sdf.IsKnown() && H.Sdf.Min >= Threshold; + } + + FORCEINLINE EVoxelOpEffect VF_CaveDetailEffect(const FRoomGraphSource* Rooms, + const FVoxelBoxHypotheses& H, + float SDFBlendRadius, + EVoxelOpEffect Intrinsic) + { + // A detail op with no room source has the same no-op guard as Eval. An unknown interval is + // not a reason to claim Identity: uncertainty costs a skip, while a false skip is a hole. + if (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) + { + return EVoxelOpEffect::Identity; + } + // A terrain op is allowed to replace the strate-level activation fields for the room that + // Eval selects. Its exact direction is not part of the generic cache proof, so preserve + // safety with Both whenever one is present. The common no-override TunnelNetwork path + // gets the real intrinsic direction and finite amplitude below. + return Rooms->HasRoomTerrainOverrideForLastBox() + ? EVoxelOpEffect::Both : Intrinsic; + } + + FORCEINLINE float VF_CaveDetailMax(const FRoomGraphSource* Rooms, + const FVoxelBoxHypotheses& H, + float SDFBlendRadius, + float IntrinsicMax) + { + // Per-room overrides can activate or enlarge a modifier even when the strate-level + // parameter is zero. A valid cache with no override makes the strate-level supremum + // exact for these modifiers; an unknown/overridden cache keeps the old safe default. + if (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) { return 0.0f; } + if (Rooms->HasRoomTerrainOverrideForLastBox()) { return FLT_MAX; } + return IntrinsicMax; + } + + //========================================================================= + // RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE DENSITÉ (TunnelNetwork, STEP 4b) + //========================================================================= + // ⚠️ CE N'EST PAS `FSdfRoughnessMod`, ET C'EST LE PIÈGE QUE `OPSTACK-DECOMPOSITION §1` SIGNALE. + // Les deux s'appellent « rugosité de surface » et lisent le même champ de params, mais : + // + // • variante SDF (Maze / VerticalShafts / FloatingIslands) : `Sdf += bruit·SCALE·Force`. + // Brut, sans fade, sans clamp, fréquence codée en dur au site d'appel. Déplace la SURFACE. + // • variante DENSITÉ (ici) : DEUX jeux d'octaves (principal + fin ×3), warp de domaine + // optionnel, QUATRE types de bruit, un `Min(…, 0)` anti-remplissage, et un fade QUADRATIQUE + // par distance à la surface. Déplace la MATIÈRE, mise à l'échelle par le gradient local. + // + // Les fusionner sous un enum `Space` était la suggestion du §1 ; en les portant, ils n'ont + // presque aucune ligne en commun (le clamp, le fade et le second jeu d'octaves n'ont pas + // d'équivalent dans l'autre). Deux opérateurs, un nom partagé — comme `FGridColumnMod` et le + // futur `FRoomColumnMod`, que le §1 sépare pour la même raison. + // + // ⚠️⚠️ CET OPÉRATEUR EST **HORS** DE L'OVERRIDE D'OP PAR SALLE, et ce n'est pas un oubli. + // Dans l'original, le shadow `const FStrateGenerationParams& Params = LocalTerrainParams;` est + // déclaré à l'INTÉRIEUR du bloc `if (bNearCaveSurface)` qui commence APRÈS l'étape 4b. La + // rugosité lit donc les params de la STRATE, jamais ceux de la salle la plus proche. Onze + // modificateurs sur douze lisent la copie par salle ; celui-ci non. À NE PAS « uniformiser » + // à l'étape C1. + // + // This op reads STRATE params, not the per-room copy: the original's shadow is declared inside + // the `if (bNearCaveSurface)` block that starts AFTER step 4b. Eleven of twelve modifiers read + // the shadowed copy; this one does not. + class FCaveRoughnessMod final : public IVoxelDensityOp + { + public: + FCaveRoughnessMod(const FStrateGenerationParams& InP, int32 Seed) + : P(InP), SeedU((uint32)Seed) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("CaveRoughnessMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (!(P.SurfaceRoughness > 0.0f)) { return; } + + const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) + ? (WorldZ / P.VerticalScale) : WorldZ; + + const float RoughnessDepth = P.SurfaceRoughness * 2.0f; + const float DistFromSurface = FMath::Abs(CaveSDF); + if (!(DistFromSurface < RoughnessDepth)) { return; } + + const float RF = P.RoughnessFrequency; + + // Keep both hot noise-coordinate chains in float. The old FVector round-trip only + // preserved legacy rounding; output compatibility with that field is abandoned. + FVector3f MainPos( + WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f), + WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f), + EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f) + ); + FVector3f FinePos( + WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f, + WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f, + EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f + ); + + // WARP DE DOMAINE : le MÊME offset est ajouté aux DEUX positions (une seule + // `FVector WarpOffset`, deux `+=`). Transcrit tel quel — appliquer deux warps + // indépendants serait plus « propre » et donnerait un autre monde. + if (P.DomainWarpStrength > 0.0f) + { + const float WF = P.DomainWarpFrequency; + const float WS = P.DomainWarpStrength; + + const float WarpX = VoxelNoise::Perlin3D(FVector3f( + WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f), + WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f), + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f) + )) * VOXEL_NOISE_SCALE * WS; + + const float WarpY = VoxelNoise::Perlin3D(FVector3f( + WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f), + WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f), + EffectiveZ * WF + 300.0f + )) * VOXEL_NOISE_SCALE * WS; + + const float WarpZ = VoxelNoise::Perlin3D(FVector3f( + WorldX * WF + 400.0f, + WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f), + EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f) + )) * VOXEL_NOISE_SCALE * WS; + + const FVector3f WarpOffset(WarpX, WarpY, WarpZ); + MainPos += WarpOffset; + FinePos += WarpOffset; + } + + // Les comptes d'octaves passent par VoxelGenLOD::Eff — contrat T2.b, les tuiles + // lointaines perdent les octaves sous-cellulaires. + float RoughNoise, FineNoise; + const int32 Oct3 = VoxelGenLOD::Eff(3); + const int32 Oct2 = VoxelGenLOD::Eff(2); + + switch (P.RoughnessNoiseType) + { + case EVoxelNoiseType::Ridged: + RoughNoise = HRidged3D(MainPos, Oct3); + FineNoise = HRidged3D(FinePos, Oct2); + break; + + case EVoxelNoiseType::Mixed: + RoughNoise = HFractal3D(MainPos, Oct3) * 0.5f + + HRidged3D(MainPos, Oct3) * 0.5f; + FineNoise = HFractal3D(FinePos, Oct2) * 0.5f + + HRidged3D(FinePos, Oct2) * 0.5f; + break; + + case EVoxelNoiseType::Cellular: + RoughNoise = VoxelNoise::Cellular3D(MainPos); + FineNoise = VoxelNoise::Cellular3D(FinePos); + break; + + case EVoxelNoiseType::FBM: + default: + RoughNoise = HFractal3D(MainPos, Oct3); + FineNoise = HFractal3D(FinePos, Oct2); + break; + } + + RoughNoise *= VOXEL_NOISE_SCALE; + FineNoise *= VOXEL_NOISE_SCALE; + + float TotalRough = RoughNoise * P.SurfaceRoughness + + FineNoise * P.SurfaceRoughness * 0.4f; + + // CLAMP ANTI-REMPLISSAGE : dans l'air certain (SDF < 0) la rugosité ne doit JAMAIS + // rajouter du solide — sinon lucarnes, membranes, coutures aux jonctions et aux lèvres + // de puits. Elle peut encore creuser plus loin dans la paroi. + if (CaveSDF < 0.0f) + { + TotalRough = FMath::Min(TotalRough, 0.0f); + } + + float SurfaceFade = 1.0f - (DistFromSurface / RoughnessDepth); + SurfaceFade = SurfaceFade * SurfaceFade; // quadratique : concentre près de la surface + + InOut.Density += TotalRough * SurfaceFade; + } + + /** + * `Both` : la rugosité peut pousser dans les deux sens (le clamp ne s'applique que dans + * l'air certain). Conservatif et donc correct, mais coûteux — comme les vers, c'est un + * opérateur dont l'AMPLITUDE est bornée alors que sa DIRECTION ne l'est pas : + * |TotalRough| ≤ 2.1 · SurfaceRoughness · VOXEL_NOISE_SCALE, fade ∈ [0,1]. + * Deuxième client pour le pliage numérique de `OPSTACK-DECOMPOSITION §0.2`, noté au point + * exact où la borne manque (le premier est `FWormFieldSource::MaxCarveAmplitude`). + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.SurfaceRoughness > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); + if (Intrinsic == EVoxelOpEffect::Identity) { return Intrinsic; } + return IsInactiveFromSdf(H) ? EVoxelOpEffect::Identity : Intrinsic; + } + + /** + * ✅ Borne consommée par le pliage numérique. `RoughNoise` et `FineNoise` respectent tous + * `FBM` est couvert par la borne prouvée `VF_PerlinAbsBound = 1.5`, mis à l'échelle par + * `VOXEL_NOISE_SCALE`. `Ridged` is in [-1,1] after its square/fold, while the unclamped + * cellular mapping needs its own finite envelope below. The two octaves contribute + * `|Rough| + 0.4·|Fine|`; the fade is in `[0,1]`. The anti-fill clamp only reduces the + * positive side, so the same envelope is valid in both directions. + */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); } + float MaxFillOverBox (const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return IsInactiveFromSdf(H) ? 0.0f : MaxCarveOverBox(VoxelBox, Ctx); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return IsInactiveFromSdf(H) ? 0.0f : MaxFillOverBox(VoxelBox, Ctx); + } + + /** La borne d'amplitude, en unités de densité. */ + float MaxAmplitude() const + { + if (!(P.SurfaceRoughness > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.SurfaceRoughness)) { return FLT_MAX; } + + float NoiseBound = VF_PerlinAbsBound; + switch (P.RoughnessNoiseType) + { + case EVoxelNoiseType::Ridged: + NoiseBound = 1.0f; + break; + case EVoxelNoiseType::Mixed: + NoiseBound = 0.5f * (VF_PerlinAbsBound + 1.0f); + break; + case EVoxelNoiseType::Cellular: + NoiseBound = VF_CellularAbsBound; + break; + case EVoxelNoiseType::FBM: + default: + NoiseBound = VF_PerlinAbsBound; + break; + } + + const float Result = 1.4f * NoiseBound * P.SurfaceRoughness * VOXEL_NOISE_SCALE; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + bool IsInactiveFromSdf(const FVoxelBoxHypotheses& H) const + { + if (VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return true; } + if (!(P.SurfaceRoughness > 0.0f) || !VoxelMath::IsFinite(P.SurfaceRoughness)) + { + return P.SurfaceRoughness <= 0.0f; + } + const float RoughnessDepth = P.SurfaceRoughness * 2.0f; + // Eval's second gate is abs(CaveSDF) < SurfaceRoughness*2. A known positive + // lower endpoint at or beyond that depth therefore proves the op is an identity even + // when the broader shared near-surface gate (SDFBlendRadius*3) remains open. + return VoxelMath::IsFinite(RoughnessDepth) && H.Sdf.IsKnown() + && H.Sdf.Min >= RoughnessDepth; + } + + FStrateGenerationParams P; + uint32 SeedU; + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : TERRASSES / TERRACING (TunnelNetwork, STEP 4c) + //========================================================================= + // Un escalier lissé en Z : `Offset = staircase(Z) − Z` ajouté à la densité. Positif ⇒ marche + // solide sur laquelle marcher, négatif ⇒ vide sous la marche du dessus. + // + // ⚠️ LE SEUL MODIFICATEUR QUI RE-INTERROGE LE CHAMP SDF. Le facteur d'orientation vient de deux + // sondes en Z±1 : un SDF a un gradient ≈ unitaire, donc |dSDF/dZ| EST déjà la composante + // verticale normalisée — proche de 1 = sol/plafond, proche de 0 = paroi. Sans ce facteur, les + // terrasses posent des bourrelets horizontaux dans les puits verticaux. + // + // C'est pour cette re-interrogation que `FRoomGraphSource` expose `ProbeSdfUnwarped` (et donc + // que son cache est sorti de `Eval`) : le refaire ici voudrait dire un second cache SDF. + class FCaveTerraceMod final : public IVoxelDensityOp + { + public: + FCaveTerraceMod(const FStrateGenerationParams& InP, int32 Seed, const FRoomGraphSource* InRooms) + : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("CaveTerraceMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + + // `&& CaveSDF < FLT_MAX` est redondant sous le gate (qui le teste déjà) mais l'original + // l'écrit, et une transcription littérale ne fait pas le tri. + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.TerraceStepHeight > 0.0f && CaveSDF < FLT_MAX)) { return; } + + const float StepH = LP.TerraceStepHeight; + const float DistFromSurface = FMath::Abs(CaveSDF); + const float TerraceRange = StepH * 3.0f; + if (!(DistFromSurface < TerraceRange)) { return; } + + // Deux évaluations SDF de plus par voxel, seulement près d'une surface. Approximation + // en Z seul : le gradient complet à 6 échantillons coûtait 3× pour le même signal. + const float SDF_Zp1 = Rooms->ProbeSdfUnwarped(WorldX, WorldY, WorldZ + 1.0f); + const float SDF_Zm1 = Rooms->ProbeSdfUnwarped(WorldX, WorldY, WorldZ - 1.0f); + const float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f; + const float SurfaceHorizontality = FMath::Clamp(FMath::Abs(GZ), 0.0f, 1.0f); + const float TerraceOrientFactor = FMath::Clamp((SurfaceHorizontality - 0.3f) / 0.4f, 0.0f, 1.0f); + + // ⚠️ Z BRUT, PAS `EffectiveZ` : les terrasses sont géologiques, elles restent + // horizontales quelle que soit l'échelle verticale de la strate. Idem pour les lignes de + // strates et les nervures plus bas. La rugosité (4b), elle, utilise EffectiveZ. C'est + // délibéré dans l'original et ça se lit dans son commentaire d'en-tête du STEP 3. + float NoisedZ = WorldZ; + if (LP.TerraceNoiseDisplacement > 0.0f) + { + const float DispNoise = HFractal3D(FVector3f( + WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f), + WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f), + WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f) + ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; + NoisedZ += DispNoise * LP.TerraceNoiseDisplacement * StepH; + } + + const float K = NoisedZ / StepH; + const float FloorK = FMath::FloorToFloat(K); + const float Frac = K - FloorK; // toujours [0, 1) + + const float Edge = FMath::Lerp(0.45f, 0.02f, LP.TerraceHardness); + float StairValue; + if (Frac < 0.5f - Edge) + { + StairValue = 0.0f; + } + else if (Frac > 0.5f + Edge) + { + StairValue = 1.0f; + } + else + { + const float T = (Frac - (0.5f - Edge)) / (2.0f * Edge); + StairValue = SmoothStep01(T); + } + + const float TerracedZ = (FloorK + StairValue) * StepH; + const float Offset = TerracedZ - NoisedZ; // ≈ [-StepH/2, +StepH/2] + + float Fade = 1.0f - (DistFromSurface / TerraceRange); + Fade = Fade * Fade; + + InOut.Density += Offset * Fade * TerraceOrientFactor; + } + + /** `Both` : `Offset` change de signe d'une demi-marche à l'autre. Amplitude bornée par + * `StepH/2` — troisième client du pliage numérique de `OPSTACK-DECOMPOSITION §0.2`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.TerraceStepHeight > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + /** + * `TerracedZ` is one staircase step below or above `NoisedZ`; the modulo phase is in + * [0,1], so the safe supremum of `abs(TerracedZ - NoisedZ)` is one full step, not StepH/2. + * The noise displacement changes which phase is selected but cannot enlarge that interval. + */ + float MaxAmplitude() const + { + return (P.TerraceStepHeight > 0.0f + && VoxelMath::IsFinite(P.TerraceStepHeight) + && VoxelMath::IsFinite(P.TerraceHardness) + && P.TerraceHardness >= 0.0f && P.TerraceHardness <= 1.0f) + ? P.TerraceStepHeight : (P.TerraceStepHeight > 0.0f ? FLT_MAX : 0.0f); + } + + private: + FStrateGenerationParams P; + uint32 SeedU; + const FRoomGraphSource* Rooms; // NON possédant : la pile possède la source + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : LIGNES DE STRATES / LAYER LINES (TunnelNetwork, STEP 4c) + //========================================================================= + // Rainures horizontales dans les parois — la strate sédimentaire vue en coupe. Une sinusoïde en + // Z, rectifiée puis CUBÉE : les bosses larges du sinus deviennent des pointes fines, donc des + // rainures étroites au lieu d'une ondulation. + class FLayerLineMod final : public IVoxelDensityOp + { + public: + FLayerLineMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("LayerLineMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; } + + const float DistFromSurface = FMath::Abs(CaveSDF); + const float LineRange = LP.LayerLineSpacing * 1.5f; + if (!(DistFromSurface < LineRange)) { return; } + + const float LinePhase = WorldZ * (2.0f * PI) / LP.LayerLineSpacing; + float LineValue = VoxelMath::DetSin(LinePhase); + + LineValue = FMath::Max(LineValue, 0.0f); + LineValue = LineValue * LineValue * LineValue; // affûtage cubique + + float Fade = 1.0f - (DistFromSurface / LineRange); + Fade = Fade * Fade; + + InOut.Density -= LineValue * LP.LayerLineDepth * Fade; + } + + /** Ne SOUSTRAIT que (`LineValue ≥ 0`, `Depth ≥ 0`) ⇒ `CarveOnly`, jamais `Both`. Un des + * rares modificateurs de détail qui garde une DIRECTION exploitable par le pliage. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.LayerLineSpacing > 0.0f && P.LayerLineDepth > 0.0f) + ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + /** `LineValue = max(sin,0)³ ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ retrait ≤ `LayerLineDepth`. + * ⚠️ Borne calculée sur les params de la STRATE : un op `LayerLines` par salle peut écrire + * une profondeur PLUS GRANDE, ce qui rendrait cette borne fausse dans le sens dangereux. + * C'est la même dette que la note d'`EffectOverBox` juste au-dessus, et elle doit être + * réglée par le même correctif — AVANT que `ClassifyTile` ne consomme `ClassifyBox`. */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); + } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + + float MaxAmplitude() const + { + if (!(P.LayerLineSpacing > 0.0f)) { return 0.0f; } + return VoxelMath::IsFinite(P.LayerLineDepth) + ? FMath::Max(P.LayerLineDepth, 0.0f) : FLT_MAX; + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : NERVURES / RIBBING (TunnelNetwork, STEP 4c) + //========================================================================= + // La MÊME sinusoïde en Z que les lignes de strates, décalée d'un quart de période + // (`+ PI · 0.5`), rectifiée puis CARRÉE au lieu de cubée, et AJOUTÉE au lieu d'être soustraite : + // des bourrelets arrondis (tube de lave) au lieu de rainures fines. + // + // ⚠️ Deux opérateurs, pas un avec un signe : l'exposant diffère (3 contre 2), la phase diffère, + // et le paramètre d'espacement est indépendant. Les fusionner demanderait trois paramètres pour + // économiser dix lignes, et rendrait la correspondance avec l'original illisible. + class FRibbingMod final : public IVoxelDensityOp + { + public: + FRibbingMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("RibbingMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX)) { return; } + + const float DistFromSurface = FMath::Abs(CaveSDF); + const float RibRange = LP.RibbingSpacing * 1.5f; + if (!(DistFromSurface < RibRange)) { return; } + + const float RibPhase = WorldZ * (2.0f * PI) / LP.RibbingSpacing + PI * 0.5f; + float RibValue = VoxelMath::DetSin(RibPhase); + + RibValue = FMath::Max(RibValue, 0.0f); + RibValue = RibValue * RibValue; // profil de bosse arrondi + + float Fade = 1.0f - (DistFromSurface / RibRange); + Fade = Fade * Fade; + + InOut.Density += RibValue * LP.RibbingDepth * Fade; + } + + /** N'AJOUTE que du solide ⇒ `FillOnly`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.RibbingSpacing > 0.0f && P.RibbingDepth > 0.0f) + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + /** `RibValue = max(sin,0)² ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ ajout ≤ `RibbingDepth`. + * Même réserve « params de strate » que `FLayerLineMod::MaxCarveOverBox`. */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); + } + + float MaxAmplitude() const + { + if (!(P.RibbingSpacing > 0.0f)) { return 0.0f; } + return VoxelMath::IsFinite(P.RibbingDepth) + ? FMath::Max(P.RibbingDepth, 0.0f) : FLT_MAX; + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : SURPLOMBS DE GROTTE / CAVE OVERHANGS (TunnelNetwork, STEP 4c) + //========================================================================= + // ⚠️ TROISIÈME OPÉRATEUR NOMMÉ « OVERHANG » DANS CE FICHIER, et ils n'ont rien en commun : + // • `FOverhangShelfMod` (SurfaceWorld) emprunte la hauteur de terrain amont — géométrique ; + // • celui-ci est un bruit fBM à FRÉQUENCE EN Z RÉDUITE (×0.15), dont on ne garde que le lobe + // POSITIF : la roche ne s'étend que VERS la grotte, jamais en creux. D'où des étagères + // éparses au lieu d'un déplacement uniforme. + // Même nom dans l'éditeur, deux idées différentes. `OPSTACK-DECOMPOSITION §1` les sépare déjà. + class FCaveOverhangMod final : public IVoxelDensityOp + { + public: + FCaveOverhangMod(const FStrateGenerationParams& InP, int32 Seed, const FRoomGraphSource* InRooms) + : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("CaveOverhangMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.OverhangStrength > 0.0f && CaveSDF < FLT_MAX)) { return; } + + const float DistFromSurface = FMath::Abs(CaveSDF); + const float OverhangRange = LP.OverhangDepth * 2.0f; + if (!(DistFromSurface < OverhangRange)) { return; } + + const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) + ? (WorldZ / P.VerticalScale) : WorldZ; + + // Fréquence en Z à 0.15× celle de XY ⇒ les motifs s'étirent horizontalement. + const float OverhangNoise = HFractal3D(FVector3f( + WorldX * LP.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f), + WorldY * LP.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f), + EffectiveZ * LP.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f) + ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; + + if (OverhangNoise > 0.0f) + { + float Fade = 1.0f - (DistFromSurface / OverhangRange); + Fade = Fade * Fade; + + InOut.Density += OverhangNoise * LP.OverhangDepth * LP.OverhangStrength * Fade; + } + } + + /** Lobe positif seulement ⇒ n'AJOUTE que du solide ⇒ `FillOnly`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.OverhangStrength > 0.0f && P.OverhangDepth > 0.0f) + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + /** fBM ∈ [-1,1] × `VOXEL_NOISE_SCALE`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ ajout + * ≤ `SCALE · Depth · Strength`. Même réserve « params de strate ». */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); + } + + float MaxAmplitude() const + { + if (!(P.OverhangStrength > 0.0f && P.OverhangDepth > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.OverhangDepth) + || !VoxelMath::IsFinite(P.OverhangStrength)) + { + return FLT_MAX; + } + const float Result = VOXEL_NOISE_SCALE * VF_PerlinAbsBound + * P.OverhangDepth * P.OverhangStrength; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + FStrateGenerationParams P; + uint32 SeedU; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : AFFÛTAGE DE FALAISE / CLIFF SHARPENING (TunnelNetwork, STEP 4c) + //========================================================================= + // ⚠️ TRANSCRIT TEL QUEL BIEN QUE LE COMMENTAIRE DE L'ORIGINAL DÉCRIVE AUTRE CHOSE. + // Il annonce « échantillonner la densité en Z±1 et calculer le gradient vertical » ; le code, lui, + // n'échantillonne RIEN : il tire un Perlin dont la fréquence en Z est 3× celle de XY et l'appelle + // `VertGrad`. C'est un PROXY de gradient, pas un gradient — donc l'effet est décorrélé de la + // pente réelle de la paroi. Le multiplier par `CaveSDF` lui donne quand même le bon SIGNE de part + // et d'autre de la surface (plus solide côté roche, plus creusé côté air), ce qui suffit à + // produire des faces plus raides. + // + // Corriger l'écart changerait le monde ; le taire le laisserait se faire « corriger » un jour par + // quelqu'un qui lit le commentaire et pas le code. Noté ici ET dans OPSTACK-PROGRESS. + // + // Ported as written, not as commented: the original's comment promises a sampled vertical + // gradient, the code uses a Z-stretched Perlin as a proxy. Fixing it would change the world. + class FCaveCliffMod final : public IVoxelDensityOp + { + public: + FCaveCliffMod(const FStrateGenerationParams& InP, int32 Seed, const FRoomGraphSource* InRooms) + : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("CaveCliffMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.CliffStrength > 0.0f && CaveSDF < FLT_MAX)) { return; } + + const float DistFromSurface = FMath::Abs(CaveSDF); + const float CliffRange = 8.0f; // constante en dur dans l'original + if (!(DistFromSurface < CliffRange)) { return; } + + const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) + ? (WorldZ / P.VerticalScale) : WorldZ; + + const float VertGrad = VoxelNoise::Perlin3D(FVector3f( + WorldX * 0.05f + VoxelHash::SeedOffset(SeedU, 71.3f), + WorldY * 0.05f + VoxelHash::SeedOffset(SeedU, 73.7f), + EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3× plus vite en Z + )) * VOXEL_NOISE_SCALE; + + const float CliffEffect = VertGrad * CaveSDF * LP.CliffStrength; + + if (FMath::Abs(VertGrad) > 0.3f) + { + float Fade = 1.0f - (DistFromSurface / CliffRange); + Fade = Fade * Fade; + InOut.Density += CliffEffect * Fade * 3.0f; + } + } + + /** `Both` : le signe suit celui de `VertGrad · CaveSDF`, donc les deux directions. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.CliffStrength > 0.0f) ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + /** `|VertGrad| ≤ SCALE·1.5`, `|CaveSDF| < 8`, `Fade ≤ 1`, final multiplier `3`. */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + float MaxAmplitude() const + { + if (!(P.CliffStrength > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.CliffStrength)) { return FLT_MAX; } + const float Result = 8.0f * 3.0f * VOXEL_NOISE_SCALE + * VF_PerlinAbsBound * P.CliffStrength; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + FStrateGenerationParams P; + uint32 SeedU; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : FESTONS / SCALLOP (TunnelNetwork, STEP 4c) + //========================================================================= + // Cuvettes concaves d'érosion hydraulique. Bruit cellulaire : la valeur est haute AU CENTRE + // d'une cellule (loin des points-features), donc on y creuse — d'où des rangées de coupelles + // lisses, la signature des grottes calcaires. + // + // Deuxième client de `VoxelNoise::Cellular3D` (le premier est la rugosité en mode Cellular) — + // c'est-à-dire la deuxième raison pour laquelle ce corps devait être PARTAGÉ et non recopié. + class FScallopMod final : public IVoxelDensityOp + { + public: + FScallopMod(const FStrateGenerationParams& InP, int32 Seed, const FRoomGraphSource* InRooms) + : P(InP), SeedU((uint32)Seed), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("ScallopMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.ScallopStrength > 0.0f && CaveSDF < FLT_MAX)) { return; } + + const float DistFromSurface = FMath::Abs(CaveSDF); + const float ScallopRange = LP.ScallopStrength * 4.0f; + if (!(DistFromSurface < ScallopRange)) { return; } + + const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) + ? (WorldZ / P.VerticalScale) : WorldZ; + + const float SF = LP.ScallopFrequency; + const float ScallopNoise = VoxelNoise::Cellular3D(FVector3f( + WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f), + WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f), + EffectiveZ * SF + VoxelHash::SeedOffset(SeedU, 97.7f) + )); + + if (ScallopNoise > 0.0f) + { + float Fade = 1.0f - (DistFromSurface / ScallopRange); + Fade = Fade * Fade; + + InOut.Density -= ScallopNoise * LP.ScallopStrength * Fade; + } + } + + /** Lobe positif seulement, SOUSTRAIT ⇒ `CarveOnly`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.ScallopStrength > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + /** The implementation maps an unclamped F2-F1 distance to `2*d-1`; the proven finite + * envelope used here is `VF_CellularAbsBound`, not the approximate [-1,1] comment beside + * the noise function. The positive lobe and `Fade ∈ [0,1]` then bound the carve. */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); + } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override { return 0.0f; } + + float MaxAmplitude() const + { + if (!(P.ScallopStrength > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.ScallopStrength)) { return FLT_MAX; } + const float Result = VF_CellularAbsBound * P.ScallopStrength; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + FStrateGenerationParams P; + uint32 SeedU; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : ARCHES / PONTS DE ROCHE (TunnelNetwork, STEP 4c) + //========================================================================= + // ⚠️ PREMIER MODIFICATEUR RELATIF À LA SALLE. Il ne lit pas seulement `InOut.Sdf` : il lui faut + // LA SALLE — son hash (pour tirer les arches de façon déterministe), son centre et ses rayons. + // C'est l'unique consommateur de `NearestRoomIdx` avec les dômes, le pincement et le biais de + // sol, et c'est ce qui rend `FRoomGraphSource::GetNearestRoomIdx()` nécessaire. + // + // ⚠️ SA PORTE N'EST PAS LE GATE COMMUN : `CaveSDF < SDFBlendRadius` (dans la grotte ou tout + // près), pas `< SDFBlendRadius·3`. Une arche se pose dans le VIDE de la salle, pas dans sa paroi. + class FCaveArchMod final : public IVoxelDensityOp + { + public: + FCaveArchMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("CaveArchMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + + const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx(); + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.ArchDensity > 0.0f && CaveSDF < LP.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0)) + { + return; + } + + // ⚠️ `IsValidIndex` AJOUTÉ : l'original indexe directement après le seul test `>= 0`. + // L'index vient d'`EvaluateSDFCached` donc il est valide par construction — ce garde-fou + // ne peut donc JAMAIS changer la sortie, seulement empêcher un crash si l'invariant se + // cassait un jour. Même famille de décision que l'empreinte de params dans la clé de + // cache : on se trompe du côté du coût, jamais du côté du résultat. + const FChunkSDFCache& Cache = Rooms->GetCache(); + if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } + const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx]; + + const FVector VoxPos(WorldX, WorldY, WorldZ); + + for (int32 i = 0; i < 3; i++) + { + const FCachedArch& Arch = Room.Arches[i]; + if (!Arch.bActive) { continue; } + const float ArchSDF = VoxelSDF::Capsule( + VoxPos, Arch.EndpointA, Arch.EndpointB, Arch.Radius); + + const float ArchBlend = 2.0f; + if (ArchSDF < ArchBlend) + { + float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + InOut.Density += Fill * Arch.BaseDensity * 1.5f; + } + } + } + + /** N'AJOUTE que du solide ⇒ `FillOnly`. La décision spatiale est indépendante : elle + * consomme l'intervalle SDF publié par la source, comme les autres détails de salle. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + if (!(P.ArchDensity > 0.0f)) { return EVoxelOpEffect::Identity; } + if (P.BaseDensity > 0.0f) { return EVoxelOpEffect::FillOnly; } + if (P.BaseDensity < 0.0f) { return EVoxelOpEffect::CarveOnly; } + return EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return P.BaseDensity < 0.0f ? MaxAmplitude() : 0.0f; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return P.BaseDensity > 0.0f ? MaxAmplitude() : 0.0f; + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + float MaxAmplitude() const + { + if (!(P.ArchDensity > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.BaseDensity)) { return FLT_MAX; } + const float Result = FMath::Abs(P.BaseDensity) * 3.0f * 1.5f; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : COLONNES DE SALLE / ROOM COLUMNS (TunnelNetwork, STEP 4d) + //========================================================================= + // ⚠️ CE N'EST PAS `FGridColumnMod` (FlatPlain / CrystalChamber). Celui-là pose des cylindres sur + // une GRILLE MONDE et se tire par cellule ; celui-ci PARCOURT une liste PRÉ-CUITE par + // `BuildChunkCache`, salle par salle. `OPSTACK-DECOMPOSITION §1` les sépare explicitement. + // + // ⚠️⚠️ IL N'A **AUCUN** PARAMÈTRE DE STRATE, ET C'EST LE PIÈGE DE CE GROUPE. + // Le code d'origine n'écrit aucun `if (Params.ColumnDensity > 0)` : il itère la liste cuite, + // point. `FStrateGenerationParams::ColumnDensity` n'est JAMAIS lu par la cuisson non plus (elle + // lit `OpParams`, un struct NEUF où seul l'op de la salle a été appliqué). Donc : + // • mettre `ColumnDensity = 0` dans les params N'ÉTEINT PAS les colonnes ; + // • la seule façon d'avoir des colonnes est un `UVoxelTerrainOpDefinition` de type `Column` + // dans le pool de la strate ; + // • et la seule façon de PROUVER qu'elles ont tiré est de regarder `SDFCache.Columns.Num()`. + // C'est exactement la leçon des pits, une troisième fois. Le test l'applique au contrôle 3b. + class FRoomColumnMod final : public IVoxelDensityOp + { + public: + FRoomColumnMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("RoomColumnMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override + { + if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + + auto EvaluateColumn = [&](int32 ColumnIndex) + { + const FCachedColumn& Col = Rooms->GetCache().Columns[ColumnIndex]; + const float DX = WorldX - Col.CenterX; + const float DY = WorldY - Col.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Col.BoundXYRadiusSq) { return; } + + const float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius; + + const float ColBlend = 3.0f; + if (CylSDF < ColBlend) + { + float Fill = FMath::Clamp((ColBlend - CylSDF) / (ColBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + InOut.Density += Fill * Col.BaseDensity * 1.5f; + } + }; + VF_ForEachChunkSDFSpatialCandidate( + Rooms->GetCache().ColumnSpatialIndex, + Rooms->GetCache().Columns.Num(), WorldX, WorldY, EvaluateColumn, + VoxelGenLOD::ShouldUseSpatialIndex(false)); + } + + /** The cached column list is the complete source of this op's work. A valid empty list is + * therefore an exact Identity result; an invalid box cache stays conservative. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::FillOnly; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) + { + return EVoxelOpEffect::Identity; + } + const float Bound = Rooms->RoomColumnFillSupremumForLastBox(); + if (!(Bound > 0.0f)) { return EVoxelOpEffect::Identity; } + return VoxelMath::IsFinite(Bound) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Both; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return FLT_MAX; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return 0.0f; } + const float Bound = Rooms->RoomColumnFillSupremumForLastBox(); + return VoxelMath::IsFinite(Bound) ? 0.0f : Bound; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return 0.0f; } + return Rooms->RoomColumnFillSupremumForLastBox(); + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : DÔMES / CATHEDRAL CEILINGS (TunnelNetwork, STEP 4g) + //========================================================================= + // Un demi-ellipsoïde creusé VERS LE HAUT depuis un point ancré au-dessus du centre de la salle. + // Relatif à la salle comme les arches, même porte `CaveSDF < SDFBlendRadius`. + class FDomeMod final : public IVoxelDensityOp + { + public: + FDomeMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("DomeMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + + const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx(); + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.DomeDensity > 0.0f && CaveSDF < LP.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0)) + { + return; + } + + const FChunkSDFCache& Cache = Rooms->GetCache(); + if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod + const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx]; + + const int32 MaxDomes = 2; + + for (int32 i = 0; i < MaxDomes; i++) + { + const uint32 DH = VoxelHash::Mix(Room.Hash ^ (0xD0AE0u + (uint32)i * 8191u)); + + if (VoxelHash::ToFloat01(DH) > LP.DomeDensity) { continue; } + + const uint32 DH2 = VoxelHash::Mix(DH ^ 0xD0A0u); + const float DmX = Room.Center.X + VoxelHash::ToFloatSigned(DH2) * Room.RadiusXY * 0.4f; + const float DmY = Room.Center.Y + + VoxelHash::ToFloatSigned(VoxelHash::Mix(DH2)) * Room.RadiusXY * 0.4f; + + const uint32 DH3 = VoxelHash::Mix(DH2 ^ 0x90DEu); + const float DmRadius = FMath::Min( + FMath::Lerp(LP.DomeMinRadius, LP.DomeMaxRadius, VoxelHash::ToFloat01(DH3)), + Room.RadiusXY * 0.85f + ); + + const uint32 DH4 = VoxelHash::Mix(DH3 ^ 0xCAFEu); + const float DmCenterZ = Room.Center.Z + Room.RadiusZ * 0.2f + + VoxelHash::ToFloat01(DH4) * Room.RadiusZ * 0.3f; + + const float DmHeight = DmRadius * LP.DomeHeightRatio; + + if (WorldZ > DmCenterZ + DmHeight + 3.0f || WorldZ < DmCenterZ - 3.0f) { continue; } + + const float DXDm = WorldX - DmX; + const float DYDm = WorldY - DmY; + const float DZDm = WorldZ - DmCenterZ; + + if (DZDm < 0.0f) { continue; } // ne creuse que vers le haut + + const float NormX = DXDm / DmRadius; + const float NormY = DYDm / DmRadius; + const float NormZ = DZDm / DmHeight; + const float EllipDist = FMath::Sqrt(NormX * NormX + NormY * NormY + NormZ * NormZ) - 1.0f; + const float DomeSDF = EllipDist * FMath::Min(DmRadius, DmHeight); + + const float DmBlend = 3.0f; + if (DomeSDF < DmBlend) + { + float Carve = FMath::Clamp((DmBlend - DomeSDF) / (DmBlend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + InOut.Density -= Carve * LP.BaseDensity * 1.5f; + } + } + } + + /** Ne SOUSTRAIT que ⇒ `CarveOnly`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.DomeDensity > 0.0f) ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return 0.0f; + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override + { + return 0.0f; + } + + float MaxAmplitude() const + { + if (!(P.DomeDensity > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.BaseDensity)) { return FLT_MAX; } + const float Result = FMath::Abs(P.BaseDensity) * 2.0f * 1.5f; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : PINCEMENT / BOTTLENECK (TunnelNetwork, STEP 4h) + //========================================================================= + // Resserre un passage PAR LES CÔTÉS. Placé sur le PÉRIMÈTRE de la salle (offset 0.85 · rayon) — + // là où les tunnels débouchent — et pas au centre, sinon il boucherait la salle elle-même. + // `SideFactor` (distance à l'axe, clampée) est ce qui laisse l'axe du passage libre : le + // remplissage est nul sur l'axe et maximal sur les bords de l'ellipsoïde. + class FPinchMod final : public IVoxelDensityOp + { + public: + FPinchMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("PinchMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + + const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx(); + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.PinchDensity > 0.0f && CaveSDF < LP.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0)) + { + return; + } + + const FChunkSDFCache& Cache = Rooms->GetCache(); + if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod + const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx]; + + for (int32 i = 0; i < 3; i++) + { + const FCachedPinch& Pinch = Room.Pinches[i]; + if (!Pinch.bActive) { continue; } + const float DXPn = WorldX - Pinch.CenterX; + const float DYPn = WorldY - Pinch.CenterY; + const float DZPn = WorldZ - Pinch.CenterZ; + + if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > Pinch.MaxExtent) { continue; } + + const float Along = DXPn * Pinch.CosAngle + DYPn * Pinch.SinAngle; + const float Across = -DXPn * Pinch.SinAngle + DYPn * Pinch.CosAngle; + + const float NAlong = Along / Pinch.HalfLength; + const float NAcross = Across / Pinch.HalfNarrow; + const float NUp = DZPn / Pinch.HalfVertical; + const float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp; + + if (EllipDist < 1.0f) + { + float Fill = 1.0f - EllipDist; + Fill = SmoothStep01(Fill); + const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp); + const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f); + InOut.Density += Fill * SideFactor * Pinch.BaseDensity * 1.5f; + } + } + } + + /** N'AJOUTE que du solide ⇒ `FillOnly`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.PinchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, 0.0f); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + float MaxAmplitude() const + { + if (!(P.PinchDensity > 0.0f)) { return 0.0f; } + if (!VoxelMath::IsFinite(P.BaseDensity)) { return FLT_MAX; } + const float Result = FMath::Abs(P.BaseDensity) * 3.0f * 1.5f; + return VoxelMath::IsFinite(Result) ? Result : FLT_MAX; + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 3 — MODIFIER : BIAIS DE SOL / FLOOR BIAS (TunnelNetwork, fin de 4h) + //========================================================================= + // Rend de la densité dans la moitié BASSE de la salle pour contrer le relief que la rugosité + // laisse sur les sols — un sol praticable au lieu d'un sol bosselé. Ne s'applique QUE dans l'air + // certain (`CaveSDF < 0`) : dans la paroi, le clamp anti-remplissage de la rugosité tient déjà. + // + // ⚠️ DERNIER DE LA CHAÎNE, ET CE N'EST PAS INTERCHANGEABLE : il ajoute un biais indépendant à + // la densité déjà accumulée par les étapes précédentes. Il ne lit ni ne soustrait la variation + // de rugosité (4b). Le déplacer avant les autres étapes changerait donc leur entrée et leur + // sortie, ce qui est la raison pour laquelle l'ordre de `BuildTunnelNetworkStack` reste celui + // de l'original, ligne pour ligne. + // + // This op is intentionally separate from 4b. Ten operators lie + // between 4b and this phase (4c through 4h), and every one reads+writes Density. Moving this + // phase next to 4b would reorder those density updates, so a bit-identical fuse is not legal. + class FFloorBiasMod final : public IVoxelDensityOp + { + public: + FFloorBiasMod(const FStrateGenerationParams& InP, const FRoomGraphSource* InRooms) + : P(InP), Rooms(InRooms) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } + const TCHAR* DebugName() const override { return TEXT("FloorBiasMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override + { + const float CaveSDF = InOut.Sdf; + if (!VF_NearCaveSurface(CaveSDF, P.SDFBlendRadius)) { return; } + if (Rooms == nullptr) { return; } + + const int32 NearestRoomIdx = Rooms->GetNearestRoomIdx(); + const FStrateGenerationParams& LP = Rooms->LocalParams(); // C1 : params PAR SALLE + if (!(LP.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f)) { return; } + + const FChunkSDFCache& Cache = Rooms->GetCache(); + if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod + const FCachedRoom& NR = Cache.Rooms[NearestRoomIdx]; + + // NormZ : -1 = sol de la salle, 0 = centre, +1 = plafond. + const float NormZ = (WorldZ - NR.Center.Z) / FMath::Max(NR.RadiusZ, 1.0f); + + if (NormZ < 0.0f) + { + const float FloorFactor = NormZ * NormZ; // 0 au centre, 1 au sol + InOut.Density += FloorFactor * LP.FloorBias; + } + } + + /** N'AJOUTE que du solide ⇒ `FillOnly`. */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return (P.FloorBias > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return VoxelMath::IsFinite(P.FloorBias) && P.FloorBias >= 0.0f + ? P.FloorBias : (P.FloorBias < 0.0f ? 0.0f : FLT_MAX); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? 0.0f : 0.0f; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return 0.0f; } + return Rooms->FloorBiasFillSupremumForLastBox(); + } + + private: + FStrateGenerationParams P; + const FRoomGraphSource* Rooms; // NON possédant + }; + + //========================================================================= + // RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork) + //========================================================================= + // Un carve par SEUIL sur du bruit 3D, masqué par la distance au réseau de salles. Il écrit la + // DENSITÉ directement (pas le canal SDF) : c'est une source « fieldée », pas une primitive + // placée — la distinction que `AUDIT §6.2` pose et que `OPSTACK-DECOMPOSITION §0.2` chiffre. + // + // ⚠️ IL LIT `InOut.Sdf` : le masque de réseau est une fonction de `CaveSDF` APRÈS pits et + // cheminées. C'est encore le canal SDF utilisé comme ce pour quoi il existe — transporter une + // information géométrique entre deux opérateurs au lieu de la recalculer. + class FWormFieldSource final : public IVoxelDensityOp + { + public: + FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed) + : P(InP) + , SeedU((uint32)Seed) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } + EVoxelOpChannelMask ChannelReads() const override + { + return VoxelOpChannels::Density | VoxelOpChannels::Sdf; + } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return true; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override + { + if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return; } + + const float CaveSDF = InOut.Sdf; + + float NetworkMask = 1.0f; + if (P.WormNetworkRange > 0.0f) + { + if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX) + { + NetworkMask = 0.0f; + } + else if (CaveSDF > 0.0f) + { + NetworkMask = 1.0f - SmoothStep01(CaveSDF / P.WormNetworkRange); + } + } + + if (NetworkMask <= 0.0f) { return; } + + const VoxelWormField::FParameters WormParameters{ + P.WormFrequency, + P.WormHorizontalBias, + P.VerticalScale, + SeedU}; + const float WormValue = VoxelWormField::Evaluate( + WorldX, WorldY, WorldZ, P.WormThreshold, + WormParameters); + if (WormValue < P.WormThreshold) + { + const float t = 1.0f - (WormValue / P.WormThreshold); + InOut.Density -= t * P.WormStrength * NetworkMask; + } + } + + /** + * The worm is a density consumer, not an SDF source. Its intrinsic response is `CarveOnly`. + * The state-aware fold may prove it inactive only when the interval already published by a + * preceding SDF writer proves `Sdf >= WormNetworkRange` throughout the box. An unknown or + * unrelated interval leaves the carve active, which is conservative for free composition. + * Its magnitude is independently bounded by `WormStrength` because both `t` and + * `NetworkMask` are in [0, 1]. + */ + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return EVoxelOpEffect::Identity; } + return EVoxelOpEffect::CarveOnly; + } + + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); + if (Intrinsic == EVoxelOpEffect::Identity) { return Intrinsic; } + if (P.WormNetworkRange > 0.0f && H.Sdf.IsKnown() + && H.Sdf.Min >= P.WormNetworkRange) + { + return EVoxelOpEffect::Identity; + } + return Intrinsic; + } + + /** + * ✅ **LA BORNE DE `§0.2`, MAINTENANT CONSOMMÉE.** Elle a passé plusieurs entrées de journal + * écrite mais inutilisée, faute d'un pliage capable de porter un nombre ; ce pliage existe. + * + * La preuve tient en une ligne : `t = 1 − WormValue/WormThreshold ∈ [0,1]` (le bloc ne + * s'exécute que sous le seuil) et `NetworkMask ∈ [0,1]` par construction, donc + * `t · WormStrength · NetworkMask ≤ WormStrength`. C'est une borne PROUVÉE, pas prudente — + * la seule sorte qui ait le droit d'être ici : sur-estimer coûte du CPU, sous-estimer fait + * un trou. + */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxCarveAmplitude(); + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + const float Strength = MaxCarveOverBox(VoxelBox, Ctx); + if (!(Strength > 0.0f) || !VoxelMath::IsFinite(Strength)) + { + return Strength; + } + if (!(P.WormNetworkRange > 0.0f) || !VoxelMath::IsFinite(P.WormNetworkRange) + || !H.Sdf.IsKnown() || !VoxelMath::IsFinite(H.Sdf.Min)) + { + return Strength; + } + + // The classifier's lattice proof is about the exact MC vertices, not the continuum + // between them. On a refined child, evaluate the worm's two threshold noises at + // those vertices once and bound the actual carve factor there. The root is left on + // the cheap interval path; a refined step-32 child has at most 17^3 vertices and the + // explicit count cap keeps this check bounded. The network mask still uses the SDF + // interval's worst case, so this never assumes a room value that the preceding source + // did not prove. + if (Ctx.bUseLatticeProof && Ctx.bTightenWarpProof && Ctx.Step >= 1 + && VoxelMath::IsFinite(P.WormFrequency) + && VoxelMath::IsFinite(P.WormHorizontalBias) + && VoxelMath::IsFinite(P.WormThreshold) + && P.WormThreshold > 0.0f + && VoxelMath::IsFinite(P.VerticalScale)) + { + const int32 Step = FMath::Max(Ctx.Step, 1); + const float OriginX = static_cast(Ctx.LatticeOriginVoxels.X); + const float OriginY = static_cast(Ctx.LatticeOriginVoxels.Y); + const float OriginZ = static_cast(Ctx.LatticeOriginVoxels.Z); + const int32 IX0 = FMath::CeilToInt( + ((float)VoxelBox.Min.X - OriginX) / (float)Step - 1.0e-4f); + const int32 IY0 = FMath::CeilToInt( + ((float)VoxelBox.Min.Y - OriginY) / (float)Step - 1.0e-4f); + const int32 IZ0 = FMath::CeilToInt( + ((float)VoxelBox.Min.Z - OriginZ) / (float)Step - 1.0e-4f); + const int32 IX1 = FMath::FloorToInt( + ((float)VoxelBox.Max.X - OriginX) / (float)Step + 1.0e-4f); + const int32 IY1 = FMath::FloorToInt( + ((float)VoxelBox.Max.Y - OriginY) / (float)Step + 1.0e-4f); + const int32 IZ1 = FMath::FloorToInt( + ((float)VoxelBox.Max.Z - OriginZ) / (float)Step + 1.0e-4f); + const int64 Count = ((int64)IX1 - IX0 + 1) + * ((int64)IY1 - IY0 + 1) + * ((int64)IZ1 - IZ0 + 1); + // A full LOD0 root is intentionally left on the cheap interval path. Refined + // child boxes are at most 17^3 lattice vertices, so their exact threshold bound + // couples the worm's spatial maximum to each child's SDF lower bound without + // evaluating the room graph 35,937 times. + if (IX1 >= IX0 && IY1 >= IY0 && IZ1 >= IZ0 + && Count > 0 && Count <= 10000) + { + float MaxThresholdFactor = 0.0f; + const float WormZFrequency = P.WormFrequency * P.WormHorizontalBias; + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float WorldX = OriginX + (float)IX * (float)Step; + const float WorldY = OriginY + (float)IY * (float)Step; + const float WorldZ = OriginZ + (float)IZ * (float)Step; + const float EffectiveZ = (P.VerticalScale != 1.0f + && P.VerticalScale > 0.0f) + ? WorldZ / P.VerticalScale : WorldZ; + const FVector3f NoiseBase( + WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), + WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), + EffectiveZ * WormZFrequency + VoxelHash::SeedOffset(SeedU, 2.3f)); + const float N1 = FMath::Abs(VoxelNoise::Perlin3D(NoiseBase) + * VOXEL_NOISE_SCALE); + if (N1 >= P.WormThreshold) { continue; } + + const FVector3f NoiseOffset( + 137.0f, 259.0f, 431.0f); + const float N2 = FMath::Abs(VoxelNoise::Perlin3D( + NoiseBase + NoiseOffset) * VOXEL_NOISE_SCALE); + const float WormValue = N1 + N2; + if (WormValue < P.WormThreshold) + { + MaxThresholdFactor = FMath::Max( + MaxThresholdFactor, + 1.0f - WormValue / P.WormThreshold); + } + } + + if (!(MaxThresholdFactor > 0.0f)) { return 0.0f; } + float MaxNetworkMask = 1.0f; + if (H.Sdf.Min >= P.WormNetworkRange) + { + MaxNetworkMask = 0.0f; + } + else if (H.Sdf.Min > 0.0f) + { + MaxNetworkMask = 1.0f - SmoothStep01( + FMath::Clamp(H.Sdf.Min / P.WormNetworkRange, 0.0f, 1.0f)); + } + return Strength * MaxThresholdFactor + * FMath::Clamp(MaxNetworkMask, 0.0f, 1.0f); + } + } + + // NetworkMask is monotone non-increasing in CaveSDF. The source interval's lower + // endpoint therefore gives a sound upper bound for the mask across the whole box, + // not just the old all-or-nothing test at WormNetworkRange. This matters in deep + // solid: a positive SDF can leave only a fraction of the worm amplitude, enough for + // the constant-rock margin to survive even when the full WormStrength would not. + float MaxNetworkMask = 1.0f; + if (H.Sdf.Min >= P.WormNetworkRange) + { + MaxNetworkMask = 0.0f; + } + else if (H.Sdf.Min > 0.0f) + { + MaxNetworkMask = 1.0f - SmoothStep01( + FMath::Clamp(H.Sdf.Min / P.WormNetworkRange, 0.0f, 1.0f)); + } + return Strength * FMath::Clamp(MaxNetworkMask, 0.0f, 1.0f); + } + + /** Le ver ne REMPLIT jamais : `InOut.Density -= …` avec un terme positif. */ + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return 0.0f; + } + + /** L'amplitude max de carve, en unités de densité. Borne BRUTE : elle ignore la portée du + * réseau, c'est `MaxCarveOverBox` qui l'applique. */ + float MaxCarveAmplitude() const + { + if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return 0.0f; } + return VoxelMath::IsFinite(P.WormStrength) ? P.WormStrength : FLT_MAX; + } + + const TCHAR* DebugName() const override { return TEXT("WormFieldSource"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } + + private: + FStrateGenerationParams P; + uint32 SeedU; + }; + +#if WITH_EDITOR + //============================================================================= + // RÔLE 2 — COMBINER : RÉGIONS LATÉRALES + //============================================================================= + // The creative stacks stay independent. This op owns only their density-level junction; + // the parent FVoxelOpStack appends the global structural posts after it. In particular, no + // recipe can make the spine, vertical seal, passage carve, or XY edge seal region-local. + class FLateralRegionBlendOp final : public IVoxelDensityOp + { + public: + FLateralRegionBlendOp(const FVoxelStrateRegionManifest& InManifest, + TArray&& InStacks) + : RegionStacks(MoveTemp(InStacks)) + { + // Keep only partition metadata here. The native vectors/recipes have already been + // consumed by the stacks and must not be duplicated in the voxel operator. + PartitionManifest.bValid = true; + PartitionManifest.Seed = InManifest.Seed; + PartitionManifest.StrateIndex = InManifest.StrateIndex; + PartitionManifest.RegionCount = InManifest.RegionCount; + PartitionManifest.PartitionSeed = InManifest.PartitionSeed; + PartitionManifest.LatticeCellSize = InManifest.LatticeCellSize; + PartitionManifest.BlendWidth = InManifest.BlendWidth; + PartitionManifest.Regions.SetNum(InManifest.RegionCount); + } + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + bool IsAdditive() const override { return false; } + + void PrepareChunk(const FVoxelOpContext& Ctx) override + { + PartitionCache.PrepareForChunk(PartitionManifest, Ctx.ChunkCoord); + bPrepared = true; + for (FVoxelOpStack& Stack : RegionStacks) + { + Stack.PrepareChunk(Ctx); + } + } + + void Eval(float WorldX, float WorldY, float WorldZ, + FVoxelOpSample& InOut) const override + { + if (RegionStacks.Num() == 0) { return; } + + const FVoxelStrateRegionQuery Query = bPrepared + ? PartitionCache.Query(WorldX, WorldY) + : VF_QueryStrateRegion(PartitionManifest, WorldX, WorldY); + const int32 Primary = FMath::Clamp(Query.PrimaryRegion, 0, RegionStacks.Num() - 1); + const float PrimaryDensity = RegionStacks[Primary].EvalInternal( + WorldX, WorldY, WorldZ); + float Density = PrimaryDensity; + + if (Query.NeighborWeight > 0.0f && Query.NeighborRegion != INDEX_NONE + && RegionStacks.IsValidIndex(Query.NeighborRegion) + && Query.NeighborRegion != Primary) + { + const float NeighborDensity = RegionStacks[Query.NeighborRegion].EvalInternal( + WorldX, WorldY, WorldZ); + Density = FMath::Lerp(PrimaryDensity, NeighborDensity, Query.NeighborWeight); + } + InOut.Density = Density; + } + + /** + * A box wholly outside the band can use its one creative stack. A box that may touch a + * bisector or the band asks every region stack. If any is Mixed, the parent is Mixed. If + * all possible stacks agree, their convex density blend has the same sign everywhere, so + * the blend band is proved uniform too. This is deliberately stricter than sampling a + * center point and is the protection against a cross-region false uniform tile. + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override + { + if (RegionStacks.Num() == 0) { return EVoxelTileClass::Mixed; } + + const FVoxelStrateRegionBoxProof Proof = + VF_AnalyzeStrateRegionBox(PartitionManifest, VoxelBox); + if (Proof.bProvablySingleRegion) + { + const FVoxelStrateRegionQuery Query = VF_QueryStrateRegion( + PartitionManifest, + ((float)VoxelBox.Min.X + (float)VoxelBox.Max.X) * 0.5f, + ((float)VoxelBox.Min.Y + (float)VoxelBox.Max.Y) * 0.5f); + const int32 Primary = FMath::Clamp( + Query.PrimaryRegion, 0, RegionStacks.Num() - 1); + return RegionStacks[Primary].ClassifyBox(VoxelBox, Ctx); + } + + EVoxelTileClass CommonVerdict = EVoxelTileClass::Mixed; + for (const FVoxelOpStack& Stack : RegionStacks) + { + const EVoxelTileClass Verdict = Stack.ClassifyBox(VoxelBox, Ctx); + if (Verdict == EVoxelTileClass::Mixed) + { + return EVoxelTileClass::Mixed; + } + if (CommonVerdict == EVoxelTileClass::Mixed) + { + CommonVerdict = Verdict; + } + else if (CommonVerdict != Verdict) + { + return EVoxelTileClass::Mixed; + } + } + return CommonVerdict; + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return EVoxelOpEffect::Both; + } + + const TCHAR* DebugName() const override { return TEXT("LateralRegionBlendOp"); } + + private: + FVoxelStrateRegionManifest PartitionManifest; + TArray RegionStacks; + mutable FVoxelStrateRegionPartitionCache PartitionCache; + mutable bool bPrepared = false; + }; +#endif + +} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. + // Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas + // (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade + // ajoutée avec elle ne ferme rien → C2059. + // END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line. + +//============================================================================= +// DIAGNOSTIC — voir la déclaration dans VoxelDensityOpStack.h +//============================================================================= + +VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic() +{ + const FRoomGraphSource::FBoxState& B = FRoomGraphSource::BoxState(); + + FRoomBoxDiagnostic D; + D.HitRooms = B.HitRooms; + D.HitTunnels = B.HitTunnels; + D.HitPits = B.HitPits; + D.HitChimneys = B.HitChimneys; + D.NumRooms = B.NumRooms; + D.NumTunnels = B.NumTunnels; + D.NumPits = B.NumPits; + D.NumChimneys = B.NumChimneys; + D.HitRoomsNoWarp = B.HitRoomsNoWarp; + D.HitTunnelsNoWarp = B.HitTunnelsNoWarp; + D.WarpDilation = B.WarpDilation; + D.bMayHaveTunnelCoreAir = B.bMayHaveTunnelCoreAir; + D.bMayHaveTunnelSupportFloor = B.bMayHaveTunnelSupportFloor; + D.bMayHaveTunnelCoreTail = B.bMayHaveTunnelCoreTail; + D.ExactTailQueries = B.ExactTailQueries; + D.ExactTailEvaluated = B.ExactTailEvaluated; + D.ExactTailCycles = B.ExactTailCycles; + D.PropagateCycles = B.PropagateCycles; + D.ExactPrimitiveCycles = B.ExactPrimitiveCycles; + D.CacheWindowCycles = B.CacheWindowCycles; + D.NumRooms = B.NumRooms; + D.NumTunnels = B.NumTunnels; + D.NumRoomFloorJoins = B.NumRoomFloorJoins; + D.NumPits = B.NumPits; + D.NumChimneys = B.NumChimneys; + return D; +} + +void VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint() +{ + if (!VoxelDensityProfile::IsEnabled()) + { + return; + } + + uint64 DynamicBytes = 0; + uint64 EntryBytes = 0; + uint64 LargestEntryBytes = 0; + uint64 ValidEntries = 0; + VoxelDensityProfile::FCacheMemoryBreakdown Breakdown; + for (int32 Bank = 0; Bank < RoomGraphCacheBankCount; ++Bank) + { + for (int32 Index = 0; Index < RoomGraphCacheSlotCount; ++Index) + { + const FRoomGraphCacheEntry& Entry = GRoomGraphCache[Bank][Index]; + if (!Entry.bValid) + { + continue; + } + + ++ValidEntries; + Breakdown += Entry.Cache.GetAllocatedSizeBreakdown(); + const uint64 EntryDynamicBytes = static_cast(Entry.Cache.GetAllocatedSize()); + const uint64 FullEntryBytes = static_cast(sizeof(FRoomGraphCacheEntry)) + + EntryDynamicBytes; + DynamicBytes += EntryDynamicBytes; + EntryBytes += FullEntryBytes; + LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); + } + } + Breakdown.SlotStorageBytes = static_cast(sizeof(GRoomGraphCache)); + + VoxelDensityProfile::SetWorkerRoomGraphCacheFootprint( + RoomGraphCacheBankCount * RoomGraphCacheSlotCount, + ValidEntries, + static_cast(sizeof(GRoomGraphCache)), + DynamicBytes, + EntryBytes, + LargestEntryBytes, + ValidEntries, + Breakdown); +} + +void VoxelDensityOps::ReportSharedRoomGraphCacheFootprint() +{ + uint64 ReadyEntries = 0; + uint64 BuildingEntries = 0; + uint64 LargestEntryBytes = 0; + uint64 CacheBytes = 0; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + for (const TSharedPtr& Entry + : GSharedRoomGraphCache) + { + if (Entry->bBuilding) + { + ++BuildingEntries; + } + else + { + ++ReadyEntries; + LargestEntryBytes = FMath::Max(LargestEntryBytes, Entry->AllocatedBytes); + CacheBytes += Entry->AllocatedBytes; + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSharedCacheMemory] entries=%d ready=%llu building=%llu " + "resident_bytes=%llu resident_mib=%.2f budget_bytes=%llu " + "largest_entry_bytes=%llu"), + GSharedRoomGraphCache.Num(), + static_cast(ReadyEntries), + static_cast(BuildingEntries), + static_cast(CacheBytes), + static_cast(CacheBytes) / (1024.0 * 1024.0), + static_cast(SharedRoomGraphCacheBudgetBytes), + static_cast(LargestEntryBytes)); + } +} + +void VoxelDensityOps::TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId) +{ + uint64 BeforeBytes = 0; + uint64 AfterBytes = 0; + int32 BeforeEntries = 0; + int32 AfterEntries = 0; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + BeforeBytes = GSharedRoomGraphCacheResidentBytes; + BeforeEntries = GSharedRoomGraphCache.Num(); + RemoveStaleSharedRoomGraphCachesLocked(KeepManagerLifetimeId); + AfterBytes = GSharedRoomGraphCacheResidentBytes; + AfterEntries = GSharedRoomGraphCache.Num(); + } + if (BeforeBytes != AfterBytes || BeforeEntries != AfterEntries) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSharedCacheMemory] trim keep_lifetime=%llu entries=%d->%d " + "resident_bytes=%llu->%llu"), + static_cast(KeepManagerLifetimeId), + BeforeEntries, AfterEntries, + static_cast(BeforeBytes), + static_cast(AfterBytes)); + } +} + +//============================================================================= +// FVoxelOpStack +//============================================================================= + +SIZE_T FVoxelOpStack::GetAllocatedSize() const +{ + SIZE_T Bytes = static_cast(Ops.GetAllocatedSize()); + for (const FOpEntry& Entry : Ops) + { + if (Entry.Op.IsValid()) + { + Bytes += FMemory::GetAllocSize(const_cast(Entry.Op.Get())); + } + } + return Bytes; +} + +bool FVoxelOpStack::TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const +{ + // The last provider wins in the same way the structural tail is ordered. At present the + // tunnel source is the only provider; walking backwards keeps this safe if a composed stack + // later adds another structural core provider. + for (int32 Index = Ops.Num() - 1; Index >= 0; --Index) + { + if (Ops[Index].Op->TryGetLastTunnelCoreWorldEvaluation(OutEvaluation)) + { + return true; + } + } + return false; +} + +void FVoxelOpStack::DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + int32 SampleStep, TArray& OutDiagnostics, + EVoxelTileClass* OutVerdict) const +{ + OutDiagnostics.Reset(); + OutDiagnostics.SetNum(Ops.Num()); + if (OutVerdict != nullptr) + { + *OutVerdict = EVoxelTileClass::Mixed; + } + + if (!VoxelBox.IsValid || SampleStep <= 0) + { + return; + } + + FVoxelBoxHypotheses H; + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + FOpBoxDiagnostic& Diagnostic = OutDiagnostics[Index]; + Diagnostic.Index = Index; + Diagnostic.Name = Entry.Op.IsValid() ? FString(Entry.Op->DebugName()) : TEXT("(null op)"); + Diagnostic.bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0; + + if (!Entry.Op.IsValid()) + { + continue; + } + + // This is intentionally the same order and branch structure as VF_FoldOp. The diagnostic + // must explain the production classifier, not an approximation of it. + const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx); + Diagnostic.ForcedVerdict = Forced; + Diagnostic.bForced = Forced != EVoxelTileClass::Mixed; + if (Diagnostic.bForced) + { + Diagnostic.Effect = EVoxelOpEffect::Identity; + Diagnostic.ForcedMargin = Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx); + VF_ForceHypotheses(H, Forced, Diagnostic.ForcedMargin); + if (Diagnostic.bWritesSdf) + { + Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); + Diagnostic.bHasSdfBound = H.Sdf.IsKnown(); + if (Diagnostic.bHasSdfBound) + { + Diagnostic.SdfBoundMin = H.Sdf.Min; + Diagnostic.SdfBoundMax = H.Sdf.Max; + } + } + } + else + { + Diagnostic.Effect = Entry.Op->EffectOverBox(VoxelBox, Ctx, H); + Diagnostic.MaxCarve = Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H); + Diagnostic.MaxFill = Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H); + VF_FoldEffect(H, Diagnostic.Effect, Diagnostic.MaxCarve, Diagnostic.MaxFill); + if (Diagnostic.bWritesSdf) + { + Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); + Diagnostic.bHasSdfBound = H.Sdf.IsKnown(); + if (Diagnostic.bHasSdfBound) + { + Diagnostic.SdfBoundMin = H.Sdf.Min; + Diagnostic.SdfBoundMax = H.Sdf.Max; + } + } + } + } + + if (OutVerdict != nullptr) + { + *OutVerdict = H.Resolve(); + } + + const int32 MinX = FMath::CeilToInt(static_cast(VoxelBox.Min.X)); + const int32 MinY = FMath::CeilToInt(static_cast(VoxelBox.Min.Y)); + const int32 MinZ = FMath::CeilToInt(static_cast(VoxelBox.Min.Z)); + const int32 MaxX = FMath::FloorToInt(static_cast(VoxelBox.Max.X)); + const int32 MaxY = FMath::FloorToInt(static_cast(VoxelBox.Max.Y)); + const int32 MaxZ = FMath::FloorToInt(static_cast(VoxelBox.Max.Z)); + if (MinX > MaxX || MinY > MaxY || MinZ > MaxZ) + { + return; + } + + constexpr float BoundEpsilon = 1.0e-4f; + for (int32 Z = MinZ; Z <= MaxZ; ) + { + for (int32 Y = MinY; Y <= MaxY; ) + { + for (int32 X = MinX; X <= MaxX; ) + { + FVoxelOpSample Sample; + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + FOpBoxDiagnostic& Diagnostic = OutDiagnostics[Index]; + if (!Entry.Op.IsValid()) + { + continue; + } + + const float DensityBefore = Sample.Density; + Entry.Op->Eval(static_cast(X), static_cast(Y), + static_cast(Z), Sample); + const float DensityDelta = Sample.Density - DensityBefore; + if (VoxelMath::IsFinite(DensityDelta)) + { + Diagnostic.bHasDensityDelta = true; + Diagnostic.ActualDensityDeltaMin = + FMath::Min(Diagnostic.ActualDensityDeltaMin, DensityDelta); + Diagnostic.ActualDensityDeltaMax = + FMath::Max(Diagnostic.ActualDensityDeltaMax, DensityDelta); + + // A forced classifier is a stronger statement than a directional delta + // bound. Its density can legitimately replace an arbitrary incoming + // value (the constant source is the canonical example), so do not call + // that replacement a MaxFill/MaxCarve violation. ForcedMargin records the + // proof margin separately for the report. + if (!Diagnostic.bForced) + { + const float CarveEpsilon = BoundEpsilon + * FMath::Max(1.0f, FMath::Abs(Diagnostic.MaxCarve)); + const float FillEpsilon = BoundEpsilon + * FMath::Max(1.0f, FMath::Abs(Diagnostic.MaxFill)); + if (VoxelMath::IsFinite(Diagnostic.MaxCarve) + && DensityDelta < -Diagnostic.MaxCarve - CarveEpsilon) + { + Diagnostic.bCarveBoundViolated = true; + } + if (VoxelMath::IsFinite(Diagnostic.MaxFill) + && DensityDelta > Diagnostic.MaxFill + FillEpsilon) + { + Diagnostic.bFillBoundViolated = true; + } + } + } + + if (Diagnostic.bWritesSdf && VoxelMath::IsFinite(Sample.Sdf)) + { + Diagnostic.bHasSdfValue = true; + Diagnostic.ActualSdfMin = FMath::Min( + Diagnostic.ActualSdfMin, Sample.Sdf); + Diagnostic.ActualSdfMax = FMath::Max( + Diagnostic.ActualSdfMax, Sample.Sdf); + } + ++Diagnostic.SampleCount; + } + + if (X > MaxX - SampleStep) { break; } + X += SampleStep; + } + if (Y > MaxY - SampleStep) { break; } + Y += SampleStep; + } + if (Z > MaxZ - SampleStep) { break; } + Z += SampleStep; + } +} + +bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const +{ + if (OutError != nullptr) { OutError->Reset(); } + + auto DescribeMask = [](EVoxelOpChannelMask Mask) -> FString + { + FString Result; + if ((Mask & VoxelOpChannels::Density) != 0) { Result += TEXT("Density"); } + if ((Mask & VoxelOpChannels::Sdf) != 0) + { + if (!Result.IsEmpty()) { Result += TEXT(", "); } + Result += TEXT("Sdf"); + } + return Result.IsEmpty() ? TEXT("none") : Result; + }; + + auto DescribeResourceMask = [](EVoxelOpResourceMask Mask) -> FString + { + FString Result; + if ((Mask & VoxelOpResources::RoomGeometry) != 0) { Result += TEXT("RoomGeometry"); } + if ((Mask & VoxelOpResources::ShaftGeometry) != 0) + { + if (!Result.IsEmpty()) { Result += TEXT(", "); } + Result += TEXT("ShaftGeometry"); + } + if ((Mask & VoxelOpResources::SurfaceColumn) != 0) + { + if (!Result.IsEmpty()) { Result += TEXT(", "); } + Result += TEXT("SurfaceColumn"); + } + return Result.IsEmpty() ? TEXT("none") : Result; + }; + + auto Fail = [&](int32 Index, const FOpEntry* Entry, const TCHAR* Rule) -> bool + { + if (OutError != nullptr) + { + const TCHAR* Name = (Entry != nullptr && Entry->Op.Get() != nullptr) + ? Entry->Op->DebugName() : TEXT("(null op)"); + const EVoxelOpChannelMask Reads = Entry != nullptr ? Entry->Reads : VoxelOpChannels::None; + const EVoxelOpChannelMask Writes = Entry != nullptr ? Entry->Writes : VoxelOpChannels::None; + *OutError = FString::Printf( + TEXT("op %d (%s) violates stack DAG: %s; reads=[%s], writes=[%s], requires=[%s], provides=[%s]"), + Index, Name, Rule, *DescribeMask(Reads), *DescribeMask(Writes), + *DescribeResourceMask(Entry != nullptr ? Entry->RequiredResources : VoxelOpResources::None), + *DescribeResourceMask(Entry != nullptr ? Entry->ProvidedResources : VoxelOpResources::None)); + } + return false; + }; + + // The two array slots are deliberately explicit: FVoxelOpSample has exactly two fields, and + // adding a third field requires extending EVoxelOpChannel and this validator together. + int32 LastWriter[2] = { INDEX_NONE, INDEX_NONE }; + EVoxelOpResourceMask AvailableResources = VoxelOpResources::None; + + auto ChannelIndex = [](EVoxelOpChannelMask Channel) -> int32 + { + return Channel == VoxelOpChannels::Density ? 0 : 1; + }; + + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + if (Entry.Op.Get() == nullptr) { return Fail(Index, &Entry, TEXT("null operator")); } + + if ((Entry.RequiredResources & VoxelOpResources::All) != Entry.RequiredResources) + { + return Fail(Index, &Entry, TEXT("requires an unknown op resource")); + } + if ((Entry.ProvidedResources & VoxelOpResources::All) != Entry.ProvidedResources) + { + return Fail(Index, &Entry, TEXT("provides an unknown op resource")); + } + if ((Entry.RequiredResources + & static_cast(~AvailableResources)) != 0) + { + return Fail(Index, &Entry, + TEXT("requires op state before an earlier provider published it")); + } + + if ((Entry.Reads & VoxelOpChannels::All) != Entry.Reads) + { + return Fail(Index, &Entry, TEXT("reads an unknown sample channel")); + } + if ((Entry.Writes & VoxelOpChannels::All) != Entry.Writes) + { + return Fail(Index, &Entry, TEXT("writes an unknown sample channel")); + } + + if (Entry.bAdditive && Entry.Writes == VoxelOpChannels::None) + { + return Fail(Index, &Entry, TEXT("an additive operator must publish a channel")); + } + if (Entry.bAdditive + && (Entry.Writes & static_cast(~Entry.Reads)) != 0) + { + return Fail(Index, &Entry, + TEXT("an additive operator must read every channel it writes")); + } + + const EVoxelOpChannelMask Channels[] = { + VoxelOpChannels::Density, VoxelOpChannels::Sdf + }; + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Entry.Reads & Channel) == 0) { continue; } + + const int32 Writer = LastWriter[ChannelIndex(Channel)]; + const bool bRootIdentityFold = Entry.Op->GetRole() == EVoxelOpRole::FieldSource + && (Entry.Writes & Channel) != 0 + && Writer == INDEX_NONE; + if (Writer == INDEX_NONE && !bRootIdentityFold) + { + return Fail(Index, &Entry, + TEXT("reads a channel before a producer has published it")); + } + } + + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Entry.Writes & Channel) == 0 || (Entry.Reads & Channel) != 0) { continue; } + + // A write-only op is an assignment/replacement. It may establish the first version, + // but it may not erase a previously produced channel without declaring a read of it. + if (LastWriter[ChannelIndex(Channel)] != INDEX_NONE) + { + return Fail(Index, &Entry, + TEXT("a write-only replacement would clobber an existing channel")); + } + } + + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Entry.Writes & Channel) != 0) + { + // Every read above has either consumed this writer's predecessor or the explicit + // root identity. Updating the version here makes all later consumer edges forward. + LastWriter[ChannelIndex(Channel)] = Index; + } + } + + AvailableResources |= Entry.ProvidedResources; + } + + return true; +} + +void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, + float SealThickness, float BaseDensity, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendEdgeSeal) +{ + // ORDRE NON NÉGOCIABLE : la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal), + // le seal vertical re-solidifie ses bandes, les passages (tube + landing + support) percent + // les seals, puis la limite XY gagne sur tout ce qui précède. Ainsi, même un passage placé + // dans la rampe ou au-delà du rayon ne peut pas ouvrir la coque extérieure. Les éditions joueur + // restent le dernier post de GetDensityAt, hors de cette pile, comme avant. + Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius)); + Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity)); + Add(MakeUnique(StrateManager, BaseDensity, SealThickness, + StrateTopWorldZ, StrateBottomWorldZ, SpineRadius)); + if (bAppendEdgeSeal) + { + Add(MakeUnique(BaseDensity)); + } +} + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelDensityOps +{ + bool GetStrateOpContract(EVoxelStrateOpClass OpClass, FVoxelStrateOpContract& OutContract) + { + const FStrateGenerationParams TunnelParams; + const FSlabGenerationParams SlabParams; + const FMazeGenerationParams MazeParams; + const FSurfaceGenerationParams SurfaceParams; + const FVerticalShaftParams ShaftParams; + const FFloatingIslandParams IslandParams; + + TUniquePtr Probe; + switch (OpClass) + { + case EVoxelStrateOpClass::ConstantRockSource: + Probe = MakeConstantRockSource(TunnelParams.BaseDensity); + break; + case EVoxelStrateOpClass::ConstantVoidSource: + Probe = MakeConstantVoidSource(IslandParams.BaseDensity); + break; + case EVoxelStrateOpClass::RoomGraphSource: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::LatticeCorridorSource: + Probe = MakeUnique(MazeParams, 0); + break; + case EVoxelStrateOpClass::ShaftFieldSource: + Probe = MakeUnique(ShaftParams, 0, 14.0f); + break; + case EVoxelStrateOpClass::IslandBlobSource: + Probe = MakeUnique(IslandParams, 0); + break; + case EVoxelStrateOpClass::NoiseRibbonSource: + Probe = MakeUnique(MazeParams, 0); + break; + case EVoxelStrateOpClass::SdfRoughnessMod: + Probe = MakeSdfRoughnessMod(MazeParams.SurfaceRoughness, 0.12f, 3, 8.0f); + break; + case EVoxelStrateOpClass::SdfCarve: + Probe = MakeSdfCarve(2.0f, TunnelParams.BaseDensity); + break; + case EVoxelStrateOpClass::SdfFill: + Probe = MakeSdfFill(2.0f, IslandParams.BaseDensity); + break; + case EVoxelStrateOpClass::GridColumnMod: + Probe = MakeUnique(SlabParams, 0); + break; + case EVoxelStrateOpClass::CaveRoughnessMod: + Probe = MakeUnique(TunnelParams, 0); + break; + case EVoxelStrateOpClass::CaveTerraceMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::LayerLineMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::RibbingMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::CaveOverhangMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::CaveCliffMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::ScallopMod: + Probe = MakeUnique(TunnelParams, 0, nullptr); + break; + case EVoxelStrateOpClass::CaveArchMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::RoomColumnMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::DomeMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::PinchMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::FloorBiasMod: + Probe = MakeUnique(TunnelParams, nullptr); + break; + case EVoxelStrateOpClass::WormFieldSource: + Probe = MakeUnique(TunnelParams, 0); + break; + case EVoxelStrateOpClass::ShaftLedgeMod: + Probe = MakeUnique(ShaftParams, nullptr); + break; + case EVoxelStrateOpClass::DensityNoiseCarveMod: + Probe = MakeUnique(1.0f, 0.02f, 3, 0, false); + break; + case EVoxelStrateOpClass::DensityNoiseFillMod: + Probe = MakeUnique(1.0f, 0.02f, 3, 0, true); + break; + default: + return false; + } + + if (!Probe) + { + return false; + } + + OutContract.Role = Probe->GetRole(); + OutContract.Reads = Probe->ChannelReads(); + OutContract.Writes = Probe->ChannelWrites(); + OutContract.bAdditive = Probe->IsAdditive(); + OutContract.RequiredResources = Probe->RequiredResources(); + OutContract.ProvidedResources = Probe->ProvidedResources(); + return true; + } + + TUniquePtr MakeConstantRockSource(float BaseDensity) + { + return MakeUnique(BaseDensity); + } + + TUniquePtr MakeConstantVoidSource(float BaseDensity) + { + // `Density = -BaseDensity` : on part du vide. La négation unaire est exacte. + // Start as open air; unary negation is exact. + return MakeUnique(-BaseDensity); + } + + TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed) + { + return MakeUnique(P, Seed); + } + + TUniquePtr MakeSdfRoughnessMod(float Strength, float Frequency, + int32 BaseOctaves, float ApplyWithin) + { + return MakeUnique(Strength, Frequency, BaseOctaves, ApplyWithin); + } + + TUniquePtr MakeSdfCarve(float Blend, float BaseDensity, float MinDivisor) + { + return MakeUnique(Blend, BaseDensity, -1.0f, MinDivisor); + } + + TUniquePtr MakeSdfFill(float Blend, float BaseDensity) + { + return MakeUnique(Blend, BaseDensity, +1.0f, 0.0f); + } + + TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) + { + return MakeUnique(P, Seed); + } + + TUniquePtr MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed) + { + return MakeUnique(P, Seed); + } + + void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + const TArray& PerBiomeParams, + TUniquePtr BiomeField, + bool bAppendStructuralPosts) + { + // ARCHÉTYPE COMPLET : vide + overhang + mélange de biomes. + // `PerBiomeParams` vide ⇒ chemin sans biomes, strictement inchangé. + TUniquePtr ColumnSource = + MakeUnique(P, Seed, PerBiomeParams, MoveTemp(BiomeField)); + const FSurfaceColumnSource* ColumnPtr = ColumnSource.Get(); + OutStack.Add(MoveTemp(ColumnSource)); + + // L'overhang lit la colonne de la source (mémo partagé, même XY par construction). Même + // motif que cliff → structural : un modificateur qui a besoin de ce que la source a produit. + OutStack.Add(MakeUnique(P, Seed, ColumnPtr)); + + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } + } + + void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) + { + // DEUX archétypes entrent ici, aucun branchement ne les distingue : FlatPlain et + // CrystalChamber ne diffèrent que par leurs valeurs par défaut. + OutStack.Add(MakeSlabVoidSource(P, Seed)); + OutStack.Add(MakeGridColumnMod(P, Seed)); + + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } + } + + void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) + { + // TROIS des cinq opérateurs ci-dessous sont ceux de Maze, repris tels quels — + // `ConstantRock`, `SdfRoughness`, `SdfCarve` — avec une source différente (`§2.5`). + // + // THREE of the five ops below are Maze's, reused unchanged, with a different source. + constexpr float CarveBlend = 2.0f; + + TUniquePtr ShaftSource = + MakeUnique(P, Seed, SpineRadius); + const FShaftFieldSource* ShaftPtr = ShaftSource.Get(); + + OutStack.Add(MakeConstantRockSource(P.BaseDensity)); + OutStack.Add(MoveTemp(ShaftSource)); + // Fréquence 0.1 et fenêtre `SurfaceRoughness + 4` — PAS celles de Maze + // (0.12 / `R + rough + 2`). Même opérateur, réglages différents. + OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.1f, 3, P.SurfaceRoughness + 4.0f)); + OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); + OutStack.Add(MakeUnique(P, ShaftPtr)); + OutStack.Add(MakeUnique(ShaftPtr, P.BaseDensity)); + OutStack.Add(MakeUnique(ShaftPtr, P.BaseDensity)); + + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } + } + + void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) + { + // ⚠️ ÉTAPES A + B + C1 — LA PILE EST COMPLÈTE POUR CET ARCHÉTYPE. + // Portés : échelle verticale, roc de base, warp, graphe de salles (+ pits + cheminées), + // carve, LES DOUZE MODIFICATEURS DE DÉTAIL (4b–4h), l'override d'op PAR SALLE, les vers, + // le post structurel. + // + // L'override (C1) n'ajoute AUCUN opérateur : il change ce que ONZE d'entre eux LISENT. + // `FRoomGraphSource::LocalParams()` publie les params de la strate avec l'op de la salle la + // plus proche appliqué ; les onze modificateurs concernés y lisent leurs champs au lieu des + // leurs. La rugosité (4b) NON — dans l'original elle précède la déclaration du shadow. + // + //--------------------------------------------------------------------- + // ⚠️ CE PORTAGE RETIRE L'IDÉE DE « FRAME OPS » (OPSTACK-DECOMPOSITION §1) + //--------------------------------------------------------------------- + // `§2` décrivait deux frames imbriqués : `VerticalScale` et `CaveWarp`. En les portant pour + // de vrai, les deux se sont dissous : + // • `CaveWarp` a une portée d'EXACTEMENT UN opérateur (le graphe de salles — pits et + // cheminées lisent explicitement les coordonnées non warpées). Une transformation qui + // n'enveloppe qu'un opérateur n'est pas un frame, c'est une variable locale. + // • `VerticalScale` est `Z / Scale` : une fonction PURE d'un scalaire et d'un param, que + // chaque opérateur qui en a besoin recalcule en une ligne. Un frame ne ferait + // qu'ajouter un canal pour éviter une division. + // Il restait le warp d'îles (§7), déjà gardé local pour la même raison. **Zéro frame sur + // trois candidats** : ce n'était pas une infrastructure manquante, c'était trois fois la + // même chose vue de loin. + constexpr float CarveMinDivisor = 1.0f; // TunnelNetwork plancher son diviseur, cf. FSdfConvertOp + + // La source de salles est retenue par pointeur non possédant : les terrasses re-interrogent + // son cache SDF en Z±1. Même motif que `FShaftFieldSource` → `FShaftLedgeMod`. + TUniquePtr RoomSource = MakeUnique( + P, Seed, StrateManager); + const FRoomGraphSource* RoomPtr = RoomSource.Get(); + + OutStack.Add(MakeConstantRockSource(P.BaseDensity)); + OutStack.Add(MoveTemp(RoomSource)); + OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor)); + // ── ÉTAPE B : les modificateurs de détail (4b–4h), chacun gated sur + // `Sdf < SDFBlendRadius·3` via VF_NearCaveSurface. Voir la note de l'étape B5 là-bas. + // L'ORDRE EST CELUI DE L'ORIGINAL et il compte : chacun lit la densité que le précédent + // a laissée (le biais de sol est un ajout indépendant appliqué à cette accumulation). + if (!VoxelDensityAblation::IsDetailOpsOff()) + { + OutStack.Add(MakeUnique(P, Seed)); // 4b + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — terrasses + OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — lignes de strates + OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — nervures + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — surplombs + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — falaise + OutStack.Add(MakeUnique(P, Seed, RoomPtr)); // 4c — festons + OutStack.Add(MakeUnique(P, RoomPtr)); // 4c — arches + OutStack.Add(MakeUnique(P, RoomPtr)); // 4d — colonnes (pré-cuites) + OutStack.Add(MakeUnique(P, RoomPtr)); // 4g — dômes + OutStack.Add(MakeUnique(P, RoomPtr)); // 4h — pincement + OutStack.Add(MakeUnique(P, RoomPtr)); // fin 4h — biais de sol + } + if (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) + { + OutStack.Add(MakeUnique(P, Seed)); + } + + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager, false); + // The generator's shared MC post evaluates the prepared tunnel-core cache after + // disturbances. Keeping these pre-disturbance graph scans here duplicated both the + // support and air queries for every voxel; the feature remains in that shared post. + OutStack.Add(MakeUnique(P.BaseDensity)); + + // Lower only the canonical native graph. The old scalar TunnelNetwork evaluator is + // the fused form of exactly these authored stages: it keeps density/SDF/room state in + // locals and gates the complete 4b-4h detail suite per sample. Marking the plan after + // the final op is important: recipes and composer stacks keep the interpreted/block + // fallback, even when they happen to contain a similar-looking subset of operators. + OutStack.SetFusedEvaluator(EVoxelOpFusedEvaluator::TunnelNetwork); + } + } + + void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) + { + // ⚠️ LA PILE QUI S'INVERSE, et c'est la mesure que ce portage-ci ajoute : les quatre autres + // archétypes partent de ROC et CREUSENT ; celui-ci part du VIDE et REMPLIT. Aucune des deux + // extrémités n'a demandé un opérateur neuf — la source constante et la conversion SDF→densité + // sont les MÊMES classes, au signe près (`FConstantFieldSource`, `FSdfConvertOp`). Un + // opérateur qui se réutilise en s'inversant est une preuve plus forte qu'un opérateur qui se + // réutilise à l'identique : ça veut dire que l'axe abstrait (le signe de la densité) est le + // bon, pas seulement que deux archétypes se ressemblaient. + // + // The stack that runs BACKWARDS: four archetypes start from rock and carve, this one starts + // from void and fills — and neither end needed a new operator, only the opposite sign. + const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f); + + OutStack.Add(MakeConstantVoidSource(P.BaseDensity)); + OutStack.Add(MakeUnique(P, Seed)); + // Fréquence 0.08 et 4 octaves — les constantes de FloatingIslands (Maze 0.12/3, + // VerticalShafts 0.1/3). + OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.08f, 4, + P.SurfaceRoughness + BlendK + 2.0f)); + OutStack.Add(MakeSdfFill(BlendK, P.BaseDensity)); + + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } + } + + void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts) + { + // Constantes Maze, codées en dur. / Maze's hard-coded constants. + constexpr float CarveBlend = 2.0f; + constexpr float RoughFrequency = 0.12f; + constexpr int32 RoughOctaves = 3; + + const float R = FMath::Max(P.CorridorRadius, 0.5f); + + // Fenêtre d'application de la rugosité : `MazeSDF < R + SurfaceRoughness + 2.0f`. + const float RoughApplyWithin = R + P.SurfaceRoughness + 2.0f; + + OutStack.Add(MakeConstantRockSource(P.BaseDensity)); + OutStack.Add(MakeLatticeCorridorSource(P, Seed)); + OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, RoughFrequency, RoughOctaves, RoughApplyWithin)); + OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); + + if (bAppendStructuralPosts) + { + OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness, P.BaseDensity, + SpineRadius, StrateManager); + } + } +} + +namespace VoxelStrateRecipePrivate +{ + const FStrateGenerationParams* Tunnel(const FVoxelStrateArchetypeParams& Params) + { + return &Params.TunnelNetworkParams; + } + + const FSlabGenerationParams* Slab(const FVoxelStrateArchetypeParams& Params) + { + return &Params.SlabParams; + } + + const FMazeGenerationParams* Maze(const FVoxelStrateArchetypeParams& Params) + { + return &Params.MazeParams; + } + + const FSurfaceGenerationParams* Surface(const FVoxelStrateArchetypeParams& Params) + { + return &Params.SurfaceParams; + } + + const FVerticalShaftParams* Shaft(const FVoxelStrateArchetypeParams& Params) + { + return &Params.VerticalShaftParams; + } + + const FFloatingIslandParams* Island(const FVoxelStrateArchetypeParams& Params) + { + return &Params.FloatingIslandParams; + } + + float BaseDensity(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->BaseDensity; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->BaseDensity; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->BaseDensity; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->BaseDensity; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->BaseDensity; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->BaseDensity; + default: return 8.0f; + } + } + + float BoundarySeal(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->BoundarySealThickness; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->BoundarySealThickness; + default: return 4.0f; + } + } + + float Top(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->StrateTopWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->StrateTopWorldZ; + default: return 0.0f; + } + } + + float Bottom(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Tunnel(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::Slab: return Slab(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::Maze: return Maze(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::Surface: return Surface(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Shaft(Params)->StrateBottomWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Island(Params)->StrateBottomWorldZ; + default: return 0.0f; + } + } + + EVoxelStrateParamBlock ShapeBlock(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::RoomGraphSource: return EVoxelStrateParamBlock::TunnelNetwork; + case EVoxelStrateOpClass::LatticeCorridorSource: + case EVoxelStrateOpClass::NoiseRibbonSource: return EVoxelStrateParamBlock::Maze; + case EVoxelStrateOpClass::ShaftFieldSource: return EVoxelStrateParamBlock::VerticalShaft; + case EVoxelStrateOpClass::IslandBlobSource: return EVoxelStrateParamBlock::FloatingIsland; + default: return EVoxelStrateParamBlock::None; + } + } + + EVoxelStrateParamBlock FixedParamBlock(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::GridColumnMod: + return EVoxelStrateParamBlock::Slab; + case EVoxelStrateOpClass::CaveRoughnessMod: + case EVoxelStrateOpClass::CaveTerraceMod: + case EVoxelStrateOpClass::LayerLineMod: + case EVoxelStrateOpClass::RibbingMod: + case EVoxelStrateOpClass::CaveOverhangMod: + case EVoxelStrateOpClass::CaveCliffMod: + case EVoxelStrateOpClass::ScallopMod: + case EVoxelStrateOpClass::CaveArchMod: + case EVoxelStrateOpClass::RoomColumnMod: + case EVoxelStrateOpClass::DomeMod: + case EVoxelStrateOpClass::PinchMod: + case EVoxelStrateOpClass::FloorBiasMod: + case EVoxelStrateOpClass::WormFieldSource: + return EVoxelStrateParamBlock::TunnelNetwork; + case EVoxelStrateOpClass::ShaftLedgeMod: + return EVoxelStrateParamBlock::VerticalShaft; + default: + return EVoxelStrateParamBlock::None; + } + } + + float SdfBlend(const FVoxelStrateArchetypeParams& Params, EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: + return Params.TunnelNetworkParams.SDFBlendRadius; + case EVoxelStrateParamBlock::Maze: + return 2.0f; + case EVoxelStrateParamBlock::VerticalShaft: + return 2.0f; + case EVoxelStrateParamBlock::FloatingIsland: + return FMath::Max(Params.FloatingIslandParams.SDFBlendRadius, 0.01f); + default: + return 2.0f; + } + } + + float SdfRoughnessStrength(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Params.TunnelNetworkParams.SurfaceRoughness; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.SurfaceRoughness; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.SurfaceRoughness; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.SurfaceRoughness; + default: return 2.0f; + } + } + + float SdfRoughnessFrequency(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return Params.TunnelNetworkParams.RoughnessFrequency; + case EVoxelStrateParamBlock::Maze: return 0.12f; + case EVoxelStrateParamBlock::VerticalShaft: return 0.1f; + case EVoxelStrateParamBlock::FloatingIsland: return 0.08f; + default: return 0.1f; + } + } + + int32 SdfRoughnessOctaves(EVoxelStrateParamBlock Block) + { + return Block == EVoxelStrateParamBlock::FloatingIsland ? 4 : 3; + } + + float SdfRoughnessWindow(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + const float Strength = SdfRoughnessStrength(Params, Block); + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: + return Params.TunnelNetworkParams.SDFBlendRadius * 3.0f + Strength + 2.0f; + case EVoxelStrateParamBlock::Maze: + return FMath::Max(Params.MazeParams.CorridorRadius, 0.5f) + Strength + 2.0f; + case EVoxelStrateParamBlock::VerticalShaft: + return Strength + 4.0f; + case EVoxelStrateParamBlock::FloatingIsland: + return Strength + SdfBlend(Params, Block) + 2.0f; + default: + return Strength + 4.0f; + } + } + + float GenericStrength(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + return FMath::Max(0.25f, 0.35f * FMath::Abs(BaseDensity(Params, Block))); + } + + float GenericFrequency(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Maze: + return 1.0f / FMath::Max(Params.MazeParams.CellSize, 8.0f); + case EVoxelStrateParamBlock::VerticalShaft: + return 0.01f; + case EVoxelStrateParamBlock::FloatingIsland: + return 0.02f; + case EVoxelStrateParamBlock::TunnelNetwork: + return FMath::Max(Params.TunnelNetworkParams.RoughnessFrequency, 0.001f); + default: + return 0.02f; + } + } + + bool IsShapeSource(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::RoomGraphSource: + case EVoxelStrateOpClass::LatticeCorridorSource: + case EVoxelStrateOpClass::ShaftFieldSource: + case EVoxelStrateOpClass::IslandBlobSource: + case EVoxelStrateOpClass::NoiseRibbonSource: + return true; + default: + return false; + } + } + + bool ValidateRecipeContracts(const FVoxelOpStackRecipe& Recipe, FString& OutError) + { + TArray Classes; + Classes.Reserve(3 + Recipe.Modifiers.Num()); + Classes.Add(Recipe.Root.OpClass); + Classes.Add(Recipe.ShapeSource.OpClass); + Classes.Add(Recipe.Conversion.OpClass); + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) + { + Classes.Add(Entry.OpClass); + } + + auto ValidateParamBlock = [&](const FVoxelOpRecipeEntry& Entry, int32 EntryIndex) + { + if (Entry.ParamBlock == EVoxelStrateParamBlock::None) + { + OutError = FString::Printf(TEXT("recipe op %d has no parameter block"), EntryIndex); + return false; + } + const EVoxelStrateParamBlock Fixed = FixedParamBlock(Entry.OpClass); + if (Fixed != EVoxelStrateParamBlock::None && Entry.ParamBlock != Fixed) + { + OutError = FString::Printf(TEXT("recipe op %d uses the wrong parameter block"), EntryIndex); + return false; + } + return true; + }; + if (!ValidateParamBlock(Recipe.Root, 0) + || !ValidateParamBlock(Recipe.ShapeSource, 1) + || !ValidateParamBlock(Recipe.Conversion, 2)) + { + return false; + } + for (int32 ModifierIndex = 0; ModifierIndex < Recipe.Modifiers.Num(); ++ModifierIndex) + { + if (!ValidateParamBlock(Recipe.Modifiers[ModifierIndex], 3 + ModifierIndex)) + { + return false; + } + } + + int32 LastWriter[2] = { INDEX_NONE, INDEX_NONE }; + EVoxelOpResourceMask AvailableResources = VoxelOpResources::None; + const EVoxelOpChannelMask Channels[] = { VoxelOpChannels::Density, VoxelOpChannels::Sdf }; + auto ChannelIndex = [](EVoxelOpChannelMask Channel) { return Channel == VoxelOpChannels::Density ? 0 : 1; }; + + for (int32 Index = 0; Index < Classes.Num(); ++Index) + { + FVoxelStrateOpContract Contract; + if (!VoxelDensityOps::GetStrateOpContract(Classes[Index], Contract)) + { + OutError = FString::Printf(TEXT("recipe op %d has no declaration"), Index); + return false; + } + if ((Contract.Reads & VoxelOpChannels::All) != Contract.Reads + || (Contract.Writes & VoxelOpChannels::All) != Contract.Writes + || (Contract.RequiredResources & VoxelOpResources::All) != Contract.RequiredResources + || (Contract.ProvidedResources & VoxelOpResources::All) != Contract.ProvidedResources) + { + OutError = FString::Printf(TEXT("recipe op %d has an unknown declaration bit"), Index); + return false; + } + if ((Contract.RequiredResources + & static_cast(~AvailableResources)) != 0) + { + OutError = FString::Printf(TEXT("recipe op %d requires unavailable state"), Index); + return false; + } + if (Contract.bAdditive && Contract.Writes == VoxelOpChannels::None) + { + OutError = FString::Printf(TEXT("recipe op %d is additive but writes no channel"), Index); + return false; + } + if (Contract.bAdditive + && (Contract.Writes & static_cast(~Contract.Reads)) != 0) + { + OutError = FString::Printf(TEXT("recipe op %d is additive without reading its writes"), Index); + return false; + } + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Contract.Reads & Channel) == 0) { continue; } + const int32 CI = ChannelIndex(Channel); + const bool bRootIdentity = Contract.Role == EVoxelOpRole::FieldSource + && (Contract.Writes & Channel) != 0 && LastWriter[CI] == INDEX_NONE; + if (LastWriter[CI] == INDEX_NONE && !bRootIdentity) + { + OutError = FString::Printf(TEXT("recipe op %d reads an unpublished channel"), Index); + return false; + } + } + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Contract.Writes & Channel) == 0 || (Contract.Reads & Channel) != 0) { continue; } + if (LastWriter[ChannelIndex(Channel)] != INDEX_NONE) + { + OutError = FString::Printf(TEXT("recipe op %d clobbers a channel"), Index); + return false; + } + } + for (const EVoxelOpChannelMask Channel : Channels) + { + if ((Contract.Writes & Channel) != 0) + { + LastWriter[ChannelIndex(Channel)] = Index; + } + } + AvailableResources |= Contract.ProvidedResources; + } + return true; + } + + TUniquePtr BuildRecipeOp(const FVoxelOpRecipeEntry& Entry, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + const FRoomGraphSource*& OutRoom, + const FShaftFieldSource*& OutShaft) + { + const EVoxelStrateParamBlock Block = Entry.ParamBlock; + switch (Entry.OpClass) + { + case EVoxelStrateOpClass::ConstantRockSource: + return VoxelDensityOps::MakeConstantRockSource(BaseDensity(Params, Block)); + case EVoxelStrateOpClass::ConstantVoidSource: + return VoxelDensityOps::MakeConstantVoidSource(BaseDensity(Params, Block)); + case EVoxelStrateOpClass::RoomGraphSource: + { + TUniquePtr Op = MakeUnique(*Tunnel(Params), Seed, StrateManager); + OutRoom = Op.Get(); + return Op; + } + case EVoxelStrateOpClass::LatticeCorridorSource: + return MakeUnique(*Maze(Params), Seed); + case EVoxelStrateOpClass::ShaftFieldSource: + { + TUniquePtr Op = MakeUnique(*Shaft(Params), Seed, SpineRadius); + OutShaft = Op.Get(); + return Op; + } + case EVoxelStrateOpClass::IslandBlobSource: + return MakeUnique(*Island(Params), Seed); + case EVoxelStrateOpClass::NoiseRibbonSource: + return MakeUnique(*Maze(Params), Seed); + case EVoxelStrateOpClass::SdfRoughnessMod: + return VoxelDensityOps::MakeSdfRoughnessMod( + SdfRoughnessStrength(Params, Block), + SdfRoughnessFrequency(Params, Block), + SdfRoughnessOctaves(Block), + SdfRoughnessWindow(Params, Block)); + case EVoxelStrateOpClass::SdfCarve: + return VoxelDensityOps::MakeSdfCarve(SdfBlend(Params, Block), BaseDensity(Params, Block)); + case EVoxelStrateOpClass::SdfFill: + return VoxelDensityOps::MakeSdfFill(SdfBlend(Params, Block), BaseDensity(Params, Block)); + case EVoxelStrateOpClass::GridColumnMod: + return MakeUnique(*Slab(Params), Seed); + case EVoxelStrateOpClass::CaveRoughnessMod: + return MakeUnique(*Tunnel(Params), Seed); + case EVoxelStrateOpClass::CaveTerraceMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::LayerLineMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::RibbingMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::CaveOverhangMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::CaveCliffMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::ScallopMod: + return MakeUnique(*Tunnel(Params), Seed, OutRoom); + case EVoxelStrateOpClass::CaveArchMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::RoomColumnMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::DomeMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::PinchMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::FloorBiasMod: + return MakeUnique(*Tunnel(Params), OutRoom); + case EVoxelStrateOpClass::WormFieldSource: + return MakeUnique(*Tunnel(Params), Seed); + case EVoxelStrateOpClass::ShaftLedgeMod: + return MakeUnique(*Shaft(Params), OutShaft); + case EVoxelStrateOpClass::DensityNoiseCarveMod: + return MakeUnique(GenericStrength(Params, Block), + GenericFrequency(Params, Block), 3, Seed, false); + case EVoxelStrateOpClass::DensityNoiseFillMod: + return MakeUnique(GenericStrength(Params, Block), + GenericFrequency(Params, Block), 3, Seed, true); + default: + return nullptr; + } + } +} + +bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError, + bool bAppendStructuralPosts) +{ + auto Fail = [&](const FString& Reason) -> bool + { + if (OutError != nullptr) { *OutError = Reason; } + return false; + }; + if (OutError != nullptr) { OutError->Reset(); } + + if (Recipe.Modifiers.Num() < 4 || Recipe.Modifiers.Num() > 8) + { + return Fail(TEXT("A structure recipe must draw between 4 and 8 modifiers.")); + } + const EVoxelStrateOpClass ExpectedRoot = Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::ConstantVoidSource : EVoxelStrateOpClass::ConstantRockSource; + const EVoxelStrateOpClass ExpectedConversion = Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::SdfFill : EVoxelStrateOpClass::SdfCarve; + if (Recipe.Root.OpClass != ExpectedRoot || Recipe.Conversion.OpClass != ExpectedConversion) + { + return Fail(TEXT("Recipe polarity does not match its root/conversion ids.")); + } + if (!VoxelStrateRecipePrivate::IsShapeSource(Recipe.ShapeSource.OpClass)) + { + return Fail(TEXT("Recipe shape source is not a rollable SDF source.")); + } + const EVoxelStrateParamBlock ShapeBlock = + VoxelStrateRecipePrivate::ShapeBlock(Recipe.ShapeSource.OpClass); + if (ShapeBlock == EVoxelStrateParamBlock::None + || Recipe.StructuralParamBlock != ShapeBlock + || Recipe.Root.ParamBlock == EVoxelStrateParamBlock::None + || Recipe.ShapeSource.ParamBlock != ShapeBlock + || Recipe.Conversion.ParamBlock != ShapeBlock) + { + return Fail(TEXT("Recipe parameter blocks do not match its shape source.")); + } + + FString ContractError; + if (!VoxelStrateRecipePrivate::ValidateRecipeContracts(Recipe, ContractError)) + { + return Fail(ContractError); + } + + FVoxelOpStack Candidate; + const FRoomGraphSource* RoomPtr = nullptr; + const FShaftFieldSource* ShaftPtr = nullptr; + + FVoxelOpRecipeEntry Root = Recipe.Root; + TUniquePtr RootOp = + VoxelStrateRecipePrivate::BuildRecipeOp(Root, Params, Seed, SpineRadius, StrateManager, RoomPtr, ShaftPtr); + if (!RootOp) { return Fail(TEXT("Recipe root could not be materialised.")); } + Candidate.Add(MoveTemp(RootOp)); + + TUniquePtr ShapeOp = + VoxelStrateRecipePrivate::BuildRecipeOp(Recipe.ShapeSource, Params, Seed, SpineRadius, + StrateManager, RoomPtr, ShaftPtr); + if (!ShapeOp) { return Fail(TEXT("Recipe shape source could not be materialised.")); } + Candidate.Add(MoveTemp(ShapeOp)); + + TUniquePtr ConversionOp = + VoxelStrateRecipePrivate::BuildRecipeOp(Recipe.Conversion, Params, Seed, SpineRadius, + StrateManager, RoomPtr, ShaftPtr); + if (!ConversionOp) { return Fail(TEXT("Recipe conversion could not be materialised.")); } + Candidate.Add(MoveTemp(ConversionOp)); + + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) + { + if (Entry.OpClass == EVoxelStrateOpClass::WormFieldSource + && !(Params.TunnelNetworkParams.WormStrength > 0.0f + && Params.TunnelNetworkParams.WormThreshold > 0.0f)) + { + // A disabled strate has no worm operator at all. This makes the off switch cheap on + // the interpreted path as well as exact in its interval/classifier fold. + continue; + } + TUniquePtr Modifier = + VoxelStrateRecipePrivate::BuildRecipeOp(Entry, Params, Seed, SpineRadius, + StrateManager, RoomPtr, ShaftPtr); + if (!Modifier) + { + return Fail(TEXT("Recipe modifier could not be materialised.")); + } + Candidate.Add(MoveTemp(Modifier)); + } + + const float Top = VoxelStrateRecipePrivate::Top(Params, Recipe.StructuralParamBlock); + const float Bottom = VoxelStrateRecipePrivate::Bottom(Params, Recipe.StructuralParamBlock); + const float Seal = VoxelStrateRecipePrivate::BoundarySeal(Params, Recipe.StructuralParamBlock); + const float Base = VoxelStrateRecipePrivate::BaseDensity(Params, Recipe.StructuralParamBlock); + if (!VoxelMath::IsFinite(Top) || !VoxelMath::IsFinite(Bottom) || Top <= Bottom + || !VoxelMath::IsFinite(Seal) || !VoxelMath::IsFinite(Base)) + { + return Fail(TEXT("Recipe structural parameters do not define a finite positive strate.")); + } + + OutContext = FVoxelOpContext(); + OutContext.Seed = Seed; + OutContext.LayoutVersion = StrateManager != nullptr ? StrateManager->GetLayoutVersion() : 0; + OutContext.WorldRadiusVoxels = 0.0f; + OutContext.EdgeSealThickness = Seal; + OutContext.StrateTopWorldZ = Top; + OutContext.StrateBottomWorldZ = Bottom; + + // This is the only place a recipe can acquire world-law operators. There is no recipe field + // for posts, so a serialised manifest cannot forget them or reorder them. Lateral callers + // materialise this same creative recipe with posts disabled, then append the global post set + // to the parent stack below. + if (bAppendStructuralPosts) + { + Candidate.AppendStructuralPost(Top, Bottom, Seal, Base, SpineRadius, StrateManager); + } + + FString ValidationError; + if (!Candidate.ValidateChannelOrder(&ValidationError)) + { + return Fail(FString::Printf(TEXT("Materialised recipe failed ValidateChannelOrder: %s"), + *ValidationError)); + } + + OutStack = MoveTemp(Candidate); + return true; +} + +#if WITH_EDITOR +namespace +{ + static EVoxelStrateParamBlock VF_RegionParamBlock(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: return EVoxelStrateParamBlock::Slab; + case ECaveGeneratorType::Maze: return EVoxelStrateParamBlock::Maze; + case ECaveGeneratorType::SurfaceWorld: return EVoxelStrateParamBlock::Surface; + case ECaveGeneratorType::VerticalShafts: return EVoxelStrateParamBlock::VerticalShaft; + case ECaveGeneratorType::FloatingIslands:return EVoxelStrateParamBlock::FloatingIsland; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + default: return EVoxelStrateParamBlock::TunnelNetwork; + } + } + + static float VF_RegionTop(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateTopWorldZ; + } + } + + static float VF_RegionBottom(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateBottomWorldZ; + } + } + + static float VF_RegionSeal(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BoundarySealThickness; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BoundarySealThickness; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BoundarySealThickness; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BoundarySealThickness; + } + } + + static float VF_RegionBase(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BaseDensity; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BaseDensity; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BaseDensity; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BaseDensity; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BaseDensity; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BaseDensity; + } + } + + static bool VF_BuildNativeRegionCore( + const FVoxelStrateRegion& Region, + const FVoxelStrateArchetypeParams& Params, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext) + { + switch (Region.Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + if (Params.SlabParams.StrateTopWorldZ - Params.SlabParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.SlabParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.SlabParams.StrateBottomWorldZ; + VoxelDensityOps::BuildSlabStack(OutStack, Params.SlabParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::Maze: + if (Params.MazeParams.StrateTopWorldZ - Params.MazeParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.MazeParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.MazeParams.StrateBottomWorldZ; + VoxelDensityOps::BuildMazeStack(OutStack, Params.MazeParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::SurfaceWorld: + if (Params.SurfaceParams.StrateTopWorldZ - Params.SurfaceParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.SurfaceParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.SurfaceParams.StrateBottomWorldZ; + VoxelDensityOps::BuildSurfaceStack( + OutStack, Params.SurfaceParams, Region.Seed, SpineRadius, StrateManager, + TArray(), nullptr, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::VerticalShafts: + if (Params.VerticalShaftParams.StrateTopWorldZ + - Params.VerticalShaftParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.VerticalShaftParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.VerticalShaftParams.StrateBottomWorldZ; + VoxelDensityOps::BuildVerticalShaftStack( + OutStack, Params.VerticalShaftParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::FloatingIslands: + if (Params.FloatingIslandParams.StrateTopWorldZ + - Params.FloatingIslandParams.StrateBottomWorldZ <= 0.0f) + { + return false; + } + OutContext.StrateTopWorldZ = Params.FloatingIslandParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.FloatingIslandParams.StrateBottomWorldZ; + VoxelDensityOps::BuildFloatingIslandStack( + OutStack, Params.FloatingIslandParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + OutContext.StrateTopWorldZ = Params.TunnelNetworkParams.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = Params.TunnelNetworkParams.StrateBottomWorldZ; + VoxelDensityOps::BuildTunnelNetworkStack( + OutStack, Params.TunnelNetworkParams, Region.Seed, + SpineRadius, StrateManager, bAppendStructuralPosts); + return true; + default: + return false; + } + } +} + +bool VF_BuildStrateRegionStack( + const FVoxelStrateRegionManifest& Manifest, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + auto Fail = [&](const FString& Reason) -> bool + { + if (OutError != nullptr) { *OutError = Reason; } + return false; + }; + if (OutError != nullptr) { OutError->Reset(); } + if (!Manifest.IsValid()) + { + return Fail(Manifest.FailureReason.IsEmpty() + ? TEXT("lateral region manifest is invalid") : Manifest.FailureReason); + } + if (Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + return Fail(TEXT("lateral region partition seed is stale for its seed/strate index")); + } + + EVoxelStrateParamBlock GlobalBlock = Manifest.StructuralParamBlock; + if (GlobalBlock == EVoxelStrateParamBlock::None) + { + GlobalBlock = Manifest.Regions[0].bUsesRecipe + ? Manifest.Regions[0].Recipe.StructuralParamBlock + : VF_RegionParamBlock(Manifest.Regions[0].Archetype); + } + + FVoxelStrateArchetypeParams FirstParams = Manifest.Regions[0].ArchetypeParams; + float Top = Manifest.bHasGlobalStructuralParams + ? Manifest.StrateTopWorldZ : VF_RegionTop(FirstParams, GlobalBlock); + float Bottom = Manifest.bHasGlobalStructuralParams + ? Manifest.StrateBottomWorldZ : VF_RegionBottom(FirstParams, GlobalBlock); + float Seal = Manifest.bHasGlobalStructuralParams + ? Manifest.BoundarySealThickness : VF_RegionSeal(FirstParams, GlobalBlock); + float Base = Manifest.bHasGlobalStructuralParams + ? Manifest.BaseDensity : VF_RegionBase(FirstParams, GlobalBlock); + if (!VoxelMath::IsFinite(Top) || !VoxelMath::IsFinite(Bottom) || !(Top > Bottom) + || !VoxelMath::IsFinite(Seal) || Seal < 0.0f + || !VoxelMath::IsFinite(Base) || !(Base > 0.0f)) + { + return Fail(TEXT("lateral region structural parameters are not a finite positive strate")); + } + + auto MakeRegionParams = [&](const FVoxelStrateRegion& Region) + { + FVoxelStrateArchetypeParams Params = Region.ArchetypeParams; + VF_SetStrateArchetypeRuntimeBounds(Params, Top, Bottom); + return Params; + }; + + // The one-region form remains a normal stack. It is useful to callers that consume a region + // manifest, while production keeps its old branch entirely untouched for the hard identity + // gate. + if (Manifest.RegionCount == 1) + { + const FVoxelStrateRegion& Region = Manifest.Regions[0]; + const FVoxelStrateArchetypeParams Params = MakeRegionParams(Region); + bool bBuilt = false; + if (Region.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Region.Recipe, Params, Region.Seed, + SpineRadius, StrateManager, OutStack, + OutContext, OutError, true); + } + else + { + OutContext = FVoxelOpContext(); + OutContext.Seed = static_cast(Region.Seed); + OutContext.LayoutVersion = StrateManager != nullptr + ? StrateManager->GetLayoutVersion() : 0; + bBuilt = VF_BuildNativeRegionCore(Region, Params, SpineRadius, StrateManager, + true, OutStack, OutContext); + if (!bBuilt && OutError != nullptr) + { + *OutError = TEXT("single lateral region native stack could not be materialised"); + } + } + if (!bBuilt) { return false; } + OutContext.WorldRadiusVoxels = 0.0f; + OutContext.EdgeSealThickness = Seal; + return true; + } + + TArray RegionStacks; + RegionStacks.Reserve(Manifest.RegionCount); + for (const FVoxelStrateRegion& Region : Manifest.Regions) + { + const FVoxelStrateArchetypeParams Params = MakeRegionParams(Region); + FVoxelOpStack Core; + FVoxelOpContext CoreContext; + bool bBuilt = false; + if (Region.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Region.Recipe, Params, Region.Seed, + SpineRadius, StrateManager, Core, + CoreContext, OutError, false); + } + else + { + CoreContext = FVoxelOpContext(); + CoreContext.Seed = static_cast(Region.Seed); + CoreContext.LayoutVersion = StrateManager != nullptr + ? StrateManager->GetLayoutVersion() : 0; + bBuilt = VF_BuildNativeRegionCore(Region, Params, SpineRadius, StrateManager, + false, Core, CoreContext); + if (!bBuilt && OutError != nullptr) + { + *OutError = FString::Printf(TEXT("region %d native stack could not be materialised"), + Region.RegionIndex); + } + } + if (!bBuilt) + { + return false; + } + RegionStacks.Add(MoveTemp(Core)); + } + + FVoxelOpStack Parent; + Parent.Add(MakeUnique(Manifest, MoveTemp(RegionStacks))); + Parent.AppendStructuralPost(Top, Bottom, Seal, Base, SpineRadius, StrateManager); + + FString ValidationError; + if (!Parent.ValidateChannelOrder(&ValidationError)) + { + return Fail(FString::Printf(TEXT("lateral region parent failed ValidateChannelOrder: %s"), + *ValidationError)); + } + + OutContext = FVoxelOpContext(); + OutContext.Seed = static_cast(Manifest.Seed); + OutContext.LayoutVersion = StrateManager != nullptr + ? StrateManager->GetLayoutVersion() : 0; + OutContext.WorldRadiusVoxels = 0.0f; + OutContext.EdgeSealThickness = Seal; + OutContext.StrateTopWorldZ = Top; + OutContext.StrateBottomWorldZ = Bottom; + OutStack = MoveTemp(Parent); + return true; +} +#endif // WITH_EDITOR — lateral regions remain behind the failed shippability gate diff --git a/Standalone/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Standalone/Source/VoxelForge/Private/VoxelDensityProfile.cpp new file mode 100644 index 0000000..fd68763 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -0,0 +1,994 @@ +#include "VoxelDensityProfile.h" +#include "Misc/Crc.h" + +namespace VoxelDensityProfile +{ + std::atomic GEnabled(false); + std::atomic GMode(EMode::Disabled); + + namespace + { + std::atomic GSampleInterval(DefaultSampleInterval); + std::atomic GTimerPairCycles(0); + + // The old implementation performed a contended atomic fetch_add for every timer and + // counter. A TunnelNetwork sample can close twenty-plus ranges and add several + // candidate counters, so profiling changed the workload it was supposed to measure. + // Keep one accumulator per worker and only aggregate at Snapshot(). Commandlet resets + // happen between completed jobs, so the deliberately light-weight list is sufficient. + struct FThreadState + { + uint64 Calls[BucketCount]{}; + uint64 Cycles[BucketCount]{}; + uint64 Samples[BucketCount]{}; + uint64 Counters[CounterCount]{}; + uint32 SampleSequence[BucketCount]{}; + uint32 BlockCallCount[BucketCount]{}; + uint64 BlockStartCycles[BucketCount]{}; + bool BlockActive[BucketCount]{}; + FSnapshot::FClassifierStats Classifier; + + uint64 TunnelCacheCapacityEntries = 0; + uint64 TunnelCacheValidEntries = 0; + uint64 TunnelCacheStaticBytes = 0; + uint64 TunnelCacheDynamicBytes = 0; + uint64 TunnelCacheEntryBytes = 0; + uint64 TunnelCacheLargestEntryBytes = 0; + uint64 TunnelCacheLargestWorkerValidEntries = 0; + FCacheMemoryBreakdown TunnelCacheBreakdown; + + uint64 RoomGraphCacheCapacityEntries = 0; + uint64 RoomGraphCacheValidEntries = 0; + uint64 RoomGraphCacheStaticBytes = 0; + uint64 RoomGraphCacheDynamicBytes = 0; + uint64 RoomGraphCacheEntryBytes = 0; + uint64 RoomGraphCacheLargestEntryBytes = 0; + uint64 RoomGraphCacheLargestWorkerValidEntries = 0; + FCacheMemoryBreakdown RoomGraphCacheBreakdown; + + std::atomic Next{nullptr}; + }; + + std::atomic GThreadStates{nullptr}; + thread_local FThreadState* GThreadState = nullptr; + thread_local uint32 GScopeDepth = 0; + thread_local bool GScopeSampled = false; + + FThreadState& GetThreadState() + { + if (GThreadState == nullptr) + { + GThreadState = new FThreadState(); + FThreadState* Head = GThreadStates.load(std::memory_order_relaxed); + do + { + GThreadState->Next.store(Head, std::memory_order_relaxed); + } + while (!GThreadStates.compare_exchange_weak( + Head, GThreadState, + std::memory_order_release, + std::memory_order_relaxed)); + } + return *GThreadState; + } + + int32 ToIndex(EBucket Bucket) + { + const int32 Index = static_cast(Bucket); + return (Index >= 0 && Index < BucketCount) + ? Index + : static_cast(EBucket::OtherOp); + } + + bool IsSampledScopeEnabled(EBucket Bucket) + { + // Sampled mode pays for the trustworthy parent and mesh containers only. Attribution + // additionally exposes the component scopes, but still samples them; Full remains the + // deliberately invasive mode for per-call scope timing. + switch (Bucket) + { + case EBucket::GetDensityAt: + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherOther: + return true; + default: + return false; + } + } + + bool IsAttributionScope(EBucket Bucket) + { + switch (Bucket) + { + case EBucket::GetDensityAt: + case EBucket::ApplyDisturbances: + case EBucket::PassageModifier: + case EBucket::PassageLandingAir: + case EBucket::PassageLandingFloor: + case EBucket::PassageLandingRoomFloor: + case EBucket::PassageTunnelAir: + case EBucket::PassageStructuralPosts: + case EBucket::TunnelCoreSupport: + case EBucket::TunnelCoreSdf: + case EBucket::TunnelCorePosts: + case EBucket::StructuralTail: + case EBucket::RoomGraphSource: + case EBucket::SdfCarve: + case EBucket::FusedEvaluator: + case EBucket::InterpretedOpStack: + case EBucket::OperatorBlock: + case EBucket::RoomGraphBuild: + case EBucket::RoomGraphSdf: + case EBucket::TunnelCoreWorld: + case EBucket::FusedDetail: + case EBucket::FusedWorm: + case EBucket::FusedStructural: + case EBucket::ClassifierTotal: + case EBucket::ClassifierIntervalProof: + case EBucket::ClassifierExactCore: + case EBucket::ClassifierExactFinal: + case EBucket::ClassifierRoomTail: + case EBucket::RuntimeStreamBuilding: + case EBucket::DensityPrologue: + case EBucket::DensityCore: + case EBucket::DensityDisturbances: + case EBucket::DensityStructuralPosts: + case EBucket::DensityBoundarySeal: + case EBucket::DensityDiffLayer: + case EBucket::DensityTail: + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherCellClassification: + case EBucket::MesherGradientNormals: + case EBucket::MesherVertexInterpolation: + case EBucket::MesherStreamBuilding: + case EBucket::MesherOther: + return true; + default: + return false; + } + } + + bool IsAttributionAlwaysTimed(EBucket Bucket) + { + switch (Bucket) + { + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherOther: + case EBucket::RoomGraphBuild: + return true; + default: + return false; + } + } + + uint64 ScaleSampledValue(uint64 Value, uint64 Calls, uint64 Samples) + { + if (Samples == 0 || Calls <= Samples) + { + return Value; + } + const double Scaled = static_cast(Value) + * static_cast(Calls) / static_cast(Samples); + return Scaled > static_cast(MAX_uint64) + ? MAX_uint64 + : static_cast(Scaled + 0.5); + } + + void AddClassifierStatsScaled( + FSnapshot::FClassifierStats& Destination, + const FSnapshot::FClassifierStats& Source, + uint64 Calls, + uint64 Samples) + { + Destination.RefineNodes += ScaleSampledValue(Source.RefineNodes, Calls, Samples); + Destination.StackBoxCalls += ScaleSampledValue(Source.StackBoxCalls, Calls, Samples); + Destination.WholeMixedNodes += ScaleSampledValue(Source.WholeMixedNodes, Calls, Samples); + Destination.WholeSolidNodes += ScaleSampledValue(Source.WholeSolidNodes, Calls, Samples); + Destination.WholeAirNodes += ScaleSampledValue(Source.WholeAirNodes, Calls, Samples); + Destination.NeedsFinalFieldNodes += ScaleSampledValue(Source.NeedsFinalFieldNodes, Calls, Samples); + Destination.SplitNodes += ScaleSampledValue(Source.SplitNodes, Calls, Samples); + Destination.MaxRefinementDepth = FMath::Max( + Destination.MaxRefinementDepth, Source.MaxRefinementDepth); + Destination.ExactCoreSamples += ScaleSampledValue(Source.ExactCoreSamples, Calls, Samples); + Destination.ExactFinalSamples += ScaleSampledValue(Source.ExactFinalSamples, Calls, Samples); + Destination.ExactCoreCacheHits += ScaleSampledValue(Source.ExactCoreCacheHits, Calls, Samples); + Destination.ExactFinalCacheHits += ScaleSampledValue(Source.ExactFinalCacheHits, Calls, Samples); + Destination.ExactCoreLeaves += ScaleSampledValue(Source.ExactCoreLeaves, Calls, Samples); + Destination.ExactFinalLeaves += ScaleSampledValue(Source.ExactFinalLeaves, Calls, Samples); + Destination.StackBoxCycles += ScaleSampledValue(Source.StackBoxCycles, Calls, Samples); + Destination.ExactCoreCycles += ScaleSampledValue(Source.ExactCoreCycles, Calls, Samples); + Destination.ExactFinalCycles += ScaleSampledValue(Source.ExactFinalCycles, Calls, Samples); + Destination.RoomTailQueries += ScaleSampledValue(Source.RoomTailQueries, Calls, Samples); + Destination.RoomTailEvaluated += ScaleSampledValue(Source.RoomTailEvaluated, Calls, Samples); + Destination.RoomTailCycles += ScaleSampledValue(Source.RoomTailCycles, Calls, Samples); + Destination.RoomPropagateCycles += ScaleSampledValue(Source.RoomPropagateCycles, Calls, Samples); + Destination.RoomExactPrimitiveCycles += ScaleSampledValue(Source.RoomExactPrimitiveCycles, Calls, Samples); + Destination.RoomCacheWindowCycles += ScaleSampledValue(Source.RoomCacheWindowCycles, Calls, Samples); + // These are topology descriptors, not per-call work counts. Keep the largest observed + // descriptor instead of multiplying one sampled graph description by the classifier + // call expansion factor. + Destination.RoomNumRooms = FMath::Max(Destination.RoomNumRooms, Source.RoomNumRooms); + Destination.RoomNumTunnels = FMath::Max(Destination.RoomNumTunnels, Source.RoomNumTunnels); + Destination.RoomNumRoomFloorJoins = FMath::Max( + Destination.RoomNumRoomFloorJoins, Source.RoomNumRoomFloorJoins); + Destination.RoomNumPits = FMath::Max(Destination.RoomNumPits, Source.RoomNumPits); + Destination.RoomNumChimneys = FMath::Max(Destination.RoomNumChimneys, Source.RoomNumChimneys); + } + } + + void SetMode(EMode Mode, uint32 SampleInterval) + { + if (SampleInterval == 0) + { + SampleInterval = DefaultSampleInterval; + } + GSampleInterval.store(SampleInterval, std::memory_order_relaxed); + GMode.store(Mode, std::memory_order_relaxed); + GEnabled.store(Mode != EMode::Disabled, std::memory_order_relaxed); + if (Mode != EMode::Disabled && GTimerPairCycles.load(std::memory_order_relaxed) == 0) + { + CalibrateTimer(); + } + } + + bool IsEnabled() + { + return GEnabled.load(std::memory_order_relaxed); + } + + bool IsCycleTimingEnabled() + { + return GMode.load(std::memory_order_relaxed) != EMode::Disabled; + } + + EMode GetMode() + { + return GMode.load(std::memory_order_relaxed); + } + + uint32 GetSampleInterval() + { + return GSampleInterval.load(std::memory_order_relaxed); + } + + uint64 GetTimerPairCycles() + { + return GTimerPairCycles.load(std::memory_order_relaxed); + } + + void CalibrateTimer() + { + constexpr uint32 Iterations = 100000; + volatile uint64 Sink = 0; + const uint64 Start = FPlatformTime::Cycles64(); + for (uint32 Index = 0; Index < Iterations; ++Index) + { + const uint64 A = FPlatformTime::Cycles64(); + const uint64 B = FPlatformTime::Cycles64(); + Sink ^= (B - A); + } + const uint64 End = FPlatformTime::Cycles64(); + const uint64 Total = End - Start; + const uint64 PairCycles = Total > 0 + ? FMath::Max(1, Total / Iterations) + : 1; + GTimerPairCycles.store(PairCycles, std::memory_order_relaxed); + (void)Sink; + } + + void Reset() + { + for (FThreadState* State = GThreadStates.load(std::memory_order_acquire); + State != nullptr; + State = State->Next.load(std::memory_order_acquire)) + { + for (int32 Index = 0; Index < BucketCount; ++Index) + { + State->Calls[Index] = 0; + State->Cycles[Index] = 0; + State->Samples[Index] = 0; + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + State->Counters[Index] = 0; + } + for (int32 Index = 0; Index < BucketCount; ++Index) + { + State->SampleSequence[Index] = 0; + State->BlockCallCount[Index] = 0; + State->BlockStartCycles[Index] = 0; + State->BlockActive[Index] = false; + } + State->Classifier = FSnapshot::FClassifierStats(); + State->TunnelCacheCapacityEntries = 0; + State->TunnelCacheValidEntries = 0; + State->TunnelCacheStaticBytes = 0; + State->TunnelCacheDynamicBytes = 0; + State->TunnelCacheEntryBytes = 0; + State->TunnelCacheLargestEntryBytes = 0; + State->TunnelCacheBreakdown = FCacheMemoryBreakdown(); + State->RoomGraphCacheCapacityEntries = 0; + State->RoomGraphCacheValidEntries = 0; + State->RoomGraphCacheStaticBytes = 0; + State->RoomGraphCacheDynamicBytes = 0; + State->RoomGraphCacheEntryBytes = 0; + State->RoomGraphCacheLargestEntryBytes = 0; + State->RoomGraphCacheBreakdown = FCacheMemoryBreakdown(); + } + GScopeDepth = 0; + GScopeSampled = false; + GSampledScopeActive = false; + } + + FSnapshot Snapshot() + { + FSnapshot Result; + for (FThreadState* State = GThreadStates.load(std::memory_order_acquire); + State != nullptr; + State = State->Next.load(std::memory_order_acquire)) + { + for (int32 Index = 0; Index < BucketCount; ++Index) + { + Result.Calls[Index] += State->Calls[Index]; + Result.Samples[Index] += State->Samples[Index]; + if (State->Samples[Index] == 0) + { + Result.Cycles[Index] += State->Cycles[Index]; + } + else + { + // Store raw sampled cycles in the worker-local state. Estimate the bucket + // from its observed call/sample ratio at aggregation time. This matters for + // small buckets: multiplying every sample by 64 would overstate a bucket with + // only eight calls where all eight were sampled. + const double Scale = static_cast(State->Calls[Index]) + / static_cast(State->Samples[Index]); + const double EstimatedCycles = static_cast(State->Cycles[Index]) * Scale; + Result.Cycles[Index] += EstimatedCycles > static_cast(MAX_uint64) + ? MAX_uint64 + : static_cast(EstimatedCycles + 0.5); + } + + if (State->Calls[Index] > 0) + { + ++Result.ActiveWorkers[Index]; + const double EstimatedWorkerCycles = State->Samples[Index] == 0 + ? static_cast(State->Cycles[Index]) + : static_cast(State->Cycles[Index]) + * static_cast(State->Calls[Index]) + / static_cast(State->Samples[Index]); + Result.WallCycles[Index] = FMath::Max( + Result.WallCycles[Index], + EstimatedWorkerCycles > static_cast(MAX_uint64) + ? MAX_uint64 + : static_cast(EstimatedWorkerCycles + 0.5)); + } + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + Result.Counters[Index] += State->Counters[Index]; + } + + Result.Classifier.Calls += State->Classifier.Calls; + Result.Classifier.SampledCalls += State->Classifier.SampledCalls; + AddClassifierStatsScaled( + Result.Classifier, + State->Classifier, + State->Classifier.Calls, + State->Classifier.SampledCalls); + + const uint64 TunnelWorkerBytes = State->TunnelCacheStaticBytes + + State->TunnelCacheDynamicBytes; + if (State->TunnelCacheCapacityEntries > 0) + { + ++Result.TunnelCacheWorkers; + Result.TunnelCacheCapacityEntries += State->TunnelCacheCapacityEntries; + Result.TunnelCacheValidEntries += State->TunnelCacheValidEntries; + Result.TunnelCacheStaticBytes += State->TunnelCacheStaticBytes; + Result.TunnelCacheDynamicBytes += State->TunnelCacheDynamicBytes; + Result.TunnelCacheEntryBytes += State->TunnelCacheEntryBytes; + Result.TunnelCacheLargestEntryBytes = FMath::Max( + Result.TunnelCacheLargestEntryBytes, + State->TunnelCacheLargestEntryBytes); + Result.TunnelCacheLargestWorkerValidEntries = FMath::Max( + Result.TunnelCacheLargestWorkerValidEntries, + State->TunnelCacheValidEntries); + Result.TunnelCacheLargestWorkerBytes = FMath::Max( + Result.TunnelCacheLargestWorkerBytes, TunnelWorkerBytes); + Result.TunnelCacheBreakdown += State->TunnelCacheBreakdown; + } + + const uint64 RoomGraphWorkerBytes = State->RoomGraphCacheStaticBytes + + State->RoomGraphCacheDynamicBytes; + if (State->RoomGraphCacheCapacityEntries > 0) + { + ++Result.RoomGraphCacheWorkers; + Result.RoomGraphCacheCapacityEntries += State->RoomGraphCacheCapacityEntries; + Result.RoomGraphCacheValidEntries += State->RoomGraphCacheValidEntries; + Result.RoomGraphCacheStaticBytes += State->RoomGraphCacheStaticBytes; + Result.RoomGraphCacheDynamicBytes += State->RoomGraphCacheDynamicBytes; + Result.RoomGraphCacheEntryBytes += State->RoomGraphCacheEntryBytes; + Result.RoomGraphCacheLargestEntryBytes = FMath::Max( + Result.RoomGraphCacheLargestEntryBytes, + State->RoomGraphCacheLargestEntryBytes); + Result.RoomGraphCacheLargestWorkerValidEntries = FMath::Max( + Result.RoomGraphCacheLargestWorkerValidEntries, + State->RoomGraphCacheValidEntries); + Result.RoomGraphCacheLargestWorkerBytes = FMath::Max( + Result.RoomGraphCacheLargestWorkerBytes, RoomGraphWorkerBytes); + Result.RoomGraphCacheBreakdown += State->RoomGraphCacheBreakdown; + } + } + Result.TimerPairCycles = GetTimerPairCycles(); + Result.SampleInterval = GetSampleInterval(); + Result.Mode = GetMode(); + return Result; + } + + FSnapshot SnapshotCurrentThread() + { + FSnapshot Result; + FThreadState* State = GThreadState; + if (State == nullptr) + { + Result.TimerPairCycles = GetTimerPairCycles(); + Result.SampleInterval = GetSampleInterval(); + Result.Mode = GetMode(); + return Result; + } + + for (int32 Index = 0; Index < BucketCount; ++Index) + { + Result.Calls[Index] = State->Calls[Index]; + Result.Samples[Index] = State->Samples[Index]; + if (State->Samples[Index] == 0) + { + Result.Cycles[Index] = State->Cycles[Index]; + } + else + { + const double Scale = static_cast(State->Calls[Index]) + / static_cast(State->Samples[Index]); + const double EstimatedCycles = static_cast(State->Cycles[Index]) * Scale; + Result.Cycles[Index] = EstimatedCycles > static_cast(MAX_uint64) + ? MAX_uint64 + : static_cast(EstimatedCycles + 0.5); + } + Result.WallCycles[Index] = Result.Cycles[Index]; + Result.ActiveWorkers[Index] = State->Calls[Index] > 0 ? 1 : 0; + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + Result.Counters[Index] = State->Counters[Index]; + } + Result.Classifier = State->Classifier; + if (State->Classifier.SampledCalls > 0) + { + FSnapshot::FClassifierStats Raw = State->Classifier; + Result.Classifier = FSnapshot::FClassifierStats(); + Result.Classifier.Calls = State->Classifier.Calls; + Result.Classifier.SampledCalls = State->Classifier.SampledCalls; + AddClassifierStatsScaled( + Result.Classifier, + Raw, + State->Classifier.Calls, + State->Classifier.SampledCalls); + } + Result.TimerPairCycles = GetTimerPairCycles(); + Result.SampleInterval = GetSampleInterval(); + Result.Mode = GetMode(); + return Result; + } + + void AddCounter(ECounter Counter, uint64 Amount) + { + if (!IsEnabledFast()) + { + return; + } + const int32 Index = static_cast(Counter); + if (Index >= 0 && Index < CounterCount) + { + GetThreadState().Counters[Index] += Amount; + } + } + + void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls) + { + const int32 Index = ToIndex(Bucket); + FThreadState& State = GetThreadState(); + State.Calls[Index] += Calls; + State.Cycles[Index] += Cycles; + } + + void RecordCall(EBucket Bucket) + { + const int32 Index = ToIndex(Bucket); + GetThreadState().Calls[Index] += 1; + } + + void RecordClassifierCall(bool bSampled) + { + FThreadState& State = GetThreadState(); + ++State.Classifier.Calls; + if (bSampled) + { + ++State.Classifier.SampledCalls; + } + ++State.Calls[ToIndex(EBucket::ClassifierTotal)]; + } + + void AddClassifierStats(const FSnapshot::FClassifierStats& Stats) + { + FThreadState& State = GetThreadState(); + State.Classifier.RefineNodes += Stats.RefineNodes; + State.Classifier.StackBoxCalls += Stats.StackBoxCalls; + State.Classifier.WholeMixedNodes += Stats.WholeMixedNodes; + State.Classifier.WholeSolidNodes += Stats.WholeSolidNodes; + State.Classifier.WholeAirNodes += Stats.WholeAirNodes; + State.Classifier.NeedsFinalFieldNodes += Stats.NeedsFinalFieldNodes; + State.Classifier.SplitNodes += Stats.SplitNodes; + State.Classifier.MaxRefinementDepth = FMath::Max( + State.Classifier.MaxRefinementDepth, Stats.MaxRefinementDepth); + State.Classifier.ExactCoreSamples += Stats.ExactCoreSamples; + State.Classifier.ExactFinalSamples += Stats.ExactFinalSamples; + State.Classifier.ExactCoreCacheHits += Stats.ExactCoreCacheHits; + State.Classifier.ExactFinalCacheHits += Stats.ExactFinalCacheHits; + State.Classifier.ExactCoreLeaves += Stats.ExactCoreLeaves; + State.Classifier.ExactFinalLeaves += Stats.ExactFinalLeaves; + State.Classifier.StackBoxCycles += Stats.StackBoxCycles; + State.Classifier.ExactCoreCycles += Stats.ExactCoreCycles; + State.Classifier.ExactFinalCycles += Stats.ExactFinalCycles; + State.Classifier.RoomTailQueries += Stats.RoomTailQueries; + State.Classifier.RoomTailEvaluated += Stats.RoomTailEvaluated; + State.Classifier.RoomTailCycles += Stats.RoomTailCycles; + State.Classifier.RoomPropagateCycles += Stats.RoomPropagateCycles; + State.Classifier.RoomExactPrimitiveCycles += Stats.RoomExactPrimitiveCycles; + State.Classifier.RoomCacheWindowCycles += Stats.RoomCacheWindowCycles; + State.Classifier.RoomNumRooms = FMath::Max( + State.Classifier.RoomNumRooms, Stats.RoomNumRooms); + State.Classifier.RoomNumTunnels = FMath::Max( + State.Classifier.RoomNumTunnels, Stats.RoomNumTunnels); + State.Classifier.RoomNumRoomFloorJoins = FMath::Max( + State.Classifier.RoomNumRoomFloorJoins, Stats.RoomNumRoomFloorJoins); + State.Classifier.RoomNumPits = FMath::Max( + State.Classifier.RoomNumPits, Stats.RoomNumPits); + State.Classifier.RoomNumChimneys = FMath::Max( + State.Classifier.RoomNumChimneys, Stats.RoomNumChimneys); + } + + bool ShouldSample(EBucket Bucket) + { + if (!IsCycleTimingEnabled()) + { + return false; + } + if (GetMode() == EMode::Full) + { + return true; + } + + FThreadState& State = GetThreadState(); + const int32 Index = ToIndex(Bucket); + const uint32 Sequence = State.SampleSequence[Index]++; + const uint32 Interval = GetSampleInterval(); + if ((Interval & (Interval - 1u)) == 0u) + { + return (Sequence & (Interval - 1u)) == 0u; + } + return (Sequence % Interval) == 0u; + } + + void AddSampledMeasurement(EBucket Bucket, uint64 RawCycles) + { + const int32 Index = ToIndex(Bucket); + FThreadState& State = GetThreadState(); + const uint64 TimerCost = GetTimerPairCycles(); + const uint64 AdjustedCycles = RawCycles > TimerCost + ? RawCycles - TimerCost + : 0; + State.Cycles[Index] += AdjustedCycles; + State.Samples[Index] += 1; + } + + void SetWorkerTunnelCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries, + const FCacheMemoryBreakdown& Breakdown) + { + if (!IsEnabled()) { return; } + FThreadState& State = GetThreadState(); + State.TunnelCacheCapacityEntries = CapacityEntries; + State.TunnelCacheValidEntries = ValidEntries; + State.TunnelCacheStaticBytes = StaticBytes; + State.TunnelCacheDynamicBytes = DynamicBytes; + State.TunnelCacheEntryBytes = EntryBytes; + State.TunnelCacheLargestEntryBytes = LargestEntryBytes; + State.TunnelCacheLargestWorkerValidEntries = LargestWorkerValidEntries; + State.TunnelCacheBreakdown = Breakdown; + } + + void SetWorkerRoomGraphCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries, + const FCacheMemoryBreakdown& Breakdown) + { + if (!IsEnabled()) { return; } + FThreadState& State = GetThreadState(); + State.RoomGraphCacheCapacityEntries = CapacityEntries; + State.RoomGraphCacheValidEntries = ValidEntries; + State.RoomGraphCacheStaticBytes = StaticBytes; + State.RoomGraphCacheDynamicBytes = DynamicBytes; + State.RoomGraphCacheEntryBytes = EntryBytes; + State.RoomGraphCacheLargestEntryBytes = LargestEntryBytes; + State.RoomGraphCacheLargestWorkerValidEntries = LargestWorkerValidEntries; + State.RoomGraphCacheBreakdown = Breakdown; + } + + const TCHAR* CounterName(ECounter Counter) + { + switch (Counter) + { + case ECounter::TunnelCacheLookup: return TEXT("TunnelCacheLookup"); + case ECounter::TunnelCacheHit: return TEXT("TunnelCacheHit"); + case ECounter::TunnelCacheMiss: return TEXT("TunnelCacheMiss"); + case ECounter::SdfCacheBuild: return TEXT("SdfCacheBuild"); + case ECounter::SdfSpatialIndexBuild: return TEXT("SdfSpatialIndexBuild"); + case ECounter::SdfBrickIndexBuild: return TEXT("SdfBrickIndexBuild"); + case ECounter::RoomGraphBuildGeneratorTile: return TEXT("RoomGraphBuildGeneratorTile"); + case ECounter::RoomGraphBuildGeneratorTunnelCore: return TEXT("RoomGraphBuildGeneratorTunnelCore"); + case ECounter::RoomGraphBuildOpShared: return TEXT("RoomGraphBuildOpShared"); + case ECounter::RoomGraphBuildOpLocal: return TEXT("RoomGraphBuildOpLocal"); + case ECounter::RoomGraphBuildClassifierShared: return TEXT("RoomGraphBuildClassifierShared"); + case ECounter::RoomGraphBuildClassifierLocal: return TEXT("RoomGraphBuildClassifierLocal"); + case ECounter::RoomGraphBuildUnknown: return TEXT("RoomGraphBuildUnknown"); + case ECounter::CaveRoomCandidates:return TEXT("CaveRoomCandidates"); + case ECounter::CaveRoomEvaluated: return TEXT("CaveRoomEvaluated"); + case ECounter::CaveTunnelCandidates:return TEXT("CaveTunnelCandidates"); + case ECounter::CaveTunnelEvaluated:return TEXT("CaveTunnelEvaluated"); + case ECounter::TunnelCoreCandidates:return TEXT("TunnelCoreCandidates"); + case ECounter::TunnelCoreEvaluated:return TEXT("TunnelCoreEvaluated"); + case ECounter::TunnelSupportColumnBuilds:return TEXT("TunnelSupportColumnBuilds"); + case ECounter::TunnelSupportColumnCandidates:return TEXT("TunnelSupportColumnCandidates"); + case ECounter::TunnelSupportFloorQueries:return TEXT("TunnelSupportFloorQueries"); + case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks"); + case ECounter::TunnelSupportFloorBackstopFires:return TEXT("TunnelSupportFloorBackstopFires"); + case ECounter::TunnelRoomFloorBackstopFires:return TEXT("TunnelRoomFloorBackstopFires"); + case ECounter::TunnelAuthoredFloorSamples:return TEXT("TunnelAuthoredFloorSamples"); + case ECounter::TunnelAuthoredFloorProtectionClamps:return TEXT("TunnelAuthoredFloorProtectionClamps"); + case ECounter::TunnelAuthoredFloorDisturbanceSkips:return TEXT("TunnelAuthoredFloorDisturbanceSkips"); + case ECounter::TunnelFloorProfileFallbacks:return TEXT("TunnelFloorProfileFallbacks"); + case ECounter::TunnelFloorProfileBuilds:return TEXT("TunnelFloorProfileBuilds"); + case ECounter::TunnelFloorProfileSegments:return TEXT("TunnelFloorProfileSegments"); + case ECounter::TunnelFloorProfileLedges:return TEXT("TunnelFloorProfileLedges"); + case ECounter::TunnelFloorProfileLedgesBelow2Voxels:return TEXT("TunnelFloorProfileLedgesBelow2Voxels"); + case ECounter::TunnelFloorProfileLedges2To4Voxels:return TEXT("TunnelFloorProfileLedges2To4Voxels"); + case ECounter::TunnelFloorProfileLedges4To8Voxels:return TEXT("TunnelFloorProfileLedges4To8Voxels"); + case ECounter::TunnelFloorProfileLedges8PlusVoxels:return TEXT("TunnelFloorProfileLedges8PlusVoxels"); + case ECounter::TunnelFloorSteepEdges:return TEXT("TunnelFloorSteepEdges"); + case ECounter::TunnelFloorWindingEdges:return TEXT("TunnelFloorWindingEdges"); + case ECounter::TunnelFloorDropEdges:return TEXT("TunnelFloorDropEdges"); + case ECounter::TunnelFloorMultiStepEdges:return TEXT("TunnelFloorMultiStepEdges"); + case ECounter::PassageSupportFloorBackstopFires:return TEXT("PassageSupportFloorBackstopFires"); + case ECounter::PassageNativeFloorCompositions:return TEXT("PassageNativeFloorCompositions"); + case ECounter::PassageCandidates: return TEXT("PassageCandidates"); + case ECounter::PassageEvaluated: return TEXT("PassageEvaluated"); + case ECounter::MesherBlockTests: return TEXT("MesherBlockTests"); + case ECounter::MesherBlockAllSolid:return TEXT("MesherBlockAllSolid"); + case ECounter::MesherBlockAllAir: return TEXT("MesherBlockAllAir"); + case ECounter::TileClassifyCalls: return TEXT("TileClassifyCalls"); + case ECounter::TileClassifyAllSolid: return TEXT("TileClassifyAllSolid"); + case ECounter::TileClassifyAllAir: return TEXT("TileClassifyAllAir"); + case ECounter::TileClassifyMixed: return TEXT("TileClassifyMixed"); + case ECounter::FusedTunnelSamples: return TEXT("FusedTunnelSamples"); + case ECounter::FusedTunnelDetailSamples: return TEXT("FusedTunnelDetailSamples"); + case ECounter::FusedTunnelDetailSkipped: return TEXT("FusedTunnelDetailSkipped"); + case ECounter::WormEligibleSamples: return TEXT("WormEligibleSamples"); + case ECounter::WormBlockProofs: return TEXT("WormBlockProofs"); + case ECounter::WormBlockSkippedSamples: return TEXT("WormBlockSkippedSamples"); + case ECounter::OpBlockBuilds: return TEXT("OpBlockBuilds"); + case ECounter::OpBlockSamples: return TEXT("OpBlockSamples"); + case ECounter::OpBlockOperators: return TEXT("OpBlockOperators"); + case ECounter::OpBlockActiveOperators: return TEXT("OpBlockActiveOperators"); + case ECounter::OpBlockPrunedOperators: return TEXT("OpBlockPrunedOperators"); + case ECounter::MesherDensityGridBytes: return TEXT("MesherDensityGridBytes"); + case ECounter::MesherSharedGridReadBytes: return TEXT("MesherSharedGridReadBytes"); + case ECounter::MesherOutputArrayBytes: return TEXT("MesherOutputArrayBytes"); + case ECounter::MesherOutputAllocatedBytes: return TEXT("MesherOutputAllocatedBytes"); + case ECounter::OpBlockScratchBytes: return TEXT("OpBlockScratchBytes"); + case ECounter::OpBlockCopyBytes: return TEXT("OpBlockCopyBytes"); + case ECounter::Count: break; + } + return TEXT("Unknown"); + } + + EBucket BucketFromName(const TCHAR* Name) + { + if (Name == nullptr) { return EBucket::OtherOp; } + + struct FNameBucket + { + const TCHAR* Name; + EBucket Bucket; + }; + static const FNameBucket Names[] = + { + { TEXT("ApplyDisturbances"), EBucket::ApplyDisturbances }, + { TEXT("PassageModifier"), EBucket::PassageModifier }, + { TEXT("PassageLandingAir"), EBucket::PassageLandingAir }, + { TEXT("PassageLandingFloor"), EBucket::PassageLandingFloor }, + { TEXT("PassageLandingRoomFloor"), EBucket::PassageLandingRoomFloor }, + { TEXT("PassageTunnelAir"), EBucket::PassageTunnelAir }, + { TEXT("PassageStructuralPosts"), EBucket::PassageStructuralPosts }, + { TEXT("TunnelCoreSupport"), EBucket::TunnelCoreSupport }, + { TEXT("TunnelCoreSdf"), EBucket::TunnelCoreSdf }, + { TEXT("TunnelCorePosts"), EBucket::TunnelCorePosts }, + { TEXT("StructuralTail"), EBucket::StructuralTail }, + { TEXT("RoomGraphSource"), EBucket::RoomGraphSource }, + { TEXT("SdfCarve"), EBucket::SdfCarve }, + { TEXT("SdfConvertOp"), EBucket::SdfCarve }, + { TEXT("CaveRoughnessMod"), EBucket::CaveRoughnessMod }, + { TEXT("CaveTerraceMod"), EBucket::CaveTerraceMod }, + { TEXT("LayerLineMod"), EBucket::LayerLineMod }, + { TEXT("RibbingMod"), EBucket::RibbingMod }, + { TEXT("CaveOverhangMod"), EBucket::CaveOverhangMod }, + { TEXT("CaveCliffMod"), EBucket::CaveCliffMod }, + { TEXT("ScallopMod"), EBucket::ScallopMod }, + { TEXT("CaveArchMod"), EBucket::CaveArchMod }, + { TEXT("RoomColumnMod"), EBucket::RoomColumnMod }, + { TEXT("DomeMod"), EBucket::DomeMod }, + { TEXT("PinchMod"), EBucket::PinchMod }, + { TEXT("FloorBiasMod"), EBucket::FloorBiasMod }, + { TEXT("WormFieldSource"), EBucket::WormFieldSource }, + { TEXT("OriginSpineOp"), EBucket::OriginSpineOp }, + { TEXT("BoundarySealOp"), EBucket::BoundarySealOp }, + { TEXT("PassageCarveOp"), EBucket::PassageCarveOp }, + { TEXT("XYEdgeSealOp"), EBucket::XYEdgeSealOp }, + { TEXT("FusedEvaluator"), EBucket::FusedEvaluator }, + { TEXT("InterpretedOpStack"),EBucket::InterpretedOpStack }, + { TEXT("OperatorBlock"), EBucket::OperatorBlock }, + { TEXT("RoomGraphBuild"), EBucket::RoomGraphBuild }, + { TEXT("RoomGraphSdf"), EBucket::RoomGraphSdf }, + { TEXT("TunnelCoreWorld"), EBucket::TunnelCoreWorld }, + { TEXT("FusedDetail"), EBucket::FusedDetail }, + { TEXT("FusedWorm"), EBucket::FusedWorm }, + { TEXT("FusedStructural"), EBucket::FusedStructural }, + { TEXT("ClassifierTotal"), EBucket::ClassifierTotal }, + { TEXT("ClassifierIntervalProof"), EBucket::ClassifierIntervalProof }, + { TEXT("ClassifierExactCore"), EBucket::ClassifierExactCore }, + { TEXT("ClassifierExactFinal"), EBucket::ClassifierExactFinal }, + { TEXT("ClassifierRoomTail"), EBucket::ClassifierRoomTail }, + { TEXT("RuntimeStreamBuilding"), EBucket::RuntimeStreamBuilding }, + { TEXT("DensityPrologue"), EBucket::DensityPrologue }, + { TEXT("DensityCore"), EBucket::DensityCore }, + { TEXT("DensityDisturbances"), EBucket::DensityDisturbances }, + { TEXT("DensityStructuralPosts"), EBucket::DensityStructuralPosts }, + { TEXT("DensityBoundarySeal"), EBucket::DensityBoundarySeal }, + { TEXT("DensityDiffLayer"), EBucket::DensityDiffLayer }, + { TEXT("DensityTail"), EBucket::DensityTail }, + { TEXT("MesherGenerateMesh"), EBucket::MesherGenerateMesh }, + { TEXT("MesherDensityGrid"), EBucket::MesherDensityGrid }, + { TEXT("MesherCellClassification"), EBucket::MesherCellClassification }, + { TEXT("MesherGradientNormals"), EBucket::MesherGradientNormals }, + { TEXT("MesherVertexInterpolation"), EBucket::MesherVertexInterpolation }, + { TEXT("MesherStreamBuilding"), EBucket::MesherStreamBuilding }, + { TEXT("MesherOther"), EBucket::MesherOther }, + }; + static const TMap> NamesByHash = []() + { + TMap> Lookup; + for (const FNameBucket& Entry : Names) + { + Lookup.FindOrAdd(FCrc::StrCrc32(Entry.Name)).Add(&Entry); + } + return Lookup; + }(); + + const TArray* Candidates = NamesByHash.Find(FCrc::StrCrc32(Name)); + if (Candidates != nullptr) + { + for (const FNameBucket* Entry : *Candidates) + { + if (Entry != nullptr && FCString::Strcmp(Name, Entry->Name) == 0) + { + return Entry->Bucket; + } + } + } + return EBucket::OtherOp; + } + + const TCHAR* BucketName(EBucket Bucket) + { + switch (Bucket) + { + case EBucket::GetDensityAt: return TEXT("GetDensityAt"); + case EBucket::ApplyDisturbances:return TEXT("ApplyDisturbances"); + case EBucket::PassageModifier: return TEXT("PassageModifier"); + case EBucket::PassageLandingAir:return TEXT("PassageLandingAir"); + case EBucket::PassageLandingFloor:return TEXT("PassageLandingFloor"); + case EBucket::PassageLandingRoomFloor:return TEXT("PassageLandingRoomFloor"); + case EBucket::PassageTunnelAir:return TEXT("PassageTunnelAir"); + case EBucket::PassageStructuralPosts:return TEXT("PassageStructuralPosts"); + case EBucket::TunnelCoreSupport:return TEXT("TunnelCoreSupport"); + case EBucket::TunnelCoreSdf: return TEXT("TunnelCoreSdf"); + case EBucket::TunnelCorePosts: return TEXT("TunnelCorePosts"); + case EBucket::StructuralTail: return TEXT("StructuralTail"); + case EBucket::RoomGraphSource: return TEXT("RoomGraphSource"); + case EBucket::SdfCarve: return TEXT("SdfCarve"); + case EBucket::CaveRoughnessMod: return TEXT("CaveRoughnessMod"); + case EBucket::CaveTerraceMod: return TEXT("CaveTerraceMod"); + case EBucket::LayerLineMod: return TEXT("LayerLineMod"); + case EBucket::RibbingMod: return TEXT("RibbingMod"); + case EBucket::CaveOverhangMod: return TEXT("CaveOverhangMod"); + case EBucket::CaveCliffMod: return TEXT("CaveCliffMod"); + case EBucket::ScallopMod: return TEXT("ScallopMod"); + case EBucket::CaveArchMod: return TEXT("CaveArchMod"); + case EBucket::RoomColumnMod: return TEXT("RoomColumnMod"); + case EBucket::DomeMod: return TEXT("DomeMod"); + case EBucket::PinchMod: return TEXT("PinchMod"); + case EBucket::FloorBiasMod: return TEXT("FloorBiasMod"); + case EBucket::WormFieldSource: return TEXT("WormFieldSource"); + case EBucket::OriginSpineOp: return TEXT("OriginSpineOp"); + case EBucket::BoundarySealOp: return TEXT("BoundarySealOp"); + case EBucket::PassageCarveOp: return TEXT("PassageCarveOp"); + case EBucket::XYEdgeSealOp: return TEXT("XYEdgeSealOp"); + case EBucket::OtherOp: return TEXT("OtherOp"); + case EBucket::FusedEvaluator: return TEXT("FusedEvaluator"); + case EBucket::InterpretedOpStack:return TEXT("InterpretedOpStack"); + case EBucket::OperatorBlock: return TEXT("OperatorBlock"); + case EBucket::RoomGraphBuild: return TEXT("RoomGraphBuild"); + case EBucket::RoomGraphSdf: return TEXT("RoomGraphSdf"); + case EBucket::TunnelCoreWorld: return TEXT("TunnelCoreWorld"); + case EBucket::FusedDetail: return TEXT("FusedDetail"); + case EBucket::FusedWorm: return TEXT("FusedWorm"); + case EBucket::FusedStructural: return TEXT("FusedStructural"); + case EBucket::ClassifierTotal: return TEXT("ClassifierTotal"); + case EBucket::ClassifierIntervalProof:return TEXT("ClassifierIntervalProof"); + case EBucket::ClassifierExactCore:return TEXT("ClassifierExactCore"); + case EBucket::ClassifierExactFinal:return TEXT("ClassifierExactFinal"); + case EBucket::ClassifierRoomTail:return TEXT("ClassifierRoomTail"); + case EBucket::RuntimeStreamBuilding:return TEXT("RuntimeStreamBuilding"); + case EBucket::DensityPrologue: return TEXT("DensityPrologue"); + case EBucket::DensityCore: return TEXT("DensityCore"); + case EBucket::DensityDisturbances:return TEXT("DensityDisturbances"); + case EBucket::DensityStructuralPosts:return TEXT("DensityStructuralPosts"); + case EBucket::DensityBoundarySeal:return TEXT("DensityBoundarySeal"); + case EBucket::DensityDiffLayer: return TEXT("DensityDiffLayer"); + case EBucket::DensityTail: return TEXT("DensityTail"); + case EBucket::MesherGenerateMesh:return TEXT("MesherGenerateMesh"); + case EBucket::MesherDensityGrid:return TEXT("MesherDensityGrid"); + case EBucket::MesherCellClassification:return TEXT("MesherCellClassification"); + case EBucket::MesherGradientNormals:return TEXT("MesherGradientNormals"); + case EBucket::MesherVertexInterpolation:return TEXT("MesherVertexInterpolation"); + case EBucket::MesherStreamBuilding:return TEXT("MesherStreamBuilding"); + case EBucket::MesherOther: return TEXT("MesherOther"); + case EBucket::Count: break; + } + return TEXT("Unknown"); + } + + FScopeToken BeginScope(EBucket Bucket) + { + FScopeToken Token{}; + if (!IsEnabledFast()) + { + return Token; + } + const EMode Mode = GetMode(); + const bool bSampledMode = Mode == EMode::Sampled || Mode == EMode::Attribution; + if ((Mode == EMode::Sampled && !IsSampledScopeEnabled(Bucket)) + || (Mode == EMode::Attribution && !IsAttributionScope(Bucket))) + { + return Token; + } + + Token.bEntered = true; + Token.bPreviousSampled = GScopeSampled; + Token.bPreviousSampledScopeActive = GSampledScopeActive; + ++GScopeDepth; + + // For the hot parent, amortise the timer over a deterministic block of calls. A timer + // pair around one ~100 ns operation is mostly the timer; a pair around 64 calls is cheap + // and estimates the parent from real elapsed work rather than instrumentation latency. + if (bSampledMode && Bucket == EBucket::GetDensityAt) + { + FThreadState& State = GetThreadState(); + const int32 Index = ToIndex(Bucket); + const uint32 Sequence = State.SampleSequence[Index]++; + Token.bBlockTiming = true; + const uint32 Interval = GetSampleInterval(); + const bool bBlockBoundary = (Interval & (Interval - 1u)) == 0u + ? (Sequence & (Interval - 1u)) == 0u + : (Sequence % Interval) == 0u; + if (bBlockBoundary && !State.BlockActive[Index]) + { + State.BlockActive[Index] = true; + State.BlockCallCount[Index] = 0; + State.BlockStartCycles[Index] = FPlatformTime::Cycles64(); + } + // The selected call admits all nested component scopes. Every other call exits the + // inline FScopedTimer fast path before crossing into this translation unit. + GScopeSampled = bBlockBoundary; + GSampledScopeActive = bBlockBoundary; + Token.bSampled = bBlockBoundary; + return Token; + } + + // Every bucket samples independently, but only one nested non-container scope owns a + // timer at a time. Otherwise a sampled GetDensityAt would also start timers for every + // phase and operation inside that same call, measuring instrumentation instead of work. + // Mesher container ranges deliberately remain transparent so their grid can still expose + // density samples underneath them. + const bool bContainerScope = Bucket == EBucket::MesherGenerateMesh + || Bucket == EBucket::MesherDensityGrid; + const bool bInheritedAttributionSample = Mode == EMode::Attribution + && GSampledScopeActive; + Token.bSampled = Mode == EMode::Full + ? true + : ((Mode == EMode::Attribution && IsAttributionAlwaysTimed(Bucket)) + ? true + : (bInheritedAttributionSample + ? true + : (Mode == EMode::Attribution && IsAttributionScope(Bucket) + ? ShouldSample(Bucket) + : (!GSampledScopeActive && ShouldSample(Bucket))))); + GScopeSampled = Token.bSampled; + if (Token.bSampled) + { + if ((Mode == EMode::Sampled || Mode == EMode::Attribution) && !bContainerScope) + { + GSampledScopeActive = true; + } + Token.StartCycles = FPlatformTime::Cycles64(); + } + return Token; + } + + void EndScope(EBucket Bucket, const FScopeToken& Token) + { + if (!Token.bEntered) + { + return; + } + + RecordCall(Bucket); + if (Token.bBlockTiming) + { + FThreadState& State = GetThreadState(); + const int32 Index = ToIndex(Bucket); + ++State.BlockCallCount[Index]; + const uint32 Interval = GetSampleInterval(); + if (State.BlockActive[Index] && State.BlockCallCount[Index] >= Interval) + { + const uint64 RawCycles = FPlatformTime::Cycles64() - State.BlockStartCycles[Index]; + const uint64 TimerCost = GetTimerPairCycles(); + const uint64 AdjustedCycles = RawCycles > TimerCost + ? RawCycles - TimerCost + : 0; + State.Cycles[Index] += AdjustedCycles / FMath::Max(1, Interval); + State.Samples[Index] += 1; + State.BlockActive[Index] = false; + State.BlockCallCount[Index] = 0; + State.BlockStartCycles[Index] = 0; + } + } + else if (Token.bSampled) + { + AddSampledMeasurement(Bucket, FPlatformTime::Cycles64() - Token.StartCycles); + } + + check(GScopeDepth > 0); + --GScopeDepth; + GScopeSampled = Token.bPreviousSampled; + GSampledScopeActive = Token.bPreviousSampledScopeActive; + } + +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelDiffLayer.cpp b/Standalone/Source/VoxelForge/Private/VoxelDiffLayer.cpp new file mode 100644 index 0000000..33716d7 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelDiffLayer.cpp @@ -0,0 +1,469 @@ +// VoxelDiffLayer.cpp +// Runtime terrain modification storage and evaluation. + +#include "VoxelDiffLayer.h" + +namespace +{ + std::atomic GNextDiffLayerLifetimeId{0}; + + bool IsFiniteVector(const FVector& Value) + { + return VoxelMath::IsFinite(Value.X) + && VoxelMath::IsFinite(Value.Y) + && VoxelMath::IsFinite(Value.Z); + } + + bool IsValidModificationGeometry(const FVoxelModification& Mod) + { + if (!IsFiniteVector(Mod.Center) || !IsFiniteVector(Mod.BoxExtent) + || !IsFiniteVector(Mod.CapsuleEnd) + || !VoxelMath::IsFinite(Mod.Radius) + || !VoxelMath::IsFinite(Mod.Strength) + || !VoxelMath::IsFinite(Mod.Falloff) + || Mod.Radius <= 0.0f || Mod.Falloff < 0.0f) + { + return false; + } + + switch (Mod.Shape) + { + case EVoxelBrushShape::Sphere: + return true; + case EVoxelBrushShape::Box: + return Mod.BoxExtent.X > 0.0f + && Mod.BoxExtent.Y > 0.0f + && Mod.BoxExtent.Z > 0.0f; + case EVoxelBrushShape::Capsule: + return true; // A coincident pair of endpoints is a valid spherical capsule. + default: + return false; + } + } + + double GetModificationBudgetVolume(const FVoxelModification& Mod) + { + switch (Mod.Shape) + { + case EVoxelBrushShape::Box: + { + // The SDF's falloff is a rounded outer band, so the expanded axis-aligned box is a + // conservative charge and remains cheap to compute. + const double Ex = static_cast(Mod.BoxExtent.X) + static_cast(Mod.Falloff); + const double Ey = static_cast(Mod.BoxExtent.Y) + static_cast(Mod.Falloff); + const double Ez = static_cast(Mod.BoxExtent.Z) + static_cast(Mod.Falloff); + return 8.0 * Ex * Ey * Ez; + } + case EVoxelBrushShape::Capsule: + { + // Charge the full capsule including its falloff shell: cylinder + two hemispheres. + const double OuterRadius = static_cast(Mod.Radius) + static_cast(Mod.Falloff); + const double SegmentLength = static_cast(FVector::Dist(Mod.Center, Mod.CapsuleEnd)); + return PI * OuterRadius * OuterRadius * SegmentLength + + (4.0 / 3.0) * PI * OuterRadius * OuterRadius * OuterRadius; + } + case EVoxelBrushShape::Sphere: + default: + { + const double Radius = static_cast(Mod.Radius); + return (4.0 / 3.0) * PI * Radius * Radius * Radius; + } + } + } +} + +uint64 UVoxelDiffLayer::GetCacheLifetimeId() const +{ + uint64 LifetimeId = CacheLifetimeId.load(std::memory_order_acquire); + if (LifetimeId != 0) + { + return LifetimeId; + } + + const uint64 NewLifetimeId = GNextDiffLayerLifetimeId.fetch_add(1, std::memory_order_relaxed) + 1; + if (CacheLifetimeId.compare_exchange_strong( + LifetimeId, + NewLifetimeId, + std::memory_order_release, + std::memory_order_acquire)) + { + return NewLifetimeId; + } + + // Another worker initialized the identity first. Its value is written into LifetimeId by + // compare_exchange_strong on failure. + return LifetimeId; +} + +//============================================================================= +// BUDGET CONFIGURATION +//============================================================================= + +void UVoxelDiffLayer::SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxVolume) +{ + BudgetMaxMods = InMaxMods; + BudgetMaxRadius = InMaxRadius; + BudgetMaxVolume = InMaxVolume; + + UE_LOG(LogTemp, Log, TEXT("[DiffLayer] Budget set: MaxMods=%d, MaxRadius=%.1f, MaxVolume=%.0f (0=unlimited)"), + BudgetMaxMods, BudgetMaxRadius, BudgetMaxVolume); +} + +bool UVoxelDiffLayer::CanModify(float Radius) const +{ + if (!VoxelMath::IsFinite(Radius) || Radius <= 0.0f) + { + return false; + } + + // Check radius limit (always enforced, even if "unlimited" count/volume) + if (BudgetMaxRadius > 0.0f && Radius > BudgetMaxRadius) + { + return false; + } + + // Check modification count limit + if (BudgetMaxMods > 0 && ModificationCount >= BudgetMaxMods) + { + return false; + } + + // Check volume limit: would this brush push us over? + if (BudgetMaxVolume > 0.0f) + { + const double BrushVolume = (4.0 / 3.0) * PI + * static_cast(Radius) * static_cast(Radius) * static_cast(Radius); + const double NewTotal = static_cast(AccumulatedVolume) + BrushVolume; + if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast(FLT_MAX) + || !VoxelMath::IsFinite(NewTotal) + || NewTotal > static_cast(BudgetMaxVolume)) + { + return false; + } + } + + return true; +} + +bool UVoxelDiffLayer::CanModify(const FVoxelModification& Mod) const +{ + if (!IsValidModificationGeometry(Mod)) return false; + + // Radius is the radial cap for spheres/capsules. A box's largest half-extent is its analogous + // brush radius; checking it separately prevents a direct API caller from hiding a huge box behind + // an unrelated small Radius field. + const float GeometryRadius = (Mod.Shape == EVoxelBrushShape::Box) + ? FMath::Max3(Mod.BoxExtent.X, Mod.BoxExtent.Y, Mod.BoxExtent.Z) + : Mod.Radius; + if (BudgetMaxRadius > 0.0f && GeometryRadius > BudgetMaxRadius) return false; + if (BudgetMaxMods > 0 && ModificationCount >= BudgetMaxMods) return false; + + const double BrushVolume = GetModificationBudgetVolume(Mod); + const double NewTotal = static_cast(AccumulatedVolume) + BrushVolume; + if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast(FLT_MAX) + || !VoxelMath::IsFinite(NewTotal)) return false; + if (BudgetMaxVolume > 0.0f && NewTotal > static_cast(BudgetMaxVolume)) return false; + return true; +} + +int32 UVoxelDiffLayer::GetRemainingModifications() const +{ + if (BudgetMaxMods <= 0) return -1; // Unlimited + return FMath::Max(0, BudgetMaxMods - ModificationCount); +} + +float UVoxelDiffLayer::GetRemainingVolume() const +{ + if (BudgetMaxVolume <= 0.0f) return -1.0f; // Unlimited + return FMath::Max(0.0f, BudgetMaxVolume - AccumulatedVolume); +} + +//============================================================================= +// APPLY MODIFICATION +//============================================================================= + +TArray UVoxelDiffLayer::ApplyModification(const FVoxelModification& Mod) +{ + TArray AffectedChunks; + + // Validate the complete geometry before radius clamping, budget accounting, or AABB math. + // FMath::Min(NaN, limit) and the shape switch's default sphere path would otherwise turn + // malformed API input into either a counter update or an invalid chunk range. + if (!IsValidModificationGeometry(Mod)) + { + UE_LOG(LogTemp, Warning, + TEXT("[DiffLayer] Modification REJECTED — non-finite or invalid brush geometry")); + return AffectedChunks; + } + + // --- Budget enforcement --- + // Preserve the historical radius clamp for spheres/capsules when a finite per-brush cap is set; + // the shape-aware check below still charges the resulting geometry and rejects oversized boxes. + const float ClampedRadius = (BudgetMaxRadius > 0.0f) + ? FMath::Min(Mod.Radius, BudgetMaxRadius) : Mod.Radius; + FVoxelModification ClampedMod = Mod; + ClampedMod.Radius = ClampedRadius; + + if (!CanModify(ClampedMod)) + { + UE_LOG(LogTemp, Warning, + TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"), + ModificationCount, BudgetMaxMods, AccumulatedVolume, BudgetMaxVolume); + return AffectedChunks; // Empty = rejected + } + + // Complete the world-AABB calculation before mutating the budget counters. Finite input can + // still overflow during Center +/- extent or address a chunk outside FIntVector's domain; + // neither case is a modification that this layer can store safely. + FVector BoundsMin, BoundsMax; + ClampedMod.GetWorldBounds(BoundsMin, BoundsMax); + auto WorldToChunk = [](float WorldCoord, int32& OutChunk) -> bool + { + if (!VoxelMath::IsFinite(WorldCoord)) + { + return false; + } + const double ChunkCoord = FMath::FloorToDouble( + static_cast(WorldCoord) / static_cast(CHUNK_SIZE)); + if (!VoxelMath::IsFinite(ChunkCoord) + || ChunkCoord < static_cast(MIN_int32) + || ChunkCoord > static_cast(MAX_int32)) + { + return false; + } + OutChunk = static_cast(ChunkCoord); + return true; + }; + + int32 MinCX = 0, MaxCX = 0, MinCY = 0, MaxCY = 0, MinCZ = 0, MaxCZ = 0; + if (!WorldToChunk(BoundsMin.X, MinCX) || !WorldToChunk(BoundsMax.X, MaxCX) + || !WorldToChunk(BoundsMin.Y, MinCY) || !WorldToChunk(BoundsMax.Y, MaxCY) + || !WorldToChunk(BoundsMin.Z, MinCZ) || !WorldToChunk(BoundsMax.Z, MaxCZ)) + { + UE_LOG(LogTemp, Warning, + TEXT("[DiffLayer] Modification REJECTED — brush bounds are not representable")); + return TArray(); + } + + // Update budget counters + ModificationCount++; + const double BrushVolume = GetModificationBudgetVolume(ClampedMod); + AccumulatedVolume += static_cast(BrushVolume); + + { + // Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map + // is mutated/rehashed. AffectedChunks is local; populate it in the same pass. + FWriteScopeLock Lock(ModsLock); + for (int32 CZ = MinCZ;; ++CZ) + { + for (int32 CY = MinCY;; ++CY) + { + for (int32 CX = MinCX;; ++CX) + { + FIntVector ChunkCoord(CX, CY, CZ); + + // Store the modification in this chunk's list + ChunkMods.FindOrAdd(ChunkCoord).Add(ClampedMod); + + // Track which chunks need re-meshing + AffectedChunks.Add(ChunkCoord); + + if (CX == MaxCX) break; + } + if (CY == MaxCY) break; + } + if (CZ == MaxCZ) break; + } + // Publish: subsequent readers must now take the lock instead of fast-rejecting, and + // worker-side snapshots keyed on the version re-copy their chunk's list. + bHasAnyMods.store(true, std::memory_order_release); + ModsVersion.fetch_add(1, std::memory_order_release); + } + + UE_LOG(LogTemp, Log, + TEXT("[DiffLayer] Modification #%d at (%.0f, %.0f, %.0f) R=%.1f S=%.1f -> %d chunks (budget: %d/%d mods, %.0f/%.0f vol)"), + ModificationCount, + ClampedMod.Center.X, ClampedMod.Center.Y, ClampedMod.Center.Z, + ClampedMod.Radius, ClampedMod.Strength, + AffectedChunks.Num(), + ModificationCount, BudgetMaxMods, + AccumulatedVolume, BudgetMaxVolume); + + return AffectedChunks; +} + +//============================================================================= +// DENSITY EVALUATION +//============================================================================= + +float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord, + float WorldX, float WorldY, float WorldZ) const +{ + // Lock-free fast reject: no carves anywhere -> nothing to offset. + if (!bHasAnyMods.load(std::memory_order_acquire)) return 0.0f; + + // Hold the read lock for the whole body: Mods points INTO the map and is dereferenced through the + // falloff loop, so the map must not be rehashed by a concurrent writer meanwhile. NOTE: hot-path + // callers (the generator) should prefer GetChunkModsSnapshot + EvaluateMods — one lock per chunk + // instead of one per voxel. + FReadScopeLock Lock(ModsLock); + + const TArray* Mods = ChunkMods.Find(ChunkCoord); + if (!Mods || Mods->Num() == 0) return 0.0f; + + return EvaluateMods(*Mods, WorldX, WorldY, WorldZ); +} + +void UVoxelDiffLayer::GetChunkModsSnapshot(const FIntVector& ChunkCoord, TArray& Out) const +{ + Out.Reset(); + if (!bHasAnyMods.load(std::memory_order_acquire)) return; + + FReadScopeLock Lock(ModsLock); + if (const TArray* Mods = ChunkMods.Find(ChunkCoord)) + { + Out = *Mods; + } +} + +bool UVoxelDiffLayer::HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const +{ + if (!bHasAnyMods.load(std::memory_order_acquire)) return false; + + if (MinChunk.X > MaxChunk.X || MinChunk.Y > MaxChunk.Y || MinChunk.Z > MaxChunk.Z) + { + return false; + } + + // ApplyModification registers a mod in every chunk its AABB overlaps, so looking up the keys in + // the queried range is conservative and proportional to that range. Do not walk ChunkMods: + // the edit history can grow without bound while a sealed-solid candidate is being proved. + FReadScopeLock Lock(ModsLock); + for (int32 CZ = MinChunk.Z;; ++CZ) + { + for (int32 CY = MinChunk.Y;; ++CY) + { + for (int32 CX = MinChunk.X;; ++CX) + { + if (const TArray* Mods = ChunkMods.Find(FIntVector(CX, CY, CZ))) + { + if (Mods->Num() > 0) return true; + } + if (CX == MaxChunk.X) break; + } + if (CY == MaxChunk.Y) break; + } + if (CZ == MaxChunk.Z) break; + } + return false; +} + +float UVoxelDiffLayer::EvaluateMods(const TArray& Mods, + float WorldX, float WorldY, float WorldZ) +{ + float TotalOffset = 0.0f; + const FVector Pos(WorldX, WorldY, WorldZ); + + // smoothstep helper (full strength when t<=0, zero when t>=1). + auto Smooth01 = [](float T) -> float + { + T = FMath::Clamp(T, 0.0f, 1.0f); + return 1.0f - T * T * (3.0f - 2.0f * T); + }; + + for (const FVoxelModification& Mod : Mods) + { + float Falloff = 0.0f; + + switch (Mod.Shape) + { + case EVoxelBrushShape::Box: + { + // Box SDF (negative inside), then fade over the Falloff band outside. + const FVector Q = (Pos - Mod.Center).GetAbs() - Mod.BoxExtent; + const float Outside = FVector(FMath::Max(Q.X, 0.0f), FMath::Max(Q.Y, 0.0f), FMath::Max(Q.Z, 0.0f)).Size(); + const float Inside = FMath::Min(FMath::Max3(Q.X, Q.Y, Q.Z), 0.0f); + const float S = Outside + Inside; // <=0 inside the box + const float Band = FMath::Max(Mod.Falloff, 0.01f); + if (S >= Band) continue; + Falloff = Smooth01(FMath::Max(S, 0.0f) / Band); + break; + } + case EVoxelBrushShape::Capsule: + { + // Distance to the segment minus the tube radius, faded over Falloff. + const FVector AB = Mod.CapsuleEnd - Mod.Center; + const FVector AP = Pos - Mod.Center; + const float T = FMath::Clamp(FVector::DotProduct(AP, AB) / FMath::Max(FVector::DotProduct(AB, AB), KINDA_SMALL_NUMBER), 0.0f, 1.0f); + const float SegDist = FVector::Dist(Pos, Mod.Center + AB * T); + const float S = SegDist - Mod.Radius; // <=0 inside the tube + const float Band = FMath::Max(Mod.Falloff, 0.01f); + if (S >= Band) continue; + Falloff = Smooth01(FMath::Max(S, 0.0f) / Band); + break; + } + case EVoxelBrushShape::Sphere: + default: + { + const float Dist = FVector::Dist(Pos, Mod.Center); + if (Dist >= Mod.Radius) continue; + Falloff = Smooth01(Dist / Mod.Radius); + break; + } + } + + TotalOffset += Mod.Strength * Falloff; + } + + return TotalOffset; +} + +bool UVoxelDiffLayer::HasModifications(const FIntVector& ChunkCoord) const +{ + // Lock-free fast reject: no carves anywhere -> the map is empty, skip lock + lookup entirely. + if (!bHasAnyMods.load(std::memory_order_acquire)) return false; + + FReadScopeLock Lock(ModsLock); // worker threads read concurrently; serialised vs. writers + const TArray* Mods = ChunkMods.Find(ChunkCoord); + return Mods && Mods->Num() > 0; +} + +//============================================================================= +// MANAGEMENT +//============================================================================= + +void UVoxelDiffLayer::Clear() +{ + int32 Count = GetTotalModificationCount(); + { + // Write lock: workers may be mid-read. Flip the fast-path flag false BEFORE emptying so any + // reader that loads it afterwards skips the map without locking. + FWriteScopeLock Lock(ModsLock); + bHasAnyMods.store(false, std::memory_order_release); + ChunkMods.Empty(); + ModsVersion.fetch_add(1, std::memory_order_release); // invalidate worker snapshots + } + + // Reset budget counters — player gets a fresh budget after clear/season reset + ModificationCount = 0; + AccumulatedVolume = 0.0f; + + UE_LOG(LogTemp, Log, TEXT("[DiffLayer] Cleared %d modifications, budget reset"), Count); +} + +int32 UVoxelDiffLayer::GetTotalModificationCount() const +{ + FReadScopeLock Lock(ModsLock); + int32 Total = 0; + for (const auto& Pair : ChunkMods) + { + Total += Pair.Value.Num(); + } + return Total; +} + +int32 UVoxelDiffLayer::GetModifiedChunkCount() const +{ + FReadScopeLock Lock(ModsLock); + return ChunkMods.Num(); +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelForgeModule.cpp b/Standalone/Source/VoxelForge/Private/VoxelForgeModule.cpp new file mode 100644 index 0000000..9d54985 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelForgeModule.cpp @@ -0,0 +1,54 @@ +// VoxelForgeModule.cpp +// Module implementation - boilerplate + +#include "VoxelForgeModule.h" +#include "VoxelStackSampler.h" +#include "VoxelGenerator.h" +#include "VoxelMarchingCubesMesher.h" +#include "Misc/CommandLine.h" +#include "Misc/Parse.h" + +#if PLATFORM_WINDOWS +#include +#endif + +// This macro registers our module with Unreal +// "VoxelForge" must match the module name in VoxelForge.Build.cs and .uplugin +IMPLEMENT_MODULE(FVoxelForgeModule, VoxelForge) + +void FVoxelForgeModule::StartupModule() +{ + // Called when plugin loads + // Keep this diagnostic before any plugin-owned generation can begin. The UCRT may select + // its FMA3 math implementations during process startup; this switch lets the canonical export + // compare that path with the non-FMA3 implementation on the same machine. +#if PLATFORM_WINDOWS + const int32 Fma3Before = _get_FMA3_enable(); + int32 RequestedFma3 = 0; + const bool bHasFma3Override = FParse::Value( + FCommandLine::Get(), TEXT("voxel.CrtFma3="), RequestedFma3); + if (bHasFma3Override) + { + _set_FMA3_enable(RequestedFma3 != 0 ? 1 : 0); + } + const int32 Fma3After = _get_FMA3_enable(); + UE_LOG(LogTemp, Log, + TEXT("VoxelForge CRT FMA3 diagnostic: before=%d override=%s requested=%d after=%d"), + Fma3Before, + bHasFma3Override ? TEXT("yes") : TEXT("no"), + RequestedFma3, + Fma3After); +#endif + // Resolve the command line once while the module is starting, before any generation task is + // submitted, instead of lazily from worker-side density/mesher calls. + VoxelGenLOD::InitializeConsoleSwitches(); + UVoxelMarchingCubesMesher::InitializeConsoleSwitches(); + UE_LOG(LogTemp, Log, TEXT("VoxelForge module started!")); +} + +void FVoxelForgeModule::ShutdownModule() +{ + // Called when plugin unloads + FVoxelStackSampler::Get().Shutdown(); + UE_LOG(LogTemp, Log, TEXT("VoxelForge module shutdown.")); +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp b/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp new file mode 100644 index 0000000..df049ac --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -0,0 +1,7659 @@ +// VoxelGenerator.cpp +// Champ de densité du monde. Toute la géométrie des grottes sort d'ici. +// +// Convention INTERNE: positif = solide, négatif = air (plus lisible). +// Convention de SORTIE (marching cubes): on négate → négatif = solide. + +#include "VoxelGenerator.h" +#include "StandaloneResearchTunnels.h" +#include "VoxelSettings.h" +#include "VoxelStrateManager.h" +#include "VoxelStrateDefinition.h" +#include "VoxelTerrainOpDefinition.h" +#include "VoxelCaveMorphology.h" +#include "VoxelDiffLayer.h" +#include "VoxelBiomeDefinition.h" +#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core +#include "VoxelWormField.h" +#include "VoxelDensityAblation.h" // fingerprinted development-only stage measurements +#include "VoxelDensityPrimitives.h" // spine / seals / passage — shared with the operator stack +#include "VoxelDensityOpStack.h" // the per-strate operator stack +#include "VoxelRedefinedDensityField.h" // default-off editor research evaluator +#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it +#include "VoxelStats.h" +#include "VoxelDensityProfile.h" +#include "ProfilingDebugging/CpuProfilerTrace.h" +#include "HAL/PlatformTime.h" +#include "HAL/IConsoleManager.h" +#include "Misc/CommandLine.h" + +#if WITH_EDITOR +#include "VoxelStrateComposer.h" +#endif + +#include + +namespace VoxelMath +{ + VOXELFORGE_API int32 GFastIsFinite = 1; +} + +namespace +{ +#if WITH_EDITOR + thread_local bool GVoxelResearchRegionCacheWindowEnabled = false; +#endif + // The default-on branch uses VoxelMath::IsFiniteFast, while 0 retains the original + // FMath::IsFinite call for a real within-round A/B and sampler comparison. + FAutoConsoleVariableRef CVarVoxelForgeFastIsFinite( + TEXT("voxel.FastIsFinite"), + VoxelMath::GFastIsFinite, + TEXT("Use the exact inline IEEE-754 bit test instead of the CRT-backed FMath::IsFinite.")); + bool GVoxelForgeFastIsFiniteSwitchParsed = false; + + void VF_ParseFastIsFiniteSwitch() + { + if (GVoxelForgeFastIsFiniteSwitchParsed) + { + return; + } + GVoxelForgeFastIsFiniteSwitchParsed = true; + int32 CommandLineValue = VoxelMath::GFastIsFinite; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.FastIsFinite="), CommandLineValue)) + { + VoxelMath::GFastIsFinite = CommandLineValue; + } + VoxelMath::GFastIsFinite = VoxelMath::GFastIsFinite != 0 ? 1 : 0; + } + + // The canonical native TunnelNetwork/Underwater graph has a hand-lowered evaluator. Keep a + // command-line switch so every change can be A/B'd against the existing interpreted/block + // path without changing the authored graph or rebuilding a different world definition. + int32 GVoxelForgeUseFusedEvaluator = 1; + FAutoConsoleVariableRef CVarVoxelForgeUseFusedEvaluator( + TEXT("voxel.UseFusedEvaluator"), + GVoxelForgeUseFusedEvaluator, + TEXT("Use the lowered fused evaluator for canonical TunnelNetwork operator stacks.")); + bool GVoxelForgeFusedEvaluatorSwitchParsed = false; + + void VF_ParseFusedEvaluatorSwitch() + { + if (GVoxelForgeFusedEvaluatorSwitchParsed) + { + return; + } + GVoxelForgeFusedEvaluatorSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeUseFusedEvaluator; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.UseFusedEvaluator="), CommandLineValue)) + { + GVoxelForgeUseFusedEvaluator = CommandLineValue; + } + } + + // A proved N1-above-threshold block skips both worm noise calls. The switch is parsed once + // on the same scalar entry point as the other generation A/B switches; 0 is the exact legacy + // path and remains the default until a tighter skip is justified by a future measurement. + int32 GVoxelForgeWormBlockSkip = 0; + FAutoConsoleVariableRef CVarVoxelForgeWormBlockSkip( + TEXT("voxel.WormBlockSkip"), + GVoxelForgeWormBlockSkip, + TEXT("Skip worm N1/N2 when a worker-local noise-space block proof says N1 >= threshold.")); + bool GVoxelForgeWormBlockSkipSwitchParsed = false; + + void VF_ParseWormBlockSkipSwitch() + { + if (GVoxelForgeWormBlockSkipSwitchParsed) + { + return; + } + GVoxelForgeWormBlockSkipSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeWormBlockSkip; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.WormBlockSkip="), CommandLineValue)) + { + GVoxelForgeWormBlockSkip = CommandLineValue; + } + GVoxelForgeWormBlockSkip = GVoxelForgeWormBlockSkip != 0 ? 1 : 0; + } + + // A tile-sized cache window is the production default on the coarse paths where it wins. + // Keep the switches in the generator module so native and op-stack sources make the same A/B + // decision, including when a headless harness applies command-line CVars after module startup. + int32 GVoxelForgeTileCacheWindow = 1; + FAutoConsoleVariableRef CVarVoxelForgeTileCacheWindow( + TEXT("voxel.TileCacheWindow"), + GVoxelForgeTileCacheWindow, + TEXT("Use the requesting tile's aligned footprint plus the deterministic warp/halo margin for room caches.")); + bool GVoxelForgeTileCacheWindowSwitchParsed = false; + // Refreshed profiles put the fused crossover at LOD3 and the op-stack crossover at LOD4. + // Separate defaults keep a path that loses at LOD3 from paying for a larger candidate set. + int32 GVoxelForgeTileCacheWindowMinLOD = 3; + FAutoConsoleVariableRef CVarVoxelForgeTileCacheWindowMinLOD( + TEXT("voxel.TileCacheWindowMinLOD"), + GVoxelForgeTileCacheWindowMinLOD, + TEXT("Minimum SampleStep LOD for the fused tile-sized room cache window.")); + int32 GVoxelForgeTileCacheWindowOpStackMinLOD = 4; + FAutoConsoleVariableRef CVarVoxelForgeTileCacheWindowOpStackMinLOD( + TEXT("voxel.TileCacheWindowOpStackMinLOD"), + GVoxelForgeTileCacheWindowOpStackMinLOD, + TEXT("Minimum SampleStep LOD for the op-stack tile-sized room cache window.")); + + int32 GVoxelForgeSpatialIndex = 1; + FAutoConsoleVariableRef CVarVoxelForgeSpatialIndex( + TEXT("voxel.SpatialIndex"), + GVoxelForgeSpatialIndex, + TEXT("Room-cache spatial broad phase: -1 measured auto policy, 0 off, 1 on.")); + bool GVoxelForgeSpatialIndexSwitchParsed = false; + + void VF_ParseTileCacheWindowSwitch() + { + if (GVoxelForgeTileCacheWindowSwitchParsed) + { + return; + } + GVoxelForgeTileCacheWindowSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeTileCacheWindow; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.TileCacheWindow="), CommandLineValue)) + { + GVoxelForgeTileCacheWindow = CommandLineValue; + } + GVoxelForgeTileCacheWindow = GVoxelForgeTileCacheWindow != 0 ? 1 : 0; + + int32 MinLODCommandLineValue = GVoxelForgeTileCacheWindowMinLOD; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.TileCacheWindowMinLOD="), + MinLODCommandLineValue)) + { + GVoxelForgeTileCacheWindowMinLOD = MinLODCommandLineValue; + } + GVoxelForgeTileCacheWindowMinLOD = FMath::Clamp( + GVoxelForgeTileCacheWindowMinLOD, 0, 5); + + int32 OpStackMinLODCommandLineValue = GVoxelForgeTileCacheWindowOpStackMinLOD; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.TileCacheWindowOpStackMinLOD="), + OpStackMinLODCommandLineValue)) + { + GVoxelForgeTileCacheWindowOpStackMinLOD = OpStackMinLODCommandLineValue; + } + GVoxelForgeTileCacheWindowOpStackMinLOD = FMath::Clamp( + GVoxelForgeTileCacheWindowOpStackMinLOD, 0, 5); + } + + void VF_ParseSpatialIndexSwitch() + { + if (GVoxelForgeSpatialIndexSwitchParsed) + { + return; + } + GVoxelForgeSpatialIndexSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeSpatialIndex; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.SpatialIndex="), CommandLineValue)) + { + GVoxelForgeSpatialIndex = CommandLineValue; + } + GVoxelForgeSpatialIndex = FMath::Clamp(GVoxelForgeSpatialIndex, -1, 1); + } + + int32 GVoxelForgeChunkRoutingCache = 1; + FAutoConsoleVariableRef CVarVoxelForgeChunkRoutingCache( + TEXT("voxel.ChunkRoutingCache"), + GVoxelForgeChunkRoutingCache, + TEXT("Memoize chunk-Z gap/generator/operator-stack routing for density samples.")); + bool GVoxelForgeChunkRoutingCacheSwitchParsed = false; + + void VF_ParseChunkRoutingCacheSwitch() + { + if (GVoxelForgeChunkRoutingCacheSwitchParsed) + { + return; + } + GVoxelForgeChunkRoutingCacheSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeChunkRoutingCache; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.ChunkRoutingCache="), CommandLineValue)) + { + GVoxelForgeChunkRoutingCache = CommandLineValue; + } + GVoxelForgeChunkRoutingCache = GVoxelForgeChunkRoutingCache != 0 ? 1 : 0; + } + +} + +//============================================================================= +// L'ADAPTATEUR DE CHAMP DE BIOMES / THE BIOME FIELD ADAPTER +//============================================================================= +// Il vit ICI, du côté qui connaît le générateur, et PAS dans la pile d'opérateurs. C'est tout +// l'intérêt de `IVoxelBiomeField` : le résolveur réel est une Voronoï warpée avec un cache par +// chunk sur `UVoxelGenerator`, et un opérateur qui tiendrait ce pointeur ne pourrait jamais devenir +// un asset (Phase 3). En le confinant ici, l'opérateur ne connaît qu'une capacité, pas un +// propriétaire. +// +// ⚠️ DURÉES DE VIE : cet adaptateur pointe vers les `thread_local` `CP_BiomeCtx` / `CP_BiomeCache` +// de `GetDensityAt`. Il est POSSÉDÉ par la pile, elle-même `thread_local` et reconstruite dans le +// MÊME bloc de refetch que ces deux caches — les trois naissent et meurent ensemble, sur le même +// thread. Un pointeur vers un thread_local depuis un objet thread_local du même bloc est sûr ; +// l'échapper ailleurs ne le serait pas. +// +// Lives here, not in the op stack: the real resolver is generator state, and an op holding that +// pointer could never become an asset. LIFETIME: it points at GetDensityAt's thread_locals and is +// owned by the stack, which is itself thread_local and rebuilt in the same refetch block. +class FGeneratorBiomeField final : public IVoxelBiomeField +{ +public: + FGeneratorBiomeField(const UVoxelGenerator* InGen, const FBiomeContext* InCtx, + FChunkBiomeCache* InCache, int32 InChunkZ) + : Gen(InGen), Ctx(InCtx), Cache(InCache), ChunkZ(InChunkZ) {} + + FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const override + { + FVoxelBiomeWeights Out; + if (!Gen || !Ctx || !Cache) { return Out; } // dégradation sûre : biome 0 partout + + const FBiomeSample S = Gen->ResolveBiomeSampleAt(WorldX, WorldY, ChunkZ, *Ctx, *Cache); + Out.Dominant = FMath::Max(S.DominantIndex, 0); // -1 ⇒ 0, comme le chemin d'origine + Out.Neighbor = S.NeighborIndex; + Out.NeighborWeight = S.NeighborWeight; + return Out; + } + +private: + const UVoxelGenerator* Gen; + const FBiomeContext* Ctx; + FChunkBiomeCache* Cache; + int32 ChunkZ; +}; + +//============================================================================= +// SURFACE COLUMN CACHE — ClassifyTile's SurfaceWorld column proof (T1.d) +//============================================================================= +// The SurfaceWorld heightfield + sky-cap + biome blend are functions of XY ONLY, but a tile +// spans many Z grid-points per column. ClassifyTile caches the column (terrain Z + ceiling Z) +// once per integer XY and reuses it down the column. (The surface density stack keeps its own +// column memo in FSurfaceColumnSource.) +// The surface heightfield (TerrainZ + sky-cap CeilSurf) is a PURE function of (worldX, worldY, +// seed, strate) — ZERO Z dependence: the climate/Voronoi fields are pure-XY (see ResolveBiomeSampleAt: +// "result is the pure function of XY either way") and surface params are constant within a strate (Hard +// transitions). So a column computed for one chunk is valid for EVERY chunk stacked above/below it in +// the same strate. This cache is therefore keyed by (XY box, StrateKey, Seed) — NOT ChunkZ — and held as +// a small LRU of boxes so the whole vertical view-distance stack (and XY neighbours interleaved by the +// task scheduler) reuse one another's heavy column noise instead of each recomputing it ~once per +// vertical chunk in view. Validity stays a world-XY BOX (chunk footprint + margin) so the ±1 gradient / +// +X/+Y boundary samples don't thrash it (same discipline as the SDF/biome caches, §8.10). +// F20 phase 2 OVERHANG, resolved once per column (biome-blended, slope-gated) so the per-voxel +// density path is cheap: OverhangAmp = strength · slope-gate (0 ⇒ no overhang here); (DirX,DirY) = +// the UNIT UPHILL direction of the terrain gradient (the lip extends downhill, borrowing rock from +// uphill). See FOverhangShelfMod for the warped-terrain union that makes the shelf. +struct FSurfaceColumn +{ + // Toutes les sorties sont écrites par ComputeSurfaceColumn avant Computed=true ; les valeurs + // par défaut ne sont donc jamais lues et leur constructeur implicite coûte dans le TLS. + // Every output is written by ComputeSurfaceColumn before Computed=true; the defaults are never + // read, and removing them keeps FSurfaceColumn trivial so TLS construction has no leaf work. + float TerrainZ, CeilSurf; + float OverhangAmp, DirX, DirY; +}; + +struct FSurfaceColumnBox +{ + // Covers a chunk footprint + a margin on every side (≥ the LOD margin ring the mesher samples for + // grid-based normals). Symmetric around the first (allocating) sample so a whole chunk's column + // queries — including the ±Step margin ring — stay inside one box. + static constexpr int32 Halo = CHUNK_SIZE + 8; + static constexpr int32 Dim = 2 * Halo + 1; + int32 BaseX = 0, BaseY = 0; // box origin (voxel coords) + int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params + uint64 OwnerId = 0; // generator/world identity + uint64 ManagerLifetimeId = 0; // strate-manager lifetime identity + // AUDIT C2, étendu : StrateKey vaut round(StrateBottomWorldZ), donc une édition à chaud qui + // change les params de terrain SANS déplacer la strate (fréquence de bruit, hauteur de + // montagne, un biome…) laisse la clé identique et sert des colonnes périmées. C'est la forme + // la plus visible du bug : « j'ai retouché le terrain et une zone a gardé l'ancienne forme ». + // StrateKey is round(StrateBottomWorldZ), so a live edit that changes terrain params WITHOUT + // moving the strate leaves the key unchanged and serves stale columns. + uint32 LayoutVersion = 0xFFFFFFFFu; + uint32 LastUse = 0; // LRU stamp + bool bValid = false; + FSurfaceColumn Cols[Dim * Dim]; + bool Computed[Dim * Dim]; +}; + +struct FSurfaceColumnCache +{ + // A handful of boxes keeps the player's column + a few XY neighbours warm across interleaved tasks, + // so vertical reuse survives whatever order the worker pulls chunks in. ~59 KB/box. + static constexpr int32 NumBoxes = 6; + FSurfaceColumnBox Boxes[NumBoxes]; + uint32 Clock = 0; + + // Return the box covering (IX,IY) for this (owner,manager,StrateKey,Seed,LayoutVersion); allocate by + // evicting the LRU box on miss. + FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed, + uint32 InLayoutVersion, uint64 InOwnerId, + uint64 InManagerLifetimeId) + { + ++Clock; + for (FSurfaceColumnBox& B : Boxes) + { + if (B.bValid && B.OwnerId == InOwnerId && B.ManagerLifetimeId == InManagerLifetimeId + && B.StrateKey == InStrateKey && B.Seed == InSeed + && B.LayoutVersion == InLayoutVersion + && IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim + && IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim) + { + B.LastUse = Clock; + return B; + } + } + // Miss → reuse the least-recently-used box, recentred on this sample. + FSurfaceColumnBox* Victim = &Boxes[0]; + for (FSurfaceColumnBox& B : Boxes) { if (B.LastUse < Victim->LastUse) Victim = &B; } + Victim->BaseX = IX - FSurfaceColumnBox::Halo; + Victim->BaseY = IY - FSurfaceColumnBox::Halo; + Victim->StrateKey = InStrateKey; + Victim->Seed = InSeed; + Victim->OwnerId = InOwnerId; + Victim->ManagerLifetimeId = InManagerLifetimeId; + Victim->LayoutVersion = InLayoutVersion; + Victim->bValid = true; + Victim->LastUse = Clock; + FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed)); + return *Victim; + } +}; + +// Cache de colonnes par thread de ClassifyTile (T1.d) : les pré-passes et passes complètes +// successives d'une même tuile, et les tuiles voisines, réutilisent leurs colonnes. +// Per-thread column cache for ClassifyTile: successive passes and neighbouring tiles reuse columns. +static thread_local FSurfaceColumnCache GSurfColCache; + +//============================================================================= +// WORM N1 BLOCK PROOF (worker-local, scalar GetDensityAt hand-off) +//============================================================================= +// GenerateMesh installs TileOriginVoxels/SampleStep/TileCellsPerAxis around every density-grid +// fill. Keep the verdicts here, rather than in the mesher, because the scalar GetDensityAt path +// owns the actual worm call and is also used by standalone/classifier queries. The cache is +// thread_local, direct-indexed by the block coordinate, and invalidated by every field identity +// that can affect N1. A miss or an invalid context always returns false and therefore executes +// the old N1/N2 code. +namespace +{ + constexpr int32 VF_WormBlockCacheAxis = + (CHUNK_SIZE + 3 + VoxelNoise::WormBlockSampleSide - 1) + / VoxelNoise::WormBlockSampleSide; + constexpr int32 VF_WormBlockCacheEntryCount = + VF_WormBlockCacheAxis * VF_WormBlockCacheAxis * VF_WormBlockCacheAxis; + + static_assert(VF_WormBlockCacheAxis == 9, + "The fixed worm block table must cover the 32-cell mesher lattice plus its halo."); + + FORCEINLINE uint32 VF_FloatBits(float Value) + { + uint32 Bits = 0; + FMemory::Memcpy(&Bits, &Value, sizeof(Bits)); + return Bits; + } + + struct FWormBlockSkipEntry + { + bool bValid = false; + bool bSkip = false; + }; + + struct FWormBlockSkipCache + { + bool bContextValid = false; + uint64 OwnerId = 0; + uint32 Seed = 0; + uint32 ParamsFingerprint = 0xFFFFFFFFu; + uint32 LayoutVersion = 0xFFFFFFFFu; + FIntVector TileOrigin = FIntVector::ZeroValue; + int32 Step = 0; + int32 Cells = 0; + uint32 WormFrequencyBits = 0; + uint32 WormHorizontalBiasBits = 0; + uint32 WormThresholdBits = 0; + uint32 VerticalScaleBits = 0; + FWormBlockSkipEntry Entries[VF_WormBlockCacheEntryCount]; + + void SetContext( + uint64 InOwnerId, uint32 InSeed, uint32 InParamsFingerprint, + uint32 InLayoutVersion, const FIntVector& InTileOrigin, + int32 InStep, int32 InCells, const FStrateGenerationParams& Params) + { + const uint32 InWormFrequencyBits = VF_FloatBits(Params.WormFrequency); + const uint32 InWormHorizontalBiasBits = VF_FloatBits(Params.WormHorizontalBias); + const uint32 InWormThresholdBits = VF_FloatBits(Params.WormThreshold); + const uint32 InVerticalScaleBits = VF_FloatBits(Params.VerticalScale); + const bool bChanged = + !bContextValid + || OwnerId != InOwnerId + || Seed != InSeed + || ParamsFingerprint != InParamsFingerprint + || LayoutVersion != InLayoutVersion + || TileOrigin != InTileOrigin + || Step != InStep + || Cells != InCells + || WormFrequencyBits != InWormFrequencyBits + || WormHorizontalBiasBits != InWormHorizontalBiasBits + || WormThresholdBits != InWormThresholdBits + || VerticalScaleBits != InVerticalScaleBits; + if (!bChanged) + { + return; + } + + bContextValid = true; + OwnerId = InOwnerId; + Seed = InSeed; + ParamsFingerprint = InParamsFingerprint; + LayoutVersion = InLayoutVersion; + TileOrigin = InTileOrigin; + Step = InStep; + Cells = InCells; + WormFrequencyBits = InWormFrequencyBits; + WormHorizontalBiasBits = InWormHorizontalBiasBits; + WormThresholdBits = InWormThresholdBits; + VerticalScaleBits = InVerticalScaleBits; + FMemory::Memzero(Entries, sizeof(Entries)); + } + }; + + static thread_local FWormBlockSkipCache GWormBlockSkipCache; + + FORCEINLINE FVector3f VF_WormN1NoisePosition( + float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params, uint32 SeedU) + { + float EffectiveZ = WorldZ; + if (Params.VerticalScale != 1.0f && Params.VerticalScale > 0.0f) + { + EffectiveZ = WorldZ / Params.VerticalScale; + } + const float WormZFreq = Params.WormFrequency * Params.WormHorizontalBias; + return FVector3f( + WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), + WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), + EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f)); + } + + FORCEINLINE bool VF_TryGetWormBlockSkip( + uint64 OwnerId, uint32 SeedU, uint32 ParamsFingerprint, uint32 LayoutVersion, + float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params, bool& bOutProofComputed) + { + bOutProofComputed = false; + + // Point queries and malformed authored parameters stay on the exact original path. In + // particular, VerticalScale <= 0 has legacy "no division" semantics; declining the proof + // here avoids making a new promise for malformed data. + if (!FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY) || !FMath::IsFinite(WorldZ) + || WorldX != FMath::FloorToFloat(WorldX) + || WorldY != FMath::FloorToFloat(WorldY) + || WorldZ != FMath::FloorToFloat(WorldZ) + || !FMath::IsFinite(Params.WormFrequency) + || !FMath::IsFinite(Params.WormHorizontalBias) + || !FMath::IsFinite(Params.WormThreshold) + || !FMath::IsFinite(Params.VerticalScale) + || !(Params.WormThreshold > 0.0f) + || !(Params.VerticalScale > 0.0f) + || !FMath::IsFinite(Params.WormFrequency * Params.WormHorizontalBias)) + { + return false; + } + + const int32 Step = VoxelGenLOD::SampleStep; + const int32 Cells = VoxelGenLOD::TileCellsPerAxis; + if (Step <= 0 || Cells <= 0 || Cells > CHUNK_SIZE) + { + return false; + } + + // FMath::RoundToInt is only reached after the finite/integer check and a range guard. The + // tile context itself is int32, so coordinates outside this range cannot belong to it. + constexpr float SafeInt32Min = -2147483008.0f; + constexpr float SafeInt32Max = 2147483008.0f; + if (WorldX < SafeInt32Min || WorldX > SafeInt32Max + || WorldY < SafeInt32Min || WorldY > SafeInt32Max + || WorldZ < SafeInt32Min || WorldZ > SafeInt32Max) + { + return false; + } + const int32 IX = FMath::RoundToInt(WorldX); + const int32 IY = FMath::RoundToInt(WorldY); + const int32 IZ = FMath::RoundToInt(WorldZ); + const FIntVector TileOrigin = VoxelGenLOD::TileOriginVoxels; + const int64 DX = static_cast(IX) - static_cast(TileOrigin.X); + const int64 DY = static_cast(IY) - static_cast(TileOrigin.Y); + const int64 DZ = static_cast(IZ) - static_cast(TileOrigin.Z); + if (DX % Step != 0 || DY % Step != 0 || DZ % Step != 0) + { + return false; + } + + const int32 LocalX = static_cast(DX / Step); + const int32 LocalY = static_cast(DY / Step); + const int32 LocalZ = static_cast(DZ / Step); + const int32 MinLocalLattice = -1; + const int32 MaxLocalLattice = Cells + 1; + if (LocalX < MinLocalLattice || LocalX > MaxLocalLattice + || LocalY < MinLocalLattice || LocalY > MaxLocalLattice + || LocalZ < MinLocalLattice || LocalZ > MaxLocalLattice) + { + return false; + } + + const int32 BlockX = (LocalX + 1) / VoxelNoise::WormBlockSampleSide; + const int32 BlockY = (LocalY + 1) / VoxelNoise::WormBlockSampleSide; + const int32 BlockZ = (LocalZ + 1) / VoxelNoise::WormBlockSampleSide; + if (BlockX < 0 || BlockX >= VF_WormBlockCacheAxis + || BlockY < 0 || BlockY >= VF_WormBlockCacheAxis + || BlockZ < 0 || BlockZ >= VF_WormBlockCacheAxis) + { + return false; + } + + GWormBlockSkipCache.SetContext( + OwnerId, SeedU, ParamsFingerprint, LayoutVersion, TileOrigin, Step, Cells, Params); + FWormBlockSkipEntry& Entry = GWormBlockSkipCache.Entries[ + (BlockZ * VF_WormBlockCacheAxis + BlockY) * VF_WormBlockCacheAxis + BlockX]; + if (Entry.bValid) + { + return Entry.bSkip; + } + + const int32 BlockMinX = BlockX * VoxelNoise::WormBlockSampleSide - 1; + const int32 BlockMinY = BlockY * VoxelNoise::WormBlockSampleSide - 1; + const int32 BlockMinZ = BlockZ * VoxelNoise::WormBlockSampleSide - 1; + const int32 BlockMaxX = FMath::Min(MaxLocalLattice, + BlockMinX + VoxelNoise::WormBlockSampleSide - 1); + const int32 BlockMaxY = FMath::Min(MaxLocalLattice, + BlockMinY + VoxelNoise::WormBlockSampleSide - 1); + const int32 BlockMaxZ = FMath::Min(MaxLocalLattice, + BlockMinZ + VoxelNoise::WormBlockSampleSide - 1); + + const auto WorldAt = [Step, &TileOrigin](int32 Local, int32 Origin) -> int64 + { + return static_cast(Origin) + static_cast(Local) * Step; + }; + const int64 MinWorldX64 = WorldAt(BlockMinX, TileOrigin.X); + const int64 MinWorldY64 = WorldAt(BlockMinY, TileOrigin.Y); + const int64 MinWorldZ64 = WorldAt(BlockMinZ, TileOrigin.Z); + const int64 MaxWorldX64 = WorldAt(BlockMaxX, TileOrigin.X); + const int64 MaxWorldY64 = WorldAt(BlockMaxY, TileOrigin.Y); + const int64 MaxWorldZ64 = WorldAt(BlockMaxZ, TileOrigin.Z); + if (MinWorldX64 < MIN_int32 || MinWorldX64 > MAX_int32 + || MinWorldY64 < MIN_int32 || MinWorldY64 > MAX_int32 + || MinWorldZ64 < MIN_int32 || MinWorldZ64 > MAX_int32 + || MaxWorldX64 < MIN_int32 || MaxWorldX64 > MAX_int32 + || MaxWorldY64 < MIN_int32 || MaxWorldY64 > MAX_int32 + || MaxWorldZ64 < MIN_int32 || MaxWorldZ64 > MAX_int32) + { + return false; + } + + const FVector3f Q0 = VF_WormN1NoisePosition( + static_cast(static_cast(MinWorldX64)), + static_cast(static_cast(MinWorldY64)), + static_cast(static_cast(MinWorldZ64)), Params, SeedU); + const FVector3f Q1 = VF_WormN1NoisePosition( + static_cast(static_cast(MaxWorldX64)), + static_cast(static_cast(MaxWorldY64)), + static_cast(static_cast(MaxWorldZ64)), Params, SeedU); + const FVector3f QMin( + FMath::Min(Q0.X, Q1.X), FMath::Min(Q0.Y, Q1.Y), FMath::Min(Q0.Z, Q1.Z)); + const FVector3f QMax( + FMath::Max(Q0.X, Q1.X), FMath::Max(Q0.Y, Q1.Y), FMath::Max(Q0.Z, Q1.Z)); + const FVector3f QCenter( + static_cast((static_cast(QMin.X) + static_cast(QMax.X)) * 0.5), + static_cast((static_cast(QMin.Y) + static_cast(QMax.Y)) * 0.5), + static_cast((static_cast(QMin.Z) + static_cast(QMax.Z)) * 0.5)); + + Entry.bSkip = VoxelNoise::ProvesScaledAbsPerlin3DAbove( + QCenter, QMin, QMax, Params.WormThreshold, + VoxelNoise::WormN1NoiseSpaceLipschitzBound); + Entry.bValid = true; + bOutProofComputed = true; + return Entry.bSkip; + } +} + +//============================================================================= +// FRACTAL NOISE (fBm — fractional Brownian motion) +//============================================================================= +// Empile plusieurs octaves de Perlin: freq x2 et amp /2 à chaque octave. +// Octave 1: freq=f, amp=1.0 → grandes collines lisses +// Octave 2: freq=2f, amp=0.5 → bosses moyennes +// Octave 3: freq=4f, amp=0.25 → petits cailloux +// Lacunarity = x freq par octave (2 = double à chaque fois) +// Persistence = x amp par octave (0.5 = moitié) + +// T2.b — per-thread octave bias for the tile being meshed (see VoxelGenerator.h). +// 0 = full quality; set by the mesher per tile from Step + Settings->LODOctaveDrop. +thread_local int32 VoxelGenLOD::OctaveBias = 0; + +//============================================================================= +// OP-MAJOR DENSITY BLOCK HAND-OFF +//============================================================================= +// The public Begin/End pair is only a lifetime marker. The actual storage is worker-local and +// bounded by the current mesher lattice. A tile may cross several chunk keys; each key is filled +// by the already-prepared stack that GetDensityAt selected for that key, so no mutable stack state +// is shared across chunk boundaries. +struct FVoxelDensityBlockSession +{ + bool bActive = false; + bool bBatchRequested = false; + bool bBatchSucceeded = false; + FIntVector Origin = FIntVector::ZeroValue; + int32 Step = 1; + int32 SizeX = 0; + int32 SizeY = 0; + int32 SizeZ = 0; + TArray Samples; + TArray Valid; + TArray BlockScratch; + TArray ActiveOps; + TArray BatchResults; + + void Begin(FIntVector InOrigin, int32 InStep, int32 InSizeX, int32 InSizeY, int32 InSizeZ) + { + bActive = InStep > 0 && InSizeX > 0 && InSizeY > 0 && InSizeZ > 0; + Origin = InOrigin; + Step = FMath::Max(InStep, 1); + SizeX = FMath::Max(InSizeX, 0); + SizeY = FMath::Max(InSizeY, 0); + SizeZ = FMath::Max(InSizeZ, 0); + if (!bActive) + { + Samples.Reset(); + Valid.Reset(); + BlockScratch.Reset(); + ActiveOps.Reset(); + BatchResults.Reset(); + bBatchRequested = false; + bBatchSucceeded = false; + return; + } + + // Allocate lazily on the first operator-stack sample. Chunks that never reach a stack + // (gaps, out-of-layout, degenerate strates) use the ordinary scalar path and should not + // pay for a block scratch lattice merely because the mesher opened the scoped hand-off. + BlockScratch.Reset(); + BatchResults.Reset(); + bBatchRequested = false; + bBatchSucceeded = false; + } + + void End() + { + bActive = false; + // Keep capacity for the next tile on this worker, but never retain a verdict between + // sessions. A stale sample can only cost the next task a block fill, never change it. + Samples.Reset(); + Valid.Reset(); + BlockScratch.Reset(); + ActiveOps.Reset(); + BatchResults.Reset(); + bBatchRequested = false; + bBatchSucceeded = false; + } + + int32 Index(int32 X, int32 Y, int32 Z) const + { + return (Z * SizeY + Y) * SizeX + X; + } + + bool LocalIndex(FIntVector World, int32& OutX, int32& OutY, int32& OutZ) const + { + const int32 DX = World.X - Origin.X; + const int32 DY = World.Y - Origin.Y; + const int32 DZ = World.Z - Origin.Z; + if (DX % Step != 0 || DY % Step != 0 || DZ % Step != 0) { return false; } + OutX = DX / Step; + OutY = DY / Step; + OutZ = DZ / Step; + return OutX >= 0 && OutX < SizeX + && OutY >= 0 && OutY < SizeY + && OutZ >= 0 && OutZ < SizeZ; + } + + bool FindAxisRange(int32 Axis, int32 TargetChunk, int32& OutMin, int32& OutMax) const + { + OutMin = INT32_MAX; + OutMax = INT32_MIN; + const int32 Limit = Axis == 0 ? SizeX : (Axis == 1 ? SizeY : SizeZ); + const int32 Base = Axis == 0 ? Origin.X : (Axis == 1 ? Origin.Y : Origin.Z); + for (int32 I = 0; I < Limit; ++I) + { + const int32 Coordinate = Base + I * Step; + if (FMath::FloorToInt(static_cast(Coordinate) / CHUNK_SIZE) == TargetChunk) + { + OutMin = FMath::Min(OutMin, I); + OutMax = FMath::Max(OutMax, I); + } + } + return OutMin != INT32_MAX; + } + + bool FillChunk(const FVoxelOpStack& Stack, FIntVector ChunkCoord, FIntVector World, + FVoxelOpSample& OutSample) + { + int32 X = 0, Y = 0, Z = 0; + if (!LocalIndex(World, X, Y, Z)) { return false; } + const int32 Count = SizeX * SizeY * SizeZ; + if (Samples.Num() != Count || Valid.Num() != Count) + { + Samples.SetNum(Count); + Valid.Init(0, Count); + } + const int32 GlobalIndex = Index(X, Y, Z); + if (Valid[GlobalIndex] != 0) + { + OutSample = Samples[GlobalIndex]; + return true; + } + + int32 MinX = 0, MaxX = 0, MinY = 0, MaxY = 0, MinZ = 0, MaxZ = 0; + if (!FindAxisRange(0, ChunkCoord.X, MinX, MaxX) + || !FindAxisRange(1, ChunkCoord.Y, MinY, MaxY) + || !FindAxisRange(2, ChunkCoord.Z, MinZ, MaxZ)) + { + return false; + } + + const int32 BlockSizeX = MaxX - MinX + 1; + const int32 BlockSizeY = MaxY - MinY + 1; + const int32 BlockSizeZ = MaxZ - MinZ + 1; + const int32 BlockCount = BlockSizeX * BlockSizeY * BlockSizeZ; + VoxelDensityProfile::FScopedTimer OperatorBlockTimer( + VoxelDensityProfile::EBucket::OperatorBlock); + BlockScratch.SetNum(BlockCount); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockScratchBytes, + static_cast(BlockCount) * sizeof(FVoxelOpSample)); + } + for (FVoxelOpSample& Sample : BlockScratch) + { + Sample = FVoxelOpSample(); + } + + FVoxelOpBlock Block; + Block.OriginVoxels = Origin + FIntVector(MinX * Step, MinY * Step, MinZ * Step); + Block.Step = Step; + Block.SizeX = BlockSizeX; + Block.SizeY = BlockSizeY; + Block.SizeZ = BlockSizeZ; + Block.Samples = BlockScratch.GetData(); + const FIntVector BlockLast = Block.OriginVoxels + + FIntVector((BlockSizeX - 1) * Step, + (BlockSizeY - 1) * Step, + (BlockSizeZ - 1) * Step); + const FBox BlockBox( + FVector(static_cast(Block.OriginVoxels.X), + static_cast(Block.OriginVoxels.Y), + static_cast(Block.OriginVoxels.Z)), + FVector(static_cast(BlockLast.X), + static_cast(BlockLast.Y), + static_cast(BlockLast.Z))); + const int32 ActiveCount = Stack.BuildActiveOpList( + BlockBox, Step, Block.OriginVoxels, ActiveOps); + Stack.EvalBlock(Block, ActiveOps); + + for (int32 Bz = 0; Bz < BlockSizeZ; ++Bz) + { + for (int32 By = 0; By < BlockSizeY; ++By) + { + for (int32 Bx = 0; Bx < BlockSizeX; ++Bx) + { + const int32 Global = Index(MinX + Bx, MinY + By, MinZ + Bz); + const int32 Local = (Bz * BlockSizeY + By) * BlockSizeX + Bx; + Samples[Global] = BlockScratch[Local]; + Valid[Global] = 1; + } + } + } + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockCopyBytes, + static_cast(BlockCount) + * (sizeof(FVoxelOpSample) + sizeof(uint8))); + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter(VoxelDensityProfile::ECounter::OpBlockBuilds); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockSamples, + static_cast(BlockCount)); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockOperators, + static_cast(Stack.Num())); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockActiveOperators, + static_cast(ActiveCount)); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockPrunedOperators, + static_cast(FMath::Max(Stack.Num() - ActiveCount, 0))); + } + + OutSample = Samples[GlobalIndex]; + return true; + } +}; + +static thread_local FVoxelDensityBlockSession GVoxelDensityBlockSession; +thread_local int32 VoxelGenLOD::SampleStep = 1; +thread_local FIntVector VoxelGenLOD::TileOriginVoxels = FIntVector::ZeroValue; +thread_local int32 VoxelGenLOD::TileCellsPerAxis = 0; + +int32 VoxelGenLOD::GetThreadOctaveBias() +{ + return OctaveBias; +} + +void VoxelGenLOD::SetThreadOctaveBias(int32 Value) +{ + OctaveBias = Value; +} + +int32 VoxelGenLOD::GetThreadSampleStep() +{ + return SampleStep; +} + +void VoxelGenLOD::SetThreadSampleStep(int32 Value) +{ + SampleStep = Value; +} + +void VoxelGenLOD::InitializeConsoleSwitches() +{ + VF_ParseFastIsFiniteSwitch(); + VF_ParseFusedEvaluatorSwitch(); + VF_ParseWormBlockSkipSwitch(); + VF_ParseTileCacheWindowSwitch(); + VF_ParseSpatialIndexSwitch(); + VF_ParseChunkRoutingCacheSwitch(); +} + +bool VoxelGenLOD::IsTileCacheWindowEnabled(bool bFusedPath) +{ + if (GVoxelForgeTileCacheWindow == 0) + { + return false; + } +#if WITH_EDITOR + if (GVoxelResearchRegionCacheWindowEnabled) + { + return true; + } +#endif + const int32 RequiredMinLOD = bFusedPath + ? GVoxelForgeTileCacheWindowMinLOD + : GVoxelForgeTileCacheWindowOpStackMinLOD; + const int32 RequiredStep = 1 << FMath::Clamp(RequiredMinLOD, 0, 5); + return FMath::Max(SampleStep, 1) >= RequiredStep; +} + +#if WITH_EDITOR +void VoxelGenLOD::SetResearchRegionCacheWindowEnabled(bool bEnabled) +{ + GVoxelResearchRegionCacheWindowEnabled = bEnabled; +} +#endif + +bool VoxelGenLOD::GetThreadTileCacheWindow( + FIntVector& OutOriginVoxels, int32& OutStep, int32& OutCellsPerAxis) +{ + OutOriginVoxels = TileOriginVoxels; + OutStep = FMath::Max(SampleStep, 1); + OutCellsPerAxis = TileCellsPerAxis; + if (TileCellsPerAxis <= 0) + { + return false; + } + const int64 Extent = static_cast(OutStep) + * static_cast(OutCellsPerAxis); + // The graph build is quadratic in the collected room count. Keep the tile policy bounded + // to a 64-chunk side; the field's LOD4 tile is 32 chunks, while an accidentally giant request + // falls back to the older bounded window instead of turning one worker into an unbounded bake. + return Extent > 0 && Extent <= MAX_int32 + && Extent <= static_cast(CHUNK_SIZE) * 64; +} + +void VoxelGenLOD::SetThreadTileContext( + const FIntVector& OriginVoxels, + int32 CellsPerAxis) +{ + TileOriginVoxels = OriginVoxels; + TileCellsPerAxis = CellsPerAxis; +} + +bool VoxelGenLOD::ShouldUseSpatialIndex(bool bFusedPath) +{ + if (GVoxelForgeSpatialIndex >= 0) + { + return GVoxelForgeSpatialIndex != 0; + } + + // The full game sweep makes the fused LOD0 floor faster without the broad phase. Keep it + // enabled for LOD1+ where the candidate reduction pays for GetRange, and keep the measured + // op-stack policy enabled at every tested LOD. The explicit override remains useful for + // field/determinism A/B runs. + if (bFusedPath && FMath::Max(SampleStep, 1) <= 1) + { + return false; + } + return true; +} + +// NOTE (T2.a): the fBm/Ridged bodies moved to VoxelNoise.h, where octaves are evaluated +// 4-wide via SSE (Perlin3D_x4). These thin wrappers keep every call site unchanged. They +// sample a DIFFERENT (float hash-gradient) noise field than the old FMath::PerlinNoise3D, +// so worlds re-tune once — but the fBm/Ridged math/contracts ([-1,1]) are identical. +static float FractalNoise3D(const FVector3f& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) +{ + return VoxelNoise::FBM(Position.X, Position.Y, Position.Z, + Octaves, Lacunarity, Persistence); +} + +//============================================================================= +// RIDGED MULTIFRACTAL NOISE +//============================================================================= +// Creates sharp, ridge-like features by folding the noise at zero-crossings. +// The absolute value creates "creases" where the noise crosses zero, +// and the 1-abs inverts them into ridges. Weight feedback from each +// octave makes ridges sharper and more detailed. +// +// Returns approximately [-1, 1] to match FractalNoise3D's range. +// Character: craggy cliffs, natural erosion patterns, sharp corridors. + +static float RidgedNoise3D(const FVector3f& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) +{ + return VoxelNoise::Ridged(Position.X, Position.Y, Position.Z, + Octaves, Lacunarity, Persistence); +} + +//============================================================================= +// WORLEY / CELLULAR NOISE (3D) +//============================================================================= +// Distance to nearest feature point in a hash grid. +// Creates rounded, cell-like patterns: bubble walls, grotto pockets, +// honeycomb textures. Very different character from Perlin-based noise. +// +// Algorithm: +// 1. Find which grid cell the point is in +// 2. Check the 3x3x3 neighborhood for feature points +// 3. Return (2nd nearest - 1st nearest) distance, normalized to ~[-1, 1] +// +// Using F2-F1 (difference of two closest distances) gives smooth cell +// boundaries with ridges between cells — more interesting than raw distance. + +// Le CORPS vit dans Public/VoxelCaveMorphology.h (namespace VoxelNoise), au plus bas point +// qui voit déjà `VoxelHash` : la pile d'opérateurs a besoin exactement du même bruit pour la +// rugosité (4b, type Cellular) et pour les festons (4f), et deux copies d'une fonction pure finissent +// par diverger — c'est littéralement `AUDIT §C1`. Ce forwarder garde les ~3 sites d'appel ci-dessous +// inchangés, comme FractalNoise3D et RidgedNoise3D. Le chemin chaud garde les coordonnées en float. +static float CellularNoise3D(const FVector3f& Position) +{ + return VoxelNoise::Cellular3D(Position); +} + +//============================================================================= +// DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab) +//============================================================================= + +// Les CORPS de ces trois helpers ont déménagé dans Public/VoxelDensityPrimitives.h : la pile +// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies des INVARIANTS +// de monde (descente possible, seals qui tiennent, passages qui percent, bord fermé) finiraient par +// diverger. Ces trois lignes gardent les noms locaux pour que les ~20 sites d'appel ci-dessous ne +// bougent pas d'un caractère — le déplacement ne change AUCUN comportement. +// +// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same primitives, +// and duplicate world invariants would eventually drift. These forwarders keep the local names +// so not one of the ~20 call sites below changes. No behavioural change. +// +// Convention INTERNE ici : positif = SOLIDE. La négation vers MC se fait sur le `return`. +static FORCEINLINE void ApplyBoundarySeal(float& Density, float WorldZ, + float StrateTopZ, float StrateBottomZ, float Thickness, float BaseDensity) +{ + VF_ApplyBoundarySeal(Density, WorldZ, StrateTopZ, StrateBottomZ, Thickness, BaseDensity); +} + +static FORCEINLINE void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + VF_ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); +} + +static FORCEINLINE void ApplyOriginLandingFloor(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + VF_ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); +} + +// DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density +// (negative = solid, positive = air), AFTER the archetype produced its terrain, so +// it works uniformly for every generator type. Stays inside the seal bands so it can +// never breach a strate boundary. All features are hash-placed and deterministic. +static void ApplyDisturbances(float& MC, float X, float Y, float Z, + const FStrateDisturbanceParams& D, uint32 Seed, bool bProtectVerticalShaftAir) +{ + if (VoxelDensityAblation::IsDisturbancesOff()) + { + return; + } + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::ApplyDisturbances); + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + if (Z <= InnerBot || Z >= InnerTop) return; + + const float Solid = D.BaseDensity * 2.0f; + const float Blend = 3.0f; + const FVector P(X, Y, Z); + + // 2D point-to-segment distance helper (XY plane). + auto Dist2DSeg = [](float px, float py, float ax, float ay, float bx, float by) -> float + { + const float abx = bx - ax, aby = by - ay; + const float apx = px - ax, apy = py - ay; + const float denom = FMath::Max(abx * abx + aby * aby, KINDA_SMALL_NUMBER); + float t = FMath::Clamp((apx * abx + apy * aby) / denom, 0.0f, 1.0f); + const float cx = ax + abx * t, cy = ay + aby * t; + return FMath::Sqrt(FMath::Square(px - cx) + FMath::Square(py - cy)); + }; + + // Each feature's 3×3 lattice candidates (existence roll, jitter, angle trig, Z anchor) are pure + // functions of (cell, seed, params) yet were re-hashed PER VOXEL — including Cos/Sin per bridge/ + // ridge candidate. Bake each feature's nearby primitives once per centre cell (thread_local); + // the per-voxel work is just the distance math. Bit-identical (same hashes, same math). + + // --- CHASMS: vertical rifts carve open air --- + if (D.ChasmDensity > 0.0f && D.ChasmSpacing > 0.0f) + { + const float Sp = D.ChasmSpacing; + const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); + + struct FChasm { float X, Y; }; + thread_local TArray> Chasms; + thread_local int32 CH_CX = INT32_MAX, CH_CY = INT32_MAX; + thread_local uint32 CH_Seed = 0xFFFFFFFFu; + thread_local float CH_Sp = -1.0f, CH_Dens = -1.0f; + if (cx != CH_CX || cy != CH_CY || Seed != CH_Seed || Sp != CH_Sp || D.ChasmDensity != CH_Dens) + { + CH_CX = cx; CH_CY = cy; CH_Seed = Seed; CH_Sp = Sp; CH_Dens = D.ChasmDensity; + Chasms.Reset(); + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x43480001u); + if (VoxelHash::ToFloat01(h) > D.ChasmDensity) continue; + const float jx = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x12345678u)); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9ABCDEF0u)); + Chasms.Add({ (nx + 0.15f + jx * 0.7f) * Sp, (ny + 0.15f + jy * 0.7f) * Sp }); + } + } + + float sdf = FLT_MAX; + for (const FChasm& C : Chasms) + { + sdf = FMath::Min(sdf, FMath::Sqrt(FMath::Square(X - C.X) + FMath::Square(Y - C.Y)) - D.ChasmRadius); + } + if (sdf < Blend) + { + float c = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f); + c = SmoothStep01(c); + MC = FMath::Max(MC, c * Solid); // force air + } + } + + // --- BRIDGES: horizontal solid spans across open space --- + if (D.BridgeDensity > 0.0f && D.BridgeSpacing > 0.0f) + { + const float Sp = D.BridgeSpacing; + const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); + + struct FBridge { FVector A, B; }; + thread_local TArray> Bridges; + thread_local int32 BR_CX = INT32_MAX, BR_CY = INT32_MAX; + thread_local uint32 BR_Seed = 0xFFFFFFFFu; + thread_local float BR_Sp = -1.0f, BR_Dens = -1.0f, BR_Bot = FLT_MAX, BR_Top = FLT_MAX; + if (cx != BR_CX || cy != BR_CY || Seed != BR_Seed || Sp != BR_Sp || + D.BridgeDensity != BR_Dens || InnerBot != BR_Bot || InnerTop != BR_Top) + { + BR_CX = cx; BR_CY = cy; BR_Seed = Seed; BR_Sp = Sp; + BR_Dens = D.BridgeDensity; BR_Bot = InnerBot; BR_Top = InnerTop; + Bridges.Reset(); + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x42520001u); + if (VoxelHash::ToFloat01(h) > D.BridgeDensity) continue; + const float zc = FMath::Lerp(InnerBot + 8.0f, InnerTop - 8.0f, + VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB1u))); + const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB2u)) * PI; + float sinAng = 0.0f, cosAng = 0.0f; + VoxelMath::DetSinCos(sinAng, cosAng, ang); + const float dxu = cosAng, dyu = sinAng; + const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; + const float half = Sp * 0.6f; + Bridges.Add({ FVector(bx - dxu * half, by - dyu * half, zc), + FVector(bx + dxu * half, by + dyu * half, zc) }); + } + } + + float sdf = FLT_MAX; + for (const FBridge& Br : Bridges) + { + sdf = FMath::Min(sdf, VoxelSDF::Capsule(P, Br.A, Br.B, D.BridgeRadius)); + } + if (sdf < Blend) + { + float f = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f); + f = SmoothStep01(f); + if (!bProtectVerticalShaftAir) + { + MC = FMath::Min(MC, -f * Solid); // force solid + } + } + } + + // --- RIDGES: thin solid blades rising from the floor --- + if (D.RidgeDensity > 0.0f && D.RidgeSpacing > 0.0f && D.RidgeHeight > 0.0f) + { + const float Sp = D.RidgeSpacing; + const float TopZ = FMath::Min(InnerBot + D.RidgeHeight, InnerTop); + if (Z < TopZ) + { + const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); + + struct FRidge { float AX, AY, BX, BY; }; + thread_local TArray> Ridges; + thread_local int32 RG_CX = INT32_MAX, RG_CY = INT32_MAX; + thread_local uint32 RG_Seed = 0xFFFFFFFFu; + thread_local float RG_Sp = -1.0f, RG_Dens = -1.0f; + if (cx != RG_CX || cy != RG_CY || Seed != RG_Seed || Sp != RG_Sp || D.RidgeDensity != RG_Dens) + { + RG_CX = cx; RG_CY = cy; RG_Seed = Seed; RG_Sp = Sp; RG_Dens = D.RidgeDensity; + Ridges.Reset(); + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x52470001u); + if (VoxelHash::ToFloat01(h) > D.RidgeDensity) continue; + const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9001u)) * PI; + float sinAng = 0.0f, cosAng = 0.0f; + VoxelMath::DetSinCos(sinAng, cosAng, ang); + const float dxu = cosAng, dyu = sinAng; + const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; + const float half = Sp * 0.45f; + Ridges.Add({ bx - dxu * half, by - dyu * half, bx + dxu * half, by + dyu * half }); + } + } + + float best = -FLT_MAX; // strongest fill across nearby blades + for (const FRidge& R : Ridges) + { + const float d2d = Dist2DSeg(X, Y, R.AX, R.AY, R.BX, R.BY); + const float wallSDF = d2d - D.RidgeThickness; // <0 inside the blade footprint + if (wallSDF >= Blend) continue; + const float zFade = 1.0f - FMath::Clamp((Z - InnerBot) / FMath::Max(D.RidgeHeight, 1.0f), 0.0f, 1.0f); + float f = FMath::Clamp((Blend - wallSDF) / (Blend * 2.0f), 0.0f, 1.0f); + f = SmoothStep01(f) * zFade; + best = FMath::Max(best, f); + } + if (best > 0.0f && !bProtectVerticalShaftAir) + { + MC = FMath::Min(MC, -best * Solid); // force solid + } + } + } +} + +// The disturbance pass is deterministic and spatially sparse. Treating a non-zero density as a +// world-wide influence would make an otherwise proven all-solid/all-air LOD0 box fall back to the +// full mesher. These predicates are deliberately one-sided: they only +// prove that no hash-placed primitive can touch the queried box. An intersecting candidate, an +// invalid range, or an unknown parameter keeps the conservative Mixed result. +static FORCEINLINE bool VF_DisturbanceBandTouchesBox( + const FBox& Box, const FStrateDisturbanceParams& D) +{ + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + if (!VoxelMath::IsFinite(InnerTop) || !VoxelMath::IsFinite(InnerBot)) + { + return true; // Unknown boundary placement must not discharge a proof. + } + return Box.Max.Z > InnerBot && Box.Min.Z < InnerTop; +} + +static FORCEINLINE float VF_DistanceSquaredToXYBox( + float X, float Y, const FBox& Box) +{ + const float DX = X < Box.Min.X ? Box.Min.X - X : X > Box.Max.X ? X - Box.Max.X : 0.0f; + const float DY = Y < Box.Min.Y ? Box.Min.Y - Y : Y > Box.Max.Y ? Y - Box.Max.Y : 0.0f; + return DX * DX + DY * DY; +} + +static FORCEINLINE bool VF_ClosedIntervalsOverlap( + float AMin, float AMax, float BMin, float BMax) +{ + return AMax >= BMin && BMax >= AMin; +} + +static FORCEINLINE bool VF_InflatedSegmentAabbTouchesBox( + const FVector& A, const FVector& B, float Inflation, const FBox& Box) +{ + const float MinX = FMath::Min(A.X, B.X) - Inflation; + const float MaxX = FMath::Max(A.X, B.X) + Inflation; + const float MinY = FMath::Min(A.Y, B.Y) - Inflation; + const float MaxY = FMath::Max(A.Y, B.Y) + Inflation; + const float MinZ = FMath::Min(A.Z, B.Z) - Inflation; + const float MaxZ = FMath::Max(A.Z, B.Z) + Inflation; + return VF_ClosedIntervalsOverlap(MinX, MaxX, Box.Min.X, Box.Max.X) + && VF_ClosedIntervalsOverlap(MinY, MaxY, Box.Min.Y, Box.Max.Y) + && VF_ClosedIntervalsOverlap(MinZ, MaxZ, Box.Min.Z, Box.Max.Z); +} + +static FORCEINLINE float VF_DistanceSquaredToXYSegment( + float X, float Y, float AX, float AY, float BX, float BY) +{ + const float ABX = BX - AX, ABY = BY - AY; + const float APX = X - AX, APY = Y - AY; + const float Denom = FMath::Max(ABX * ABX + ABY * ABY, KINDA_SMALL_NUMBER); + const float T = FMath::Clamp((APX * ABX + APY * ABY) / Denom, 0.0f, 1.0f); + const float CX = AX + ABX * T, CY = AY + ABY * T; + return FMath::Square(X - CX) + FMath::Square(Y - CY); +} + +static FORCEINLINE bool VF_AnyLatticeZInOpenBand( + const FIntVector& Origin, int32 Step, int32 Cells, float MinZ, float MaxZ) +{ + for (int32 GZ = 0; GZ <= Cells; ++GZ) + { + const float Z = static_cast(Origin.Z + GZ * Step); + if (Z > MinZ && Z < MaxZ) + { + return true; + } + } + return false; +} + +static bool VF_AnyChasmCanTouchLattice( + const FIntVector& Origin, int32 Step, int32 Cells, + const FStrateDisturbanceParams& D, uint32 Seed) +{ + if (D.ChasmDensity == 0.0f) + { + return false; + } + if (!VoxelMath::IsFinite(D.ChasmDensity) || D.ChasmDensity < 0.0f + || D.ChasmDensity > 1.0f || !VoxelMath::IsFinite(D.ChasmSpacing) + || !VoxelMath::IsFinite(D.ChasmRadius) || D.ChasmSpacing <= 0.0f + || D.ChasmRadius < 0.0f) + { + return true; + } + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + if (!VoxelMath::IsFinite(InnerTop) || !VoxelMath::IsFinite(InnerBot)) + { + return true; + } + if (!VF_AnyLatticeZInOpenBand(Origin, Step, Cells, InnerBot, InnerTop)) + { + return false; + } + + const float Spacing = D.ChasmSpacing; + const float InfluenceRadius = D.ChasmRadius + 3.0f; + const float InfluenceRadiusSq = InfluenceRadius * InfluenceRadius; + for (int32 GY = 0; GY <= Cells; ++GY) + for (int32 GX = 0; GX <= Cells; ++GX) + { + const float X = static_cast(Origin.X + GX * Step); + const float Y = static_cast(Origin.Y + GY * Step); + const int32 CX = FMath::FloorToInt(X / Spacing); + const int32 CY = FMath::FloorToInt(Y / Spacing); + for (int32 DY = -1; DY <= 1; ++DY) + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 NX = CX + DX, NY = CY + DY; + const uint32 H = VoxelHash::Cell(NX, NY, Seed ^ 0x43480001u); + if (VoxelHash::ToFloat01(H) > D.ChasmDensity) + { + continue; + } + const float ChasmX = (NX + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) * 0.7f) * Spacing; + const float ChasmY = (NY + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) * 0.7f) * Spacing; + if (FMath::Square(X - ChasmX) + FMath::Square(Y - ChasmY) <= InfluenceRadiusSq) + { + return true; + } + } + } + return false; +} + +static bool VF_AnyBridgeCanTouchLattice( + const FIntVector& Origin, int32 Step, int32 Cells, + const FStrateDisturbanceParams& D, uint32 Seed) +{ + if (D.BridgeDensity == 0.0f) + { + return false; + } + if (!VoxelMath::IsFinite(D.BridgeDensity) || D.BridgeDensity < 0.0f + || D.BridgeDensity > 1.0f || !VoxelMath::IsFinite(D.BridgeSpacing) + || !VoxelMath::IsFinite(D.BridgeRadius) || D.BridgeSpacing <= 0.0f + || D.BridgeRadius < 0.0f) + { + return true; + } + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + if (!VoxelMath::IsFinite(InnerTop) || !VoxelMath::IsFinite(InnerBot)) + { + return true; + } + + const float Spacing = D.BridgeSpacing; + const float Inflation = D.BridgeRadius + 3.0f; + const float InflationSq = Inflation * Inflation; + for (int32 GY = 0; GY <= Cells; ++GY) + for (int32 GX = 0; GX <= Cells; ++GX) + { + const float X = static_cast(Origin.X + GX * Step); + const float Y = static_cast(Origin.Y + GY * Step); + const int32 CX = FMath::FloorToInt(X / Spacing); + const int32 CY = FMath::FloorToInt(Y / Spacing); + for (int32 DY = -1; DY <= 1; ++DY) + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 NX = CX + DX, NY = CY + DY; + const uint32 H = VoxelHash::Cell(NX, NY, Seed ^ 0x42520001u); + if (VoxelHash::ToFloat01(H) > D.BridgeDensity) + { + continue; + } + const float ZC = FMath::Lerp( + InnerBot + 8.0f, InnerTop - 8.0f, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xB1u))); + const float Angle = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xB2u)) * PI; + float SinAngle = 0.0f, CosAngle = 0.0f; + VoxelMath::DetSinCos(SinAngle, CosAngle, Angle); + const float DXU = CosAngle, DYU = SinAngle; + const float BX = (NX + 0.5f) * Spacing, BY = (NY + 0.5f) * Spacing; + const float Half = Spacing * 0.6f; + const float HorizontalSq = VF_DistanceSquaredToXYSegment( + X, Y, BX - DXU * Half, BY - DYU * Half, + BX + DXU * Half, BY + DYU * Half); + if (HorizontalSq > InflationSq) + { + continue; + } + for (int32 GZ = 0; GZ <= Cells; ++GZ) + { + const float Z = static_cast(Origin.Z + GZ * Step); + if (FMath::Square(Z - ZC) + HorizontalSq <= InflationSq) + { + return true; + } + } + } + } + return false; +} + +static bool VF_AnyRidgeCanTouchLattice( + const FIntVector& Origin, int32 Step, int32 Cells, + const FStrateDisturbanceParams& D, uint32 Seed) +{ + if (D.RidgeDensity == 0.0f) + { + return false; + } + if (!VoxelMath::IsFinite(D.RidgeDensity) || D.RidgeDensity < 0.0f + || D.RidgeDensity > 1.0f || !VoxelMath::IsFinite(D.RidgeSpacing) + || !VoxelMath::IsFinite(D.RidgeHeight) || !VoxelMath::IsFinite(D.RidgeThickness) + || D.RidgeSpacing <= 0.0f || D.RidgeHeight <= 0.0f || D.RidgeThickness < 0.0f) + { + return true; + } + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + if (!VoxelMath::IsFinite(InnerTop) || !VoxelMath::IsFinite(InnerBot)) + { + return true; + } + const float TopZ = FMath::Min(InnerBot + D.RidgeHeight, InnerTop); + bool bHasRidgeZ = false; + for (int32 GZ = 0; GZ <= Cells; ++GZ) + { + if (static_cast(Origin.Z + GZ * Step) < TopZ) + { + bHasRidgeZ = true; + break; + } + } + if (!bHasRidgeZ) + { + return false; + } + + const float Spacing = D.RidgeSpacing; + const float InfluenceRadius = D.RidgeThickness + 3.0f; + const float InfluenceRadiusSq = InfluenceRadius * InfluenceRadius; + for (int32 GY = 0; GY <= Cells; ++GY) + for (int32 GX = 0; GX <= Cells; ++GX) + { + const float X = static_cast(Origin.X + GX * Step); + const float Y = static_cast(Origin.Y + GY * Step); + const int32 CX = FMath::FloorToInt(X / Spacing); + const int32 CY = FMath::FloorToInt(Y / Spacing); + for (int32 DY = -1; DY <= 1; ++DY) + for (int32 DX = -1; DX <= 1; ++DX) + { + const int32 NX = CX + DX, NY = CY + DY; + const uint32 H = VoxelHash::Cell(NX, NY, Seed ^ 0x52470001u); + if (VoxelHash::ToFloat01(H) > D.RidgeDensity) + { + continue; + } + const float Angle = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9001u)) * PI; + float SinAngle = 0.0f, CosAngle = 0.0f; + VoxelMath::DetSinCos(SinAngle, CosAngle, Angle); + const float DXU = CosAngle, DYU = SinAngle; + const float BX = (NX + 0.5f) * Spacing, BY = (NY + 0.5f) * Spacing; + const float Half = Spacing * 0.45f; + if (VF_DistanceSquaredToXYSegment( + X, Y, BX - DXU * Half, BY - DYU * Half, + BX + DXU * Half, BY + DYU * Half) <= InfluenceRadiusSq) + { + return true; + } + } + } + return false; +} + +static bool VF_AnyChasmCanTouchBox( + const FBox& Box, const FStrateDisturbanceParams& D, uint32 Seed) +{ + if (D.ChasmDensity == 0.0f) + { + return false; + } + if (!VoxelMath::IsFinite(D.ChasmDensity) || D.ChasmDensity < 0.0f + || D.ChasmDensity > 1.0f || !VoxelMath::IsFinite(D.ChasmSpacing) + || !VoxelMath::IsFinite(D.ChasmRadius) || D.ChasmSpacing <= 0.0f + || D.ChasmRadius < 0.0f) + { + return true; + } + if (!VF_DisturbanceBandTouchesBox(Box, D)) + { + return false; + } + + const float Spacing = D.ChasmSpacing; + const int32 CellX0 = FMath::FloorToInt(Box.Min.X / Spacing) - 1; + const int32 CellX1 = FMath::FloorToInt(Box.Max.X / Spacing) + 1; + const int32 CellY0 = FMath::FloorToInt(Box.Min.Y / Spacing) - 1; + const int32 CellY1 = FMath::FloorToInt(Box.Max.Y / Spacing) + 1; + const float InfluenceRadius = D.ChasmRadius + 3.0f; // ApplyDisturbances' Blend. + const float InfluenceRadiusSq = InfluenceRadius * InfluenceRadius; + + for (int32 NY = CellY0; NY <= CellY1; ++NY) + for (int32 NX = CellX0; NX <= CellX1; ++NX) + { + const uint32 H = VoxelHash::Cell(NX, NY, Seed ^ 0x43480001u); + if (VoxelHash::ToFloat01(H) > D.ChasmDensity) + { + continue; + } + const float CX = (NX + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) * 0.7f) * Spacing; + const float CY = (NY + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) * 0.7f) * Spacing; + if (VF_DistanceSquaredToXYBox(CX, CY, Box) <= InfluenceRadiusSq) + { + return true; + } + } + return false; +} + +static bool VF_AnyBridgeCanTouchBox( + const FBox& Box, const FStrateDisturbanceParams& D, uint32 Seed) +{ + if (D.BridgeDensity == 0.0f) + { + return false; + } + if (!VoxelMath::IsFinite(D.BridgeDensity) || D.BridgeDensity < 0.0f + || D.BridgeDensity > 1.0f || !VoxelMath::IsFinite(D.BridgeSpacing) + || !VoxelMath::IsFinite(D.BridgeRadius) || D.BridgeSpacing <= 0.0f + || D.BridgeRadius < 0.0f) + { + return true; + } + if (!VF_DisturbanceBandTouchesBox(Box, D)) + { + return false; + } + + const float Spacing = D.BridgeSpacing; + const int32 CellX0 = FMath::FloorToInt(Box.Min.X / Spacing) - 1; + const int32 CellX1 = FMath::FloorToInt(Box.Max.X / Spacing) + 1; + const int32 CellY0 = FMath::FloorToInt(Box.Min.Y / Spacing) - 1; + const int32 CellY1 = FMath::FloorToInt(Box.Max.Y / Spacing) + 1; + const float Inflation = D.BridgeRadius + 3.0f; // Capsule radius plus Blend. + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + + for (int32 NY = CellY0; NY <= CellY1; ++NY) + for (int32 NX = CellX0; NX <= CellX1; ++NX) + { + const uint32 H = VoxelHash::Cell(NX, NY, Seed ^ 0x42520001u); + if (VoxelHash::ToFloat01(H) > D.BridgeDensity) + { + continue; + } + const float ZC = FMath::Lerp( + InnerBot + 8.0f, InnerTop - 8.0f, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xB1u))); + const float Angle = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xB2u)) * PI; + float SinAngle = 0.0f, CosAngle = 0.0f; + VoxelMath::DetSinCos(SinAngle, CosAngle, Angle); + const float DX = CosAngle, DY = SinAngle; + const float BX = (NX + 0.5f) * Spacing, BY = (NY + 0.5f) * Spacing; + const float Half = Spacing * 0.6f; + const FVector A(BX - DX * Half, BY - DY * Half, ZC); + const FVector B(BX + DX * Half, BY + DY * Half, ZC); + if (VF_InflatedSegmentAabbTouchesBox(A, B, Inflation, Box)) + { + return true; + } + } + return false; +} + +static bool VF_AnyRidgeCanTouchBox( + const FBox& Box, const FStrateDisturbanceParams& D, uint32 Seed) +{ + if (D.RidgeDensity == 0.0f) + { + return false; + } + if (!VoxelMath::IsFinite(D.RidgeDensity) || D.RidgeDensity < 0.0f + || D.RidgeDensity > 1.0f || !VoxelMath::IsFinite(D.RidgeSpacing) + || !VoxelMath::IsFinite(D.RidgeHeight) || !VoxelMath::IsFinite(D.RidgeThickness) + || D.RidgeSpacing <= 0.0f || D.RidgeHeight <= 0.0f || D.RidgeThickness < 0.0f) + { + return true; + } + if (!VF_DisturbanceBandTouchesBox(Box, D)) + { + return false; + } + + const float Spacing = D.RidgeSpacing; + const int32 CellX0 = FMath::FloorToInt(Box.Min.X / Spacing) - 1; + const int32 CellX1 = FMath::FloorToInt(Box.Max.X / Spacing) + 1; + const int32 CellY0 = FMath::FloorToInt(Box.Min.Y / Spacing) - 1; + const int32 CellY1 = FMath::FloorToInt(Box.Max.Y / Spacing) + 1; + const float Inflation = D.RidgeThickness + 3.0f; // Footprint radius plus Blend. + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + const float TopZ = FMath::Min(InnerBot + D.RidgeHeight, InnerTop); + if (Box.Min.Z >= TopZ) + { + return false; + } + + for (int32 NY = CellY0; NY <= CellY1; ++NY) + for (int32 NX = CellX0; NX <= CellX1; ++NX) + { + const uint32 H = VoxelHash::Cell(NX, NY, Seed ^ 0x52470001u); + if (VoxelHash::ToFloat01(H) > D.RidgeDensity) + { + continue; + } + const float Angle = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9001u)) * PI; + float SinAngle = 0.0f, CosAngle = 0.0f; + VoxelMath::DetSinCos(SinAngle, CosAngle, Angle); + const float DX = CosAngle, DY = SinAngle; + const float BX = (NX + 0.5f) * Spacing, BY = (NY + 0.5f) * Spacing; + const float Half = Spacing * 0.45f; + const FVector A(BX - DX * Half, BY - DY * Half, InnerBot); + const FVector B(BX + DX * Half, BY + DY * Half, InnerBot); + if (VF_InflatedSegmentAabbTouchesBox(A, B, Inflation, Box)) + { + return true; + } + } + return false; +} + +static FORCEINLINE bool VF_AnyDisturbanceCanTouchBox( + const FBox& Box, const FStrateDisturbanceParams& D, uint32 Seed) +{ + return VF_AnyChasmCanTouchBox(Box, D, Seed) + || VF_AnyBridgeCanTouchBox(Box, D, Seed) + || VF_AnyRidgeCanTouchBox(Box, D, Seed); +} + +static FORCEINLINE bool VF_SealedSolidProofGuardDisabled( + uint8 DisabledGuards, EVoxelSealedSolidProofGuard Guard) +{ + return (DisabledGuards & static_cast(Guard)) != 0; +} + +static FORCEINLINE bool VF_IsSafeBoundarySealLatticeZ( + float WorldZ, float StrateTopZ, float StrateBottomZ, + float SealThickness, float BaseDensity) +{ + // ApplyBoundarySeal is a positive internal-density lower bound. At an exact lattice point, + // the original inequalities are sufficient: a strictly positive seal factor times a finite, + // positive base density makes the final MC density negative. Do not widen the band here; + // false Mixed costs time, while the brute-force test below is the authority for this shortcut. + if (!VoxelMath::IsFinite(WorldZ) + || !VoxelMath::IsFinite(StrateTopZ) + || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(SealThickness) + || !VoxelMath::IsFinite(BaseDensity) + || !(StrateTopZ > StrateBottomZ) + || !(SealThickness > 0.0f) + || !(BaseDensity > 0.0f)) + { + return false; + } + + const float DistTop = StrateTopZ - WorldZ; + const float DistBottom = WorldZ - StrateBottomZ; + return (DistTop >= 0.0f && DistTop < SealThickness) + || (DistBottom >= 0.0f && DistBottom < SealThickness); +} + +static bool VF_SealedSolidProofPassageInputsAreValid(const UVoxelStrateManager& Manager) +{ + // AnyPassageNearBox is intentionally a cheap bounding-sphere test. Its normal runtime inputs + // are generated data, but a malformed bound must fail closed: NaN distance comparisons would + // otherwise turn an unknown passage into a false "no passage" result. + for (const FVoxelPassage& Passage : Manager.GetPassages()) + { + if (!VoxelMath::IsFinite(Passage.BoundCenter.X) + || !VoxelMath::IsFinite(Passage.BoundCenter.Y) + || !VoxelMath::IsFinite(Passage.BoundCenter.Z) + || !VoxelMath::IsFinite(Passage.BoundRadius) + || Passage.BoundRadius < 0.0f + || !VoxelMath::IsFinite(Passage.Radius) + || Passage.Radius < 0.0f) + { + return false; + } + for (const FVector& Point : Passage.ControlPoints) + { + if (!VoxelMath::IsFinite(Point.X) + || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z)) + { + return false; + } + } + for (const float Radius : Passage.ControlRadii) + { + if (!VoxelMath::IsFinite(Radius) || Radius < 0.0f) + { + 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->StandingPoint.Z) + || !VoxelMath::IsFinite(Landing->DoorPoint.X) + || !VoxelMath::IsFinite(Landing->DoorPoint.Y) + || !VoxelMath::IsFinite(Landing->DoorPoint.Z) + || !VoxelMath::IsFinite(Landing->FloorZ) + || !VoxelMath::IsFinite(Landing->CeilingZ) + || !VoxelMath::IsFinite(Landing->HalfWidth) + || !VoxelMath::IsFinite(Landing->FloorThickness) + || Landing->HalfWidth < 0.0f + || Landing->FloorThickness < 0.0f) + { + return false; + } + } + + if (Passage.bWalkableTunnelContract + && (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num())) + { + return false; + } + } + return true; +} + +static bool VF_TryProveSealedSolidTile( + const UVoxelGenerator& Generator, const FIntVector& OriginVoxels, + int32 Step, int32 CellsPerAxis, uint8 DisabledGuards) +{ + const UVoxelStrateManager* Manager = Generator.StrateManager; + if (Manager == nullptr) + { + return false; + } + + Step = FMath::Max(1, Step); + const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); + + // Keep the integer arithmetic that defines the MC lattice identical to ClassifyTile, but + // reject a caller whose endpoint would overflow before any floor division or FBox creation. + const int64 MaxX64 = static_cast(OriginVoxels.X) + + static_cast(CPA) * static_cast(Step); + const int64 MaxY64 = static_cast(OriginVoxels.Y) + + static_cast(CPA) * static_cast(Step); + const int64 MaxZ64 = static_cast(OriginVoxels.Z) + + static_cast(CPA) * static_cast(Step); + if (MaxX64 < MIN_int32 || MaxX64 > MAX_int32 + || MaxY64 < MIN_int32 || MaxY64 > MAX_int32 + || MaxZ64 < MIN_int32 || MaxZ64 > MAX_int32) + { + return false; + } + + const int32 MinX = OriginVoxels.X; + const int32 MinY = OriginVoxels.Y; + const int32 MinZ = OriginVoxels.Z; + const int32 MaxX = static_cast(MaxX64); + const int32 MaxY = static_cast(MaxY64); + const int32 MaxZ = static_cast(MaxZ64); + const FBox TileVoxelBox( + FVector(static_cast(MinX), static_cast(MinY), static_cast(MinZ)), + FVector(static_cast(MaxX), static_cast(MaxY), static_cast(MaxZ))); + + auto FloorDivC = [](int32 A, int32 B) -> int32 + { + const int32 Q = A / B; + const int32 R = A % B; + return (R != 0 && ((R < 0) != (B < 0))) ? Q - 1 : Q; + }; + + // First pass is deliberately Z-only. Most streaming tiles are neither gap nor seal, so they + // leave in this cheap loop without taking a diff lock, scanning passages, or copying params. + enum : uint8 { GapRow = 1u, SealRow = 2u }; + int32 SampleChunkZ[CHUNK_SIZE + 1]; + uint8 SampleKind[CHUNK_SIZE + 1]; + bool bSawSeal = false; + bool bSawSolidRegion = false; + for (int32 G = 0; G <= CPA; ++G) + { + const int64 Z64 = static_cast(OriginVoxels.Z) + + static_cast(G) * static_cast(Step); + const int32 Z = static_cast(Z64); + const int32 ChunkZ = FloorDivC(Z, CHUNK_SIZE); + SampleChunkZ[G] = ChunkZ; + if (Manager->IsGapChunk(FIntVector(0, 0, ChunkZ))) + { + SampleKind[G] = GapRow; + bSawSolidRegion = true; + continue; + } + + int32 UnusedTopChunkZ = 0; + int32 UnusedBottomChunkZ = 0; + if (!Manager->GetStrateChunkZBounds( + ChunkZ, UnusedTopChunkZ, UnusedBottomChunkZ)) + { + // Above/below the stack is open air, not a solid gap. A tile that touches it is not + // a sealed-solid candidate even if another row happens to be bedrock. + return false; + } + SampleKind[G] = SealRow; + bSawSeal = true; + bSawSolidRegion = true; + } + if (!bSawSolidRegion) + { + return false; + } + if (bSawSeal && VoxelDensityAblation::IsBoundarySealOff()) + { + return false; + } + + // Player edits are the final writer and can carve a tile that was structurally solid. This + // is intentionally checked only after the Z pre-pass: ordinary mixed tiles avoid the lock. + const int32 MinChunkX = FloorDivC(MinX, CHUNK_SIZE); + const int32 MaxChunkX = FloorDivC(MaxX, CHUNK_SIZE); + const int32 MinChunkY = FloorDivC(MinY, CHUNK_SIZE); + const int32 MaxChunkY = FloorDivC(MaxY, CHUNK_SIZE); + const int32 MinChunkZ = FloorDivC(MinZ, CHUNK_SIZE); + const int32 MaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE); + if (!VF_SealedSolidProofGuardDisabled( + DisabledGuards, EVoxelSealedSolidProofGuard::DiffLayer) + && Generator.DiffLayer != nullptr + && Generator.DiffLayer->HasAnyMods()) + { + if (Generator.DiffLayer->HasAnyModInChunkRange( + FIntVector(MinChunkX, MinChunkY, MinChunkZ), + FIntVector(MaxChunkX, MaxChunkY, MaxChunkZ))) + { + return false; + } + } + + // PassageCarving's public box guard includes the 4-voxel carve blend pad on top of each + // generated passage bound. Landing and structural-post air are also handled conservatively: + // if carving is ablated but another passage air writer remains active, reject any passage data + // rather than trusting an API whose historical early-out is specific to carving. + if (!VF_SealedSolidProofGuardDisabled( + DisabledGuards, EVoxelSealedSolidProofGuard::Passages)) + { + const bool bPassageAirWriterActive = + !VoxelDensityAblation::IsPassageCarvingOff() + || !VoxelDensityAblation::IsLandingPostsOff() + || !VoxelDensityAblation::IsPassageStructuralPostsOff(); + if (bPassageAirWriterActive) + { + if (!VF_SealedSolidProofPassageInputsAreValid(*Manager)) + { + return false; + } + if (!VoxelDensityAblation::IsPassageCarvingOff()) + { + if (Manager->AnyPassageNearBox( + TileVoxelBox.Min, TileVoxelBox.Max)) + { + return false; + } + } + else if (Manager->GetPassages().Num() > 0) + { + // AnyPassageNearBox deliberately returns false when the carve stage is off. The + // remaining landing/tunnel posts have no equivalent box API, so this is the + // capability-first answer under that development ablation. + return false; + } + } + } + + // The origin landing is the (0,0) spine's current room-shaped air writer. Its box guard is + // broader than the exact lattice guard used by ClassifyTile; false Mixed is cheap here. + if (!VF_SealedSolidProofGuardDisabled( + DisabledGuards, EVoxelSealedSolidProofGuard::OriginLanding) + && (!VoxelDensityAblation::IsOriginSpineOff() + || !VoxelDensityAblation::IsLandingPostsOff()) + && Manager->AnyOriginLandingNearBox(TileVoxelBox.Min, TileVoxelBox.Max)) + { + return false; + } + + const int64 XYChunkCount = + (static_cast(MaxChunkX) - static_cast(MinChunkX) + 1) + * (static_cast(MaxChunkY) - static_cast(MinChunkY) + 1); + const int64 ZChunkCount = static_cast(MaxChunkZ) - static_cast(MinChunkZ) + 1; + // This is a per-tile proof, not a second coarse mesher. A normal LOD0/LOD1 tile touches at + // most a handful of chunks. Refuse pathological caller boxes before a nested loop can turn + // the cheap path into a large param walk. + constexpr int64 MaxProofChunkCoordinates = 65536; + if (XYChunkCount <= 0 || ZChunkCount <= 0 + || XYChunkCount > MaxProofChunkCoordinates + || XYChunkCount * ZChunkCount > MaxProofChunkCoordinates) + { + return false; + } + + auto ResolveSealFields = [&](const FIntVector& ChunkCoord, + float& OutTop, float& OutBottom, + float& OutThickness, float& OutBase) -> bool + { + switch (Manager->GetGeneratorTypeForChunk(ChunkCoord)) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + { + const FSlabGenerationParams P = Manager->GetSlabParamsForChunk(ChunkCoord); + OutTop = P.StrateTopWorldZ; + OutBottom = P.StrateBottomWorldZ; + OutThickness = P.BoundarySealThickness; + OutBase = P.BaseDensity; + return true; + } + case ECaveGeneratorType::Maze: + { + const FMazeGenerationParams P = Manager->GetMazeParamsForChunk(ChunkCoord); + OutTop = P.StrateTopWorldZ; + OutBottom = P.StrateBottomWorldZ; + OutThickness = P.BoundarySealThickness; + OutBase = P.BaseDensity; + return true; + } + case ECaveGeneratorType::SurfaceWorld: + { + const FSurfaceGenerationParams P = Manager->GetSurfaceParamsForChunk(ChunkCoord); + OutTop = P.StrateTopWorldZ; + OutBottom = P.StrateBottomWorldZ; + OutThickness = P.BoundarySealThickness; + OutBase = P.BaseDensity; + return true; + } + case ECaveGeneratorType::VerticalShafts: + { + const FVerticalShaftParams P = Manager->GetVerticalShaftParamsForChunk(ChunkCoord); + OutTop = P.StrateTopWorldZ; + OutBottom = P.StrateBottomWorldZ; + OutThickness = P.BoundarySealThickness; + OutBase = P.BaseDensity; + return true; + } + case ECaveGeneratorType::FloatingIslands: + { + const FFloatingIslandParams P = Manager->GetFloatingIslandParamsForChunk(ChunkCoord); + OutTop = P.StrateTopWorldZ; + OutBottom = P.StrateBottomWorldZ; + OutThickness = P.BoundarySealThickness; + OutBase = P.BaseDensity; + return true; + } + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + { + const FStrateGenerationParams P = Manager->GetGenerationParams(ChunkCoord); + OutTop = P.StrateTopWorldZ; + OutBottom = P.StrateBottomWorldZ; + OutThickness = P.BoundarySealThickness; + OutBase = P.BaseDensity; + return true; + } + default: + return false; + } + }; + + for (int32 ChunkZ = MinChunkZ; ChunkZ <= MaxChunkZ; ++ChunkZ) + { + bool bThisChunkHasSealRow = false; + for (int32 G = 0; G <= CPA; ++G) + { + if (SampleKind[G] == SealRow && SampleChunkZ[G] == ChunkZ) + { + bThisChunkHasSealRow = true; + break; + } + } + if (!bThisChunkHasSealRow) + { + continue; + } + + for (int32 ChunkY = MinChunkY; ChunkY <= MaxChunkY; ++ChunkY) + for (int32 ChunkX = MinChunkX; ChunkX <= MaxChunkX; ++ChunkX) + { + const FIntVector ChunkCoord(ChunkX, ChunkY, ChunkZ); + +#if WITH_EDITOR + // A plain composer override changes the archetype/params, while a region override + // changes the lateral field. Both must remain on the ordinary mesher/classifier path. + int32 ComposerSeed = 0; + ECaveGeneratorType ComposerArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ComposerParams; + bool bComposerRecipe = false; + FVoxelOpStackRecipe ComposerRecipe; + if (Manager->GetComposerOverrideForChunk( + ChunkCoord, ComposerSeed, ComposerArchetype, ComposerParams, + bComposerRecipe, ComposerRecipe)) + { + return false; + } + FVoxelStrateRegionManifest ComposerRegions; + if (Manager->GetComposerRegionOverrideForChunk(ChunkCoord, ComposerRegions)) + { + return false; + } +#endif + + // Cooked seasons can materialise a custom recipe for a chunk. The cheap proof only + // knows the native structural seal contract, so selected recipes fail closed. + int32 RecipeSeed = 0; + ECaveGeneratorType RecipeArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams RecipeParams; + FVoxelOpStackRecipe Recipe; + if (Manager->GetRecipeForChunk( + ChunkCoord, RecipeSeed, RecipeArchetype, RecipeParams, Recipe)) + { + return false; + } + + float Top = 0.0f; + float Bottom = 0.0f; + float Thickness = 0.0f; + float BaseDensity = 0.0f; + if (!ResolveSealFields(ChunkCoord, Top, Bottom, Thickness, BaseDensity)) + { + return false; + } + + if (!VF_SealedSolidProofGuardDisabled( + DisabledGuards, EVoxelSealedSolidProofGuard::DisturbanceChasms) + && !VoxelDensityAblation::IsDisturbancesOff()) + { + const FStrateDisturbanceParams D = + Manager->GetDisturbanceParamsForChunk(ChunkCoord); + if (VF_AnyChasmCanTouchBox(TileVoxelBox, D, static_cast(Generator.Seed))) + { + return false; + } + } + + for (int32 G = 0; G <= CPA; ++G) + { + if (SampleKind[G] != SealRow || SampleChunkZ[G] != ChunkZ) + { + continue; + } + const float Z = static_cast( + static_cast(OriginVoxels.Z) + + static_cast(G) * static_cast(Step)); + if (!VF_IsSafeBoundarySealLatticeZ( + Z, Top, Bottom, Thickness, BaseDensity)) + { + return false; + } + } + } + } + + return true; +} + +static bool VF_TryProveOutOfLayoutAirTile( + const UVoxelGenerator& Generator, const FIntVector& OriginVoxels, + int32 Step, int32 CellsPerAxis) +{ + const UVoxelStrateManager* Manager = Generator.StrateManager; + if (Manager == nullptr) + { + return false; + } + + Step = FMath::Max(1, Step); + const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); + + const int64 MaxX64 = static_cast(OriginVoxels.X) + + static_cast(CPA) * static_cast(Step); + const int64 MaxY64 = static_cast(OriginVoxels.Y) + + static_cast(CPA) * static_cast(Step); + const int64 MaxZ64 = static_cast(OriginVoxels.Z) + + static_cast(CPA) * static_cast(Step); + if (MaxX64 < MIN_int32 || MaxX64 > MAX_int32 + || MaxY64 < MIN_int32 || MaxY64 > MAX_int32 + || MaxZ64 < MIN_int32 || MaxZ64 > MAX_int32) + { + return false; + } + + const int32 MinX = OriginVoxels.X; + const int32 MinY = OriginVoxels.Y; + const int32 MinZ = OriginVoxels.Z; + const int32 MaxX = static_cast(MaxX64); + const int32 MaxY = static_cast(MaxY64); + const int32 MaxZ = static_cast(MaxZ64); + const FBox TileVoxelBox( + FVector(static_cast(MinX), static_cast(MinY), static_cast(MinZ)), + FVector(static_cast(MaxX), static_cast(MaxY), static_cast(MaxZ))); + + auto FloorDivC = [](int32 A, int32 B) -> int32 + { + const int32 Q = A / B; + const int32 R = A % B; + return (R != 0 && ((R < 0) != (B < 0))) ? Q - 1 : Q; + }; + + // All core lattice rows must be outside the layout. A gap or a real strate row means that + // this is no longer the constant-air case, even when another row is open air. + for (int32 G = 0; G <= CPA; ++G) + { + const int64 Z64 = static_cast(OriginVoxels.Z) + + static_cast(G) * static_cast(Step); + const int32 ChunkZ = FloorDivC(static_cast(Z64), CHUNK_SIZE); + if (Manager->IsGapChunk(FIntVector(0, 0, ChunkZ))) + { + return false; + } + int32 UnusedTopChunkZ = 0; + int32 UnusedBottomChunkZ = 0; + if (Manager->GetStrateChunkZBounds( + ChunkZ, UnusedTopChunkZ, UnusedBottomChunkZ)) + { + return false; + } + } + + const int32 MinChunkX = FloorDivC(MinX, CHUNK_SIZE); + const int32 MaxChunkX = FloorDivC(MaxX, CHUNK_SIZE); + const int32 MinChunkY = FloorDivC(MinY, CHUNK_SIZE); + const int32 MaxChunkY = FloorDivC(MaxY, CHUNK_SIZE); + const int32 MinChunkZ = FloorDivC(MinZ, CHUNK_SIZE); + const int32 MaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE); + if (Generator.DiffLayer != nullptr + && Generator.DiffLayer->HasAnyMods() + && Generator.DiffLayer->HasAnyModInChunkRange( + FIntVector(MinChunkX, MinChunkY, MinChunkZ), + FIntVector(MaxChunkX, MaxChunkY, MaxChunkZ))) + { + return false; + } + + // The final XY pass can add rock to open air. Radius == 0 (and other non-positive inputs) is + // the documented no-op, so only validate and inspect the band when that writer is live. + if (!VoxelDensityAblation::IsXYEdgeSealOff() + && Generator.WorldRadiusVoxels > 0.0f + && Generator.EdgeSealThickness > 0.0f) + { + if (!VF_IsValidXYEdgeSealProofInput( + TileVoxelBox, Generator.WorldRadiusVoxels, + Generator.EdgeSealThickness, 1.0f)) + { + return false; + } + + // Use double arithmetic here: the production helper is float-based, while this proof + // must not turn a large finite coordinate into an overflowed squared-distance false + // negative. Equality is safe because the production inner test is <= as well. + const double FarthestX = FMath::Max( + FMath::Abs(static_cast(TileVoxelBox.Min.X)), + FMath::Abs(static_cast(TileVoxelBox.Max.X))); + const double FarthestY = FMath::Max( + FMath::Abs(static_cast(TileVoxelBox.Min.Y)), + FMath::Abs(static_cast(TileVoxelBox.Max.Y))); + const double InnerRadius = static_cast(Generator.WorldRadiusVoxels) + - static_cast(Generator.EdgeSealThickness); + if (!(InnerRadius > 0.0) + || FarthestX * FarthestX + FarthestY * FarthestY + > InnerRadius * InnerRadius) + { + return false; + } + } + + if (Manager->GetPassages().Num() > 0) + { + if (!VF_SealedSolidProofPassageInputsAreValid(*Manager)) + { + return false; + } + + // Passage carving is air-monotone and therefore cannot invalidate an AllAir proof. Only + // the solid floor/support writers need a guard here. Use the same exact lattice floor + // predicate as ClassifyTile: its padded AABBs stay cheap, but do not reject an open-air + // tile merely because a harmless carve bound passes near it. + const bool bPassageFloorWriterActive = + !VoxelDensityAblation::IsPassageStructuralPostsOff() + || !VoxelDensityAblation::IsNativeFloorOff() + || !VoxelDensityAblation::IsLandingPostsOff(); + if (bPassageFloorWriterActive + && Manager->AnyPassageLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + return false; + } + } + + // Origin rooms only carve open air, but their landing floors reassert solid. The manager's + // floor box includes every per-strate room; this is intentionally broader than the exact + // lattice query used by ClassifyTile because a false Mixed is cheaper than a dropped floor. + if (!VoxelDensityAblation::IsOriginSpineOff() + || !VoxelDensityAblation::IsLandingPostsOff()) + { + if (!VoxelMath::IsFinite(Generator.OriginSpineRadius) + || Generator.OriginSpineRadius <= 0.0f) + { + return false; + } + if (Manager->AnyOriginLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + return false; + } + } + + if (!VoxelDensityAblation::IsDisturbancesOff()) + { + const int64 XYChunkCount = + (static_cast(MaxChunkX) - static_cast(MinChunkX) + 1) + * (static_cast(MaxChunkY) - static_cast(MinChunkY) + 1); + const int64 ZChunkCount = + static_cast(MaxChunkZ) - static_cast(MinChunkZ) + 1; + constexpr int64 MaxProofChunkCoordinates = 65536; + if (XYChunkCount <= 0 || ZChunkCount <= 0 + || XYChunkCount > MaxProofChunkCoordinates + || XYChunkCount * ZChunkCount > MaxProofChunkCoordinates) + { + return false; + } + + // Out-of-layout chunks normally return disabled disturbance params. Still ask the exact + // bridge/ridge predicates for every touched chunk so a future out-of-layout decoration + // cannot silently turn this air proof into an invisible solid patch. + for (int32 ChunkZ = MinChunkZ; ChunkZ <= MaxChunkZ; ++ChunkZ) + for (int32 ChunkY = MinChunkY; ChunkY <= MaxChunkY; ++ChunkY) + for (int32 ChunkX = MinChunkX; ChunkX <= MaxChunkX; ++ChunkX) + { + const FStrateDisturbanceParams D = + Manager->GetDisturbanceParamsForChunk(FIntVector(ChunkX, ChunkY, ChunkZ)); + if (VF_AnyBridgeCanTouchBox(TileVoxelBox, D, static_cast(Generator.Seed)) + || VF_AnyRidgeCanTouchBox(TileVoxelBox, D, static_cast(Generator.Seed))) + { + return false; + } + } + } + + return true; +} + +//============================================================================= +// LE MAPPING « ARCHÉTYPE → PILE D’OPÉRATEURS » — UNE SEULE DÉFINITION +//============================================================================= +// ⚠️ EXTRAIT DE `GetDensityAt` PARCE QUE `ClassifyTile` EN A BESOIN AUSSI, ET QU'UNE DEUXIÈME COPIE +// SERAIT LA PIRE FORME DE BUG DISPONIBLE ICI. +// +// `ClassifyTile` décide si une tuile est maillée DU TOUT. Si son verdict venait d'une pile +// construite autrement que celle qui produit la densité — ne serait-ce qu'un paramètre de +// construction différent — la tuile serait sautée sur la foi d'un champ qui n'est pas celui que le +// mesher aurait vu. C'est-à-dire un TROU : pas de géométrie, pas de collision, invisible. +// Un commentaire « garder les deux en phase » n'aurait pas suffi ; il fallait qu'il n'y en ait +// qu'une. +// +// Les params ne sont PAS cherchés ici : les deux appelants les ont déjà (le chemin densité les tient +// dans ses `CP_*`, le classifieur les cherche pour son slot). On ne passe que des pointeurs. +// +// ONE definition of the archetype → stack mapping, because ClassifyTile decides whether a tile is +// meshed at all: a verdict from a differently-built stack would be a hole. Params are passed in, +// never fetched here — both callers already have them. +namespace +{ + // Une identité monotone évite qu'un worker réutilise les CP_* d'un monde détruit même si + // l'allocateur UObject recycle plus tard la même adresse. Relaxed suffit : on ne publie aucune + // donnée, on alloue seulement une valeur distincte par instance. + // A monotonic identity prevents stale CP_* reuse even if UObject allocation later recycles an + // address. Relaxed ordering is sufficient: this allocates uniqueness, it publishes no data. + std::atomic GNextDensityCacheOwnerId { 0 }; + + struct FTunnelCoreCacheState + { + FChunkSDFCache Cache; + uint64 OwnerId = 0; + uint64 ManagerLifetimeId = 0; + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + FIntVector TileOrigin = FIntVector::ZeroValue; + int32 TileStep = 0; + int32 TileCells = 0; + int32 StrateIndex = INT32_MIN; + uint32 WorldSeed = 0xFFFFFFFFu; + uint32 ParamsFingerprint = 0xFFFFFFFFu; + uint32 LayoutVersion = 0xFFFFFFFFu; + bool bUsesTileCacheWindow = false; + bool bValid = false; + }; + + static thread_local FTunnelCoreCacheState GTunnelCoreCache; + + // A mesher tile samples an expanded grid (-1..Cells+1), so one worker visits several exact + // chunk coordinates even while it is generating one tile. The old CP_* cache retained only + // the last coordinate and consequently rebuilt the whole TunnelNetwork stack whenever the + // sampling cursor crossed a chunk boundary. Keep a deterministic direct-mapped set of states + // instead: collisions only cause a safe rebuild, never a stale read or an order-dependent + // result. Native TunnelNetwork/Underwater states are immutable after construction; the active + // pointer below lets the hot sample path read the stored stack and tunnel-core cache without + // moving them on every voxel. + // The cache is open-addressed and bounded. Keep the worker footprint at the established + // 128-entry cap; the expensive native tunnel-core cache is not prepared for an operator-stack + // chunk because the stack publishes the same result to the common post below. + constexpr int32 TunnelDensityCacheSlotCount = 128; + + struct FTunnelNetworkDensityCacheEntry + { + bool bValid = false; + uint64 OwnerId = 0; + uint64 ManagerLifetimeId = 0; + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint32 LayoutVersion = 0xFFFFFFFFu; + ECaveGeneratorType GenType = ECaveGeneratorType::TunnelNetwork; + FStrateGenerationParams Tunnel; + uint32 TunnelFingerprint = 0xFFFFFFFFu; + FStrateDisturbanceParams Disturbance; + bool bUseOpStack = false; + bool bUsesTileCacheWindow = false; + FVoxelOpStack OpStack; + FTunnelCoreCacheState TunnelCore; + }; + + void ReportTunnelDensityCacheFootprint( + const FTunnelNetworkDensityCacheEntry* Entries) + { + if (!VoxelDensityProfile::IsEnabled() || Entries == nullptr) + { + return; + } + + uint64 DynamicBytes = 0; + uint64 EntryBytes = 0; + uint64 LargestEntryBytes = 0; + uint64 ValidEntries = 0; + VoxelDensityProfile::FCacheMemoryBreakdown Breakdown; + for (int32 Index = 0; Index < TunnelDensityCacheSlotCount; ++Index) + { + const FTunnelNetworkDensityCacheEntry& Entry = Entries[Index]; + if (!Entry.bValid) + { + continue; + } + + ++ValidEntries; + const uint64 StackBytes = static_cast(Entry.OpStack.GetAllocatedSize()); + const uint64 TunnelCoreBytes = static_cast( + Entry.TunnelCore.Cache.GetAllocatedSize()); + Breakdown.OpStackBytes += StackBytes; + Breakdown += Entry.TunnelCore.Cache.GetAllocatedSizeBreakdown(); + const uint64 EntryDynamicBytes = StackBytes + TunnelCoreBytes; + const uint64 FullEntryBytes = static_cast( + sizeof(FTunnelNetworkDensityCacheEntry)) + EntryDynamicBytes; + DynamicBytes += EntryDynamicBytes; + EntryBytes += FullEntryBytes; + LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); + } + Breakdown.SlotStorageBytes = static_cast(sizeof(FTunnelNetworkDensityCacheEntry)) + * TunnelDensityCacheSlotCount; + + VoxelDensityProfile::SetWorkerTunnelCacheFootprint( + TunnelDensityCacheSlotCount, + ValidEntries, + static_cast(sizeof(FTunnelNetworkDensityCacheEntry)) + * TunnelDensityCacheSlotCount, + DynamicBytes, + EntryBytes, + LargestEntryBytes, + ValidEntries, + Breakdown); + } + + FORCEINLINE int32 TunnelDensityCacheSlot(const FIntVector& Chunk) + { + uint32 Hash = static_cast(Chunk.X) * 0x9E3779B9u; + Hash ^= static_cast(Chunk.Y) * 0x85EBCA6Bu; + Hash ^= static_cast(Chunk.Z) * 0xC2B2AE35u; + Hash ^= Hash >> 16; + return static_cast(Hash & (TunnelDensityCacheSlotCount - 1)); + } + + static void UpdateTunnelCoreTileReach( + const FChunkSDFCache& Cache, + bool bUseTileCacheWindow) + { + if (!bUseTileCacheWindow + || !VoxelGenLOD::bTileReachDiagnosticTile + || VoxelGenLOD::TileCellsPerAxis <= 0) + { + return; + } + + const int32 Step = FMath::Max(VoxelGenLOD::GetThreadSampleStep(), 1); + const int64 Extent = static_cast(VoxelGenLOD::TileCellsPerAxis + 1) + * static_cast(Step); + if (Extent <= 0 || Extent > MAX_int32) + { + VoxelGenLOD::RecordTunnelCoreReachDecision(true); + return; + } + const FIntVector& Origin = VoxelGenLOD::TileOriginVoxels; + const FBox LatticeWithHalo( + FVector( + static_cast(static_cast(Origin.X) - Step), + static_cast(static_cast(Origin.Y) - Step), + static_cast(static_cast(Origin.Z) - Step)), + FVector( + static_cast(static_cast(Origin.X) + Extent), + static_cast(static_cast(Origin.Y) + Extent), + static_cast(static_cast(Origin.Z) + Extent))); + const bool bNear = VoxelCaveMorphology::AnyTunnelCoreWorldNearLattice( + Cache, LatticeWithHalo, VoxelGenLOD::TileReachScale); + if (bNear) + { + VoxelGenLOD::TilePostReachFlags |= VoxelGenLOD::TunnelCoreReachable; + } + else + { + VoxelGenLOD::TilePostReachFlags &= ~VoxelGenLOD::TunnelCoreReachable; + } + VoxelGenLOD::bTileCoreReachProofEnabled = true; + VoxelGenLOD::RecordTunnelCoreReachDecision(bNear); + } + + void PrepareTunnelCoreCache( + const UVoxelStrateManager& Manager, + const FIntVector& ChunkCoord, + const FStrateGenerationParams& Params, + uint32 ParamsFingerprint, + uint32 LayoutVersion, + uint64 ManagerLifetimeId, + uint64 OwnerId, + FTunnelCoreCacheState& OutState) + { + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = 1; + int32 RequestedTileCells = 0; + const TArray* TerrainOps = nullptr; + if (UVoxelStrateDefinition* Def = Manager.GetStrateForChunk(ChunkCoord)) + { + TerrainOps = &Def->TerrainOperations; + } + const bool bUseTileCacheWindow = VoxelGenLOD::IsTileCacheWindowEnabled(true) + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells) + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(Params, TerrainOps); + const bool bUseSpatialIndex = VoxelGenLOD::ShouldUseSpatialIndex(true); + const int32 StrateIndex = Manager.GetStrateIndex( + (static_cast(ChunkCoord.Z) + 0.5f) + * static_cast(CHUNK_SIZE) * VOXEL_SIZE); + const uint32 WorldSeed = static_cast(Manager.GetWorldSeed()); + + // A tile-window cache contains the complete graph reach for this tile. Chunk-local + // caches deliberately leave the core bit conservative; updating it there would make the + // first chunk's false result hide a tunnel that enters through a later cache window. + if (bUseTileCacheWindow && OutState.bValid) + { + UpdateTunnelCoreTileReach(OutState.Cache, bUseTileCacheWindow); + } + + const bool bSameGraphWindow = OutState.bValid + && OutState.OwnerId == OwnerId + && OutState.ManagerLifetimeId == ManagerLifetimeId + && OutState.StrateIndex == StrateIndex + && OutState.WorldSeed == WorldSeed + && OutState.ParamsFingerprint == ParamsFingerprint + && OutState.LayoutVersion == LayoutVersion + && OutState.bUsesTileCacheWindow == bUseTileCacheWindow + && (!bUseTileCacheWindow + ? OutState.Chunk == ChunkCoord + : (OutState.TileOrigin == RequestedTileOrigin + && OutState.TileStep == RequestedTileStep + && OutState.TileCells == RequestedTileCells)); + if (bSameGraphWindow) + { + OutState.Chunk = ChunkCoord; + return; + } + + const float ChunkMinX = static_cast(ChunkCoord.X * CHUNK_SIZE); + const float ChunkMinY = static_cast(ChunkCoord.Y * CHUNK_SIZE); + const float ChunkMaxX = ChunkMinX + static_cast(CHUNK_SIZE); + const float ChunkMaxY = ChunkMinY + static_cast(CHUNK_SIZE); + const float WarpMargin = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f + 2.0f; + float SearchMinX = ChunkMinX - WarpMargin; + float SearchMinY = ChunkMinY - WarpMargin; + float SearchMaxX = ChunkMaxX + WarpMargin; + float SearchMaxY = ChunkMaxY + WarpMargin; + if (bUseTileCacheWindow) + { + const float TileMinX = static_cast(RequestedTileOrigin.X); + const float TileMinY = static_cast(RequestedTileOrigin.Y); + const float TileExtent = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + const float TileHalo = static_cast(RequestedTileStep) + 2.0f; + const float Expansion = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f + TileHalo; + SearchMinX = TileMinX - Expansion; + SearchMinY = TileMinY - Expansion; + SearchMaxX = TileMinX + TileExtent + Expansion; + SearchMaxY = TileMinY + TileExtent + Expansion; + } + VoxelCaveMorphology::BuildChunkCache( + OutState.Cache, + SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, WorldSeed, + StrateIndex, nullptr, + ERoomGraphBuildSite::GeneratorTunnelCore); + UpdateTunnelCoreTileReach(OutState.Cache, bUseTileCacheWindow); + OutState.OwnerId = OwnerId; + OutState.ManagerLifetimeId = ManagerLifetimeId; + OutState.Chunk = ChunkCoord; + OutState.TileOrigin = bUseTileCacheWindow + ? RequestedTileOrigin : FIntVector::ZeroValue; + OutState.TileStep = bUseTileCacheWindow ? RequestedTileStep : 0; + OutState.TileCells = bUseTileCacheWindow ? RequestedTileCells : 0; + OutState.StrateIndex = StrateIndex; + OutState.WorldSeed = WorldSeed; + OutState.ParamsFingerprint = ParamsFingerprint; + OutState.LayoutVersion = LayoutVersion; + OutState.bUsesTileCacheWindow = bUseTileCacheWindow; + OutState.bValid = true; + } + + struct FVoxelStackParamRefs + { + const FSlabGenerationParams* Slab = nullptr; + const FMazeGenerationParams* Maze = nullptr; + const FVerticalShaftParams* Vert = nullptr; + const FFloatingIslandParams* Float = nullptr; + const FStrateGenerationParams* Tunnel = nullptr; + + // SurfaceWorld uniquement. `Surface == nullptr` ⇒ la fabrique REFUSE cet archétype, ce qui + // est exactement ce que veut `ClassifyTile` : il prouve SurfaceWorld lui-même, sur le + // treillis exact du mesher, et n'a aucune raison de passer par la pile pour ça. + const FSurfaceGenerationParams* Surface = nullptr; + const TArray* SurfaceBiomeParams = nullptr; + TUniquePtr BiomeField; + }; + + /** + * Construit la pile de cet archétype dans `OutStack` et remplit les bornes Z de `OutCtx`. + * + * @return false quand la pile NE DOIT PAS être utilisée — archétype inconnu, params absents, + * ou **strate dégénérée** (hauteur nulle). La pile n'a pas d'early-out pour ce dernier + * cas, par conception : l'appelant rend de l'air (`GetDensityAt`) ou refuse de prouver + * la tuile (`ClassifyTile`). (`GetDensityWithParams` n'a aucun early-out de ce genre : + * TunnelNetwork/Underwater n'ont donc pas cette garde.) + */ + bool VF_BuildOpStackForChunk(ECaveGeneratorType Type, FVoxelStackParamRefs& Refs, + int32 Seed, float SpineRadius, const UVoxelStrateManager* SM, + FVoxelOpStack& OutStack, FVoxelOpContext& OutCtx) + { + switch (Type) + { + case ECaveGeneratorType::Maze: + if (!Refs.Maze) { return false; } + if (Refs.Maze->StrateTopWorldZ - Refs.Maze->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Maze->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Maze->StrateBottomWorldZ; + VoxelDensityOps::BuildMazeStack(OutStack, *Refs.Maze, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + // UN SEUL cas pour les deux : ils ne diffèrent que par leurs valeurs par défaut. + // Voir BuildSlabStack. + if (!Refs.Slab) { return false; } + if (Refs.Slab->StrateTopWorldZ - Refs.Slab->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Slab->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Slab->StrateBottomWorldZ; + VoxelDensityOps::BuildSlabStack(OutStack, *Refs.Slab, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::SurfaceWorld: + if (!Refs.Surface) { return false; } + if (Refs.Surface->StrateTopWorldZ - Refs.Surface->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Surface->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Surface->StrateBottomWorldZ; + VoxelDensityOps::BuildSurfaceStack( + OutStack, *Refs.Surface, Seed, SpineRadius, SM, + Refs.SurfaceBiomeParams ? *Refs.SurfaceBiomeParams : TArray(), + MoveTemp(Refs.BiomeField)); + return true; + + case ECaveGeneratorType::VerticalShafts: + if (!Refs.Vert) { return false; } + if (Refs.Vert->StrateTopWorldZ - Refs.Vert->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Vert->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Vert->StrateBottomWorldZ; + VoxelDensityOps::BuildVerticalShaftStack(OutStack, *Refs.Vert, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::FloatingIslands: + if (!Refs.Float) { return false; } + if (Refs.Float->StrateTopWorldZ - Refs.Float->StrateBottomWorldZ <= 0.0f) { return false; } + OutCtx.StrateTopWorldZ = Refs.Float->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Float->StrateBottomWorldZ; + VoxelDensityOps::BuildFloatingIslandStack(OutStack, *Refs.Float, Seed, SpineRadius, SM); + return true; + + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + // Underwater EST TunnelNetwork plus un drapeau d'eau consommé côté rendu. Pas de garde + // de strate dégénérée : `GetDensityWithParams` n'a pas d'early-out à reproduire. + if (!Refs.Tunnel) { return false; } + OutCtx.StrateTopWorldZ = Refs.Tunnel->StrateTopWorldZ; + OutCtx.StrateBottomWorldZ = Refs.Tunnel->StrateBottomWorldZ; + VoxelDensityOps::BuildTunnelNetworkStack( + OutStack, *Refs.Tunnel, Seed, SpineRadius, SM, + /*bAppendStructuralPosts=*/true); + return true; + + default: + // `UsesOperatorStackForChunk` ne rend true que pour les 8 archétypes, donc on ne devrait + // jamais arriver ici (valeur d'enum invalide). + return false; + } + } +} + +#if WITH_EDITOR +bool VF_BuildNativeStrateStackForCandidate( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + int32 InSeed, + float SpineRadius, + float InWorldRadiusVoxels, + float InEdgeSealThickness, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext) +{ + FVoxelStackParamRefs Refs; + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Refs.Slab = &Params.SlabParams; + break; + case ECaveGeneratorType::Maze: + Refs.Maze = &Params.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + // The live surface path can additionally carry biome-specific parameter arrays and a + // generator-owned biome field. The editor sanity check deliberately handles the exact + // no-biome/native case only; the caller reports a biome target as unavailable rather than + // comparing unlike fields. + Refs.Surface = &Params.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + Refs.Vert = &Params.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + Refs.Float = &Params.FloatingIslandParams; + break; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + Refs.Tunnel = &Params.TunnelNetworkParams; + break; + default: + return false; + } + + OutContext = FVoxelOpContext(); + OutContext.Seed = static_cast(InSeed); + OutContext.LayoutVersion = StrateManager != nullptr ? StrateManager->GetLayoutVersion() : 0; + OutContext.WorldRadiusVoxels = InWorldRadiusVoxels; + OutContext.EdgeSealThickness = InEdgeSealThickness; + if (!VF_BuildOpStackForChunk(Archetype, Refs, InSeed, SpineRadius, StrateManager, + OutStack, OutContext)) + { + return false; + } + return true; +} +#endif + +UVoxelGenerator::UVoxelGenerator() + : DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1) +{ +} + +void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) +{ + // Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas + // déréférencer l'asset de settings. + Seed = Settings ? Settings->GetEffectiveWorldSeed() : 0; + OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 28.0f; + WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f; + EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 128.0f; +} + +void UVoxelGenerator::BeginDensityBlock(FIntVector OriginVoxels, int32 Step, + int32 SizeX, int32 SizeY, int32 SizeZ) const +{ + GVoxelDensityBlockSession.Begin(OriginVoxels, Step, SizeX, SizeY, SizeZ); +} + +void UVoxelGenerator::EndDensityBlock() const +{ + GVoxelDensityBlockSession.End(); +} + +#if WITH_EDITOR +bool UVoxelGenerator::EvaluateDensityChunk( + FIntVector OriginVoxels, int32 SizeX, int32 SizeY, int32 SizeZ, + TArray& OutDensities, int32& OutFallbackSamples, + uint64& OutGeneralPathEntries, uint64& OutSupportedBlockSetupEntries) const +{ + OutDensities.Reset(); + OutFallbackSamples = 0; + OutGeneralPathEntries = 0; + OutSupportedBlockSetupEntries = 0; + const int64 SampleCount64 = static_cast(SizeX) * SizeY * SizeZ; + if (SizeX <= 0 || SizeY <= 0 || SizeZ <= 0 + || SizeX > CHUNK_SIZE || SizeY > CHUNK_SIZE || SizeZ > CHUNK_SIZE + || SampleCount64 > static_cast(CHUNK_SIZE) * CHUNK_SIZE * CHUNK_SIZE) + { + return false; + } + + const FIntVector Last = OriginVoxels + FIntVector(SizeX - 1, SizeY - 1, SizeZ - 1); + const auto SameCell = [](int32 A, int32 B, int32 CellSize) + { + return FMath::FloorToInt(static_cast(A) / static_cast(CellSize)) + == FMath::FloorToInt(static_cast(B) / static_cast(CellSize)); + }; + if (!SameCell(OriginVoxels.X, Last.X, CHUNK_SIZE) + || !SameCell(OriginVoxels.Y, Last.Y, CHUNK_SIZE) + || !SameCell(OriginVoxels.Z, Last.Z, CHUNK_SIZE)) + { + return false; + } + + const int32 SampleCount = static_cast(SampleCount64); + BeginDensityBlock(OriginVoxels, 1, SizeX, SizeY, SizeZ); + GVoxelDensityBlockSession.bBatchRequested = true; + GVoxelDensityBlockSession.bBatchSucceeded = false; + GVoxelDensityBlockSession.BatchResults.SetNumUninitialized(SampleCount); + + const float FirstDensity = GetDensityAt( + static_cast(OriginVoxels.X), + static_cast(OriginVoxels.Y), + static_cast(OriginVoxels.Z)); + OutGeneralPathEntries = 1; + if (GVoxelDensityBlockSession.bBatchSucceeded) + { + OutDensities.SetNumUninitialized(SampleCount); + FMemory::Memcpy( + OutDensities.GetData(), GVoxelDensityBlockSession.BatchResults.GetData(), + static_cast(SampleCount) * sizeof(float)); + OutSupportedBlockSetupEntries = 1; + EndDensityBlock(); + return OutDensities.Num() == SampleCount; + } + + // Routes without the editor block program retain the canonical point evaluator. The first + // call already produced point zero; the remaining samples are explicit counted fallbacks. + GVoxelDensityBlockSession.bBatchRequested = false; + OutDensities.SetNumUninitialized(SampleCount); + OutDensities[0] = FirstDensity; + for (int32 Index = 1; Index < SampleCount; ++Index) + { + const int32 X = Index % SizeX; + const int32 Y = (Index / SizeX) % SizeY; + const int32 Z = Index / (SizeX * SizeY); + OutDensities[Index] = GetDensityAt( + static_cast(OriginVoxels.X + X), + static_cast(OriginVoxels.Y + Y), + static_cast(OriginVoxels.Z + Z)); + } + OutFallbackSamples = SampleCount; + OutGeneralPathEntries = static_cast(SampleCount); + EndDensityBlock(); + return true; +} +#endif + +float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity) const +{ +#if WITH_EDITOR + // Step 2e and step 2d are separate editor-only, default-off side paths. The owner field stays + // authoritative unless a research commandlet or automation test explicitly selects one. + if (VoxelForgeRedefinedDensityField::IsParameterizedEnabled()) + { + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + const float ResearchDensity = VoxelForgeRedefinedDensityField::EvaluateParameterized( + *this, WorldX, WorldY, WorldZ, OutPreEditDensity, + VoxelForgeRedefinedDensityField::GetDetailScale()); + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + return ResearchDensity; + } + if (VoxelForgeRedefinedDensityField::IsEnabled()) + { + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + const float ResearchDensity = VoxelForgeRedefinedDensityField::Evaluate( + *this, WorldX, WorldY, WorldZ, OutPreEditDensity, + VoxelForgeRedefinedDensityField::GetDetailScale()); + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + return ResearchDensity; + } +#endif + // Soundness mode: for a sample in a block whose core or passage-carving reach bit is clear, + // evaluate the complete canonical field once with the bypass bit set. The normal call then + // takes the fast path below and compares the final MC field after the boundary seal and diff + // layer—not an intermediate SDF. This is deliberately debug-only; production pays no + // recursive call and only the evaluator/tail flag reads. + const bool bCompareFinalField = VoxelGenLOD::bTilePostReachDebug + && !VoxelGenLOD::IsTilePostReachBypassActive() + && VoxelGenLOD::bTileReachDiagnosticTile + && (!VoxelGenLOD::IsTunnelCoreReachable() + || !VoxelGenLOD::IsPassageCarvingReachable()); + float CanonicalFinalField = 0.0f; + if (bCompareFinalField) + { + const bool PreviousBypass = VoxelGenLOD::bTilePostReachBypass; + VoxelGenLOD::bTilePostReachBypass = true; + CanonicalFinalField = GetDensityAt(WorldX, WorldY, WorldZ); + VoxelGenLOD::bTilePostReachBypass = PreviousBypass; + } + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GetDensityAt); + VoxelDensityProfile::FScopedTimer DensityProfileTimer( + VoxelDensityProfile::EBucket::GetDensityAt); + VoxelDensityProfile::FScopedTimer DensityPrologueTimer( + VoxelDensityProfile::EBucket::DensityPrologue); + // ── STRATE SYSTEM ── + // Query per-chunk params from the manager so each strate has different caves. + float Result = 0.0f; + + // A vertical tree connector may need to stay open through the shared disturbance and + // structural-post tail below. This is one bit for this density call, not cached voxel data. + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + + FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), + FMath::FloorToInt(WorldZ / CHUNK_SIZE) + ); + + const bool bUseChunkRoutingCache = GVoxelForgeChunkRoutingCache != 0; + thread_local uint64 CP_RouteGeneratorOwnerId = 0; + thread_local const UVoxelStrateManager* CP_RouteManager = nullptr; + thread_local uint64 CP_RouteManagerLifetimeId = 0; + thread_local uint32 CP_RouteLayoutVersion = 0xFFFFFFFFu; + thread_local FIntVector CP_RouteChunk(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local bool CP_RouteIsGapChunk = false; + thread_local ECaveGeneratorType CP_RouteGeneratorType = ECaveGeneratorType::TunnelNetwork; + thread_local bool CP_RouteUsesOperatorStack = false; + uint32 RouteLayoutVersion = 0; + uint64 RouteManagerLifetimeId = 0; + bool bIsGapChunk = false; + if (StrateManager && bUseChunkRoutingCache) + { + RouteLayoutVersion = StrateManager->GetLayoutVersion(); + RouteManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + const bool bRouteKeyChanged = CP_RouteGeneratorOwnerId != DensityCacheOwnerId + || CP_RouteManager != StrateManager + || CP_RouteManagerLifetimeId != RouteManagerLifetimeId + || CP_RouteLayoutVersion != RouteLayoutVersion + || CP_RouteChunk != ChunkCoord; + if (bRouteKeyChanged) + { + CP_RouteGeneratorOwnerId = DensityCacheOwnerId; + CP_RouteManager = StrateManager; + CP_RouteManagerLifetimeId = RouteManagerLifetimeId; + CP_RouteLayoutVersion = RouteLayoutVersion; + CP_RouteChunk = ChunkCoord; + CP_RouteIsGapChunk = StrateManager->IsGapChunk(ChunkCoord); + CP_RouteGeneratorType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); + CP_RouteUsesOperatorStack = + StrateManager->UsesOperatorStackForChunk(ChunkCoord); + } + bIsGapChunk = CP_RouteIsGapChunk; + } + else if (StrateManager) + { + bIsGapChunk = StrateManager->IsGapChunk(ChunkCoord); + } + +#if WITH_EDITOR + bool bEvaluateDensityBatch = false; + int32 BatchSampleCount = 0; + int32 DensityBatchCount = 1; + bool bBrickHasDiffMods = false; +#endif + if (StrateManager && bIsGapChunk) + { + // SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel + // through it, but the (0,0) descent stays solid here so the player digs the gap + // to reach the next layer. No caves, no spine, no vertical seal — just rock + passage + // tube/landing; + // the global XY edge seal is applied below for bounded worlds. + DensityPrologueTimer.End(); + VoxelDensityProfile::FScopedTimer DensityCoreTimer( + VoxelDensityProfile::EBucket::DensityCore); + float Density = 8.0f; // bedrock solidity (positive = solid) + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, 8.0f, 0.0f); + // Gap chunks use the compact bedrock path above instead of the ordinary strate tail. + // Native walkable passages still cross those chunks, so compose their build-authored + // D-floor here as well; otherwise the floor disappears exactly at the gap/strate seam. + Result = -Density; + StrateManager->ApplyPassageNativeFloorMC( + Result, WorldX, WorldY, WorldZ, 8.0f); + VF_ApplyXYEdgeSealMC( + Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); + } + else if (StrateManager) + { + // ── PER-CHUNK PARAM CACHE ── + // GetDensityAt runs per voxel AND ~6× more per surface vertex (gradient normals). + // The generator type, the (boundary-blended) param struct, and the disturbance + // params are identical for the whole chunk, yet resolving them re-runs a strate + // lookup + copies large structs (and a ~60-field Lerp for blended cave chunks). + // Cache them thread-locally, keyed by owner + chunk coord + layout version — refetch only + // when one of those integer identities changes. + thread_local uint64 CP_OwnerId = 0; + thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork; + thread_local FStrateGenerationParams CP_Tunnel; + // AUDIT §C2 — empreinte de `CP_Tunnel`, rafraîchie avec lui. Elle voyage jusqu'à la clé du + // cache SDF de `GetDensityWithParams` pour qu'un chunk ne puisse plus être évalué contre + // les salles d'un chunk voisin aux params blendés différemment. + thread_local uint32 CP_TunnelFP = 0xFFFFFFFFu; + thread_local FSlabGenerationParams CP_Slab; + thread_local FMazeGenerationParams CP_Maze; + thread_local FSurfaceGenerationParams CP_Surface; + thread_local FVerticalShaftParams CP_Vert; + thread_local FFloatingIslandParams CP_Float; + thread_local FStrateDisturbanceParams CP_Dist; + // Biomes (SurfaceWorld for now): CP_BiomeCtx is the cheap per-chunk flatten; + // CP_BiomeCache is the box-validated grid; CP_SurfaceBiomeParams holds each biome's + // resolved surface params (override or strate fallback) parallel to CP_BiomeCtx.Biomes. + thread_local FBiomeContext CP_BiomeCtx; + thread_local FChunkBiomeCache CP_BiomeCache; + thread_local TArray CP_SurfaceBiomeParams; + // AUDIT C2 — la clé DOIT contenir la version de layout, pas seulement le chunk. Après un + // RebuildStrates / une édition à chaud, StrateManager reconstruit le layout et bumpe la + // version ; un worker dont CP_Chunk vaut encore ce chunk sauterait le refetch et + // générerait avec les ANCIENS params. Comme RegenerateAllChunks recharge les MÊMES coords + // de tuile, souvent sur les MÊMES workers, le cas est probable plutôt qu'exotique. + // The key MUST include the layout version, not just the chunk coord. Symptom without it: + // "I tweaked the strate asset, regenerated, and one patch kept the old shape." + thread_local uint32 CP_Version = 0xFFFFFFFFu; + // The tunnel cache key also includes whether the current tile requested the coarse + // tile-window context. A cache hit moves the prepared stack/core out of these TLS values; + // when the other context misses, this bit must force the rebuild guard below to run. + thread_local bool CP_UsesTileCacheWindow = false; + // OPSTACK — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les + // params (donc même clé owner+chunk+version, aucune logique d'invalidation en plus). Vide + // seulement pour une strate dégénérée (hauteur nulle). + thread_local FVoxelOpStack CP_OpStack; + thread_local bool CP_UseOpStack = false; + thread_local uint64 CP_ManagerLifetimeId = 0; + thread_local FTunnelNetworkDensityCacheEntry CP_TunnelDensityCache[TunnelDensityCacheSlotCount]; + thread_local FTunnelNetworkDensityCacheEntry* CP_ActiveTunnelDensityCache = nullptr; + // Cooked-season recipes and editor candidate recipes share this immutable worker-local + // hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only. + thread_local bool CP_UseCustomRecipe = false; + thread_local int32 CP_CustomRecipeSeed = 0; + thread_local FVoxelStrateArchetypeParams CP_CustomParams; + thread_local FVoxelOpStackRecipe CP_CustomRecipe; +#if WITH_EDITOR + // Editor-only composer override. The candidate recipe is copied into this worker-local + // cache on the same versioned refetch as the native params; no worker reads mutable editor + // state while the world is applying a new candidate. + thread_local bool CP_UseComposerRegions = false; + thread_local int32 CP_ComposerSeed = 0; + thread_local FVoxelStrateRegionManifest CP_ComposerRegions; +#endif + + const uint32 LayoutVersion = bUseChunkRoutingCache + ? RouteLayoutVersion : StrateManager->GetLayoutVersion(); + const uint64 ManagerLifetimeId = bUseChunkRoutingCache + ? RouteManagerLifetimeId : StrateManager->GetCacheLifetimeId(); + // TunnelNetwork/Underwater operator-stack parameters are selected from chunk Z only. + // A coarse tile samples thousands of exact XY chunk keys, but those keys describe the + // same immutable stack whenever the selected archetype is operator-stack backed. Share + // that prepared state by Z; the stack still queries the actual world position, and its + // room-graph cache remains keyed by the real XY search window. Chunks outside the strate + // layout (constant air) keep the full XYZ key. + const ECaveGeneratorType QueryGeneratorType = bUseChunkRoutingCache + ? CP_RouteGeneratorType + : StrateManager->GetGeneratorTypeForChunk(ChunkCoord); + const bool bShareTunnelStackByZ = !bIsGapChunk + && (QueryGeneratorType == ECaveGeneratorType::TunnelNetwork + || QueryGeneratorType == ECaveGeneratorType::Underwater) + && (bUseChunkRoutingCache + ? CP_RouteUsesOperatorStack + : StrateManager->UsesOperatorStackForChunk(ChunkCoord)); + const FIntVector TunnelCacheKey = bShareTunnelStackByZ + ? FIntVector(0, 0, ChunkCoord.Z) : ChunkCoord; + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = 1; + int32 RequestedTileCells = 0; + const bool bTileCacheContext = VoxelGenLOD::IsTileCacheWindowEnabled( + !bShareTunnelStackByZ) + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells); + FVoxelOpStack* ActiveOpStack = &CP_OpStack; + FTunnelCoreCacheState* ActiveTunnelCoreCache = >unnelCoreCache; + bool bLoadedTunnelDensityCache = false; + FTunnelNetworkDensityCacheEntry* TunnelDensityCacheEntryForWrite = nullptr; + + // The table is open-addressed rather than one-slot direct-mapped. A direct map is very + // cheap until a coarse tile's regular lattice happens to put two chunk keys in the same + // bucket; then the two keys evict each other on every row and rebuild the prepared graph. + // Probe deterministically to the first empty/stale slot, retaining the bounded memory cap. + auto FindTunnelDensityCacheEntry = [&](const FIntVector& QueryChunk, + FTunnelNetworkDensityCacheEntry*& OutEntry) -> bool + { + const int32 StartSlot = TunnelDensityCacheSlot(TunnelCacheKey); + FTunnelNetworkDensityCacheEntry* ReuseEntry = nullptr; + for (int32 Probe = 0; Probe < TunnelDensityCacheSlotCount; ++Probe) + { + const int32 Slot = (StartSlot + Probe) & (TunnelDensityCacheSlotCount - 1); + FTunnelNetworkDensityCacheEntry& Candidate = CP_TunnelDensityCache[Slot]; + if (!Candidate.bValid || Candidate.OwnerId != DensityCacheOwnerId) + { + if (ReuseEntry == nullptr) { ReuseEntry = &Candidate; } + if (!Candidate.bValid) { break; } + continue; + } + const bool bKeyMatches = Candidate.ManagerLifetimeId == ManagerLifetimeId + && Candidate.Chunk == TunnelCacheKey + && Candidate.LayoutVersion == LayoutVersion + && Candidate.bUseOpStack == bShareTunnelStackByZ + && Candidate.bUsesTileCacheWindow == bTileCacheContext + && (Candidate.GenType == ECaveGeneratorType::TunnelNetwork + || Candidate.GenType == ECaveGeneratorType::Underwater); + if (bKeyMatches) + { + OutEntry = &Candidate; + return true; + } + } + OutEntry = ReuseEntry != nullptr + ? ReuseEntry + : &CP_TunnelDensityCache[StartSlot]; + return false; + }; + + if (!bIsGapChunk) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCacheLookup); + } + FTunnelNetworkDensityCacheEntry* CachedEntryPtr = nullptr; + const bool bCacheKeyMatches = FindTunnelDensityCacheEntry(ChunkCoord, CachedEntryPtr); + FTunnelNetworkDensityCacheEntry& CachedEntry = *CachedEntryPtr; + TunnelDensityCacheEntryForWrite = CachedEntryPtr; + if (bCacheKeyMatches) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCacheHit); + } + if (CP_ActiveTunnelDensityCache != &CachedEntry) + { + CP_OwnerId = CachedEntry.OwnerId; + CP_ManagerLifetimeId = CachedEntry.ManagerLifetimeId; + CP_Version = CachedEntry.LayoutVersion; + CP_Chunk = CachedEntry.Chunk; + CP_GenType = CachedEntry.GenType; + CP_Tunnel = CachedEntry.Tunnel; + CP_TunnelFP = CachedEntry.TunnelFingerprint; + CP_Dist = CachedEntry.Disturbance; + CP_UseOpStack = CachedEntry.bUseOpStack; + CP_UsesTileCacheWindow = CachedEntry.bUsesTileCacheWindow; + CP_UseCustomRecipe = false; +#if WITH_EDITOR + CP_UseComposerRegions = false; +#endif + CP_ActiveTunnelDensityCache = &CachedEntry; + } + ActiveOpStack = &CachedEntry.OpStack; + ActiveTunnelCoreCache = &CachedEntry.TunnelCore; + bLoadedTunnelDensityCache = true; + } + else if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCacheMiss); + } + } + + const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; + const bool bManagerChanged = ManagerLifetimeId != CP_ManagerLifetimeId; + // A cache hit may have moved CP_OpStack/GTunnelCoreCache into the shared entry. If the + // next lookup misses (including an eviction/collision for the same shared Z key), the + // fallback TLS objects are moved-from and must never be evaluated as if they were valid. + // The active-entry pointer is the explicit moved-out marker; a miss therefore forces the + // normal rebuild even when the shared key happens to equal ChunkCoord at the origin. + const bool bMovedOutTunnelState = CP_ActiveTunnelDensityCache != nullptr; + if (!bLoadedTunnelDensityCache + && (bOwnerChanged || bManagerChanged || ChunkCoord != CP_Chunk + || LayoutVersion != CP_Version + || bTileCacheContext != CP_UsesTileCacheWindow + || bMovedOutTunnelState)) + { + CP_ActiveTunnelDensityCache = nullptr; + + // La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext + // ayant servi à classer ses cellules : sur un changement de version elle est périmée + // même si la boîte couvre encore la requête. Même invalidation quand le propriétaire + // change : deux mondes peuvent partager version et coordonnées, jamais leur contexte. + // The biome grid's XY box says nothing about its context. Owner changes invalidate it + // too: two worlds may share version and coordinates, never cached params/context. + if (bOwnerChanged || bManagerChanged || LayoutVersion != CP_Version) + { + CP_BiomeCache.Invalidate(); + } + CP_OwnerId = DensityCacheOwnerId; + CP_ManagerLifetimeId = ManagerLifetimeId; + CP_Version = LayoutVersion; + CP_Chunk = ChunkCoord; + CP_UsesTileCacheWindow = bTileCacheContext; + CP_GenType = bUseChunkRoutingCache + ? CP_RouteGeneratorType + : StrateManager->GetGeneratorTypeForChunk(ChunkCoord); + CP_UseCustomRecipe = false; + bool bAllowCustomRecipe = true; +#if WITH_EDITOR + CP_UseComposerRegions = false; + if (StrateManager->GetComposerRegionOverrideForChunk( + ChunkCoord, CP_ComposerRegions)) + { + CP_UseComposerRegions = true; + bAllowCustomRecipe = false; + CP_ComposerSeed = CP_ComposerRegions.Seed; + if (CP_ComposerRegions.Regions.Num() > 0) + { + CP_GenType = CP_ComposerRegions.Regions[0].Archetype; + } + } +#endif + if (bAllowCustomRecipe + && StrateManager->GetRecipeForChunk( + ChunkCoord, CP_CustomRecipeSeed, CP_GenType, + CP_CustomParams, CP_CustomRecipe)) + { + CP_UseCustomRecipe = true; + } + switch (CP_GenType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + CP_Slab = StrateManager->GetSlabParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::Maze: + CP_Maze = StrateManager->GetMazeParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::SurfaceWorld: + ResolveSurfaceChunkParams(ChunkCoord, CP_Surface, CP_BiomeCtx, CP_SurfaceBiomeParams); + break; + case ECaveGeneratorType::VerticalShafts: + CP_Vert = StrateManager->GetVerticalShaftParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::FloatingIslands: + CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break; + default: // TunnelNetwork / Underwater + CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); + // AUDIT §C2 — l'empreinte est calculée ICI, une fois par chunk, au seul endroit où + // les params changent. `FStrateGenerationParams` est du POD pur (aucun TArray / + // FString / pointeur), donc une CRC mémoire peut donner un FAUX POSITIF par + // collision CRC32 ; au pire un octet de padding donne aussi un faux MANQUE, c'est- + // à-dire une reconstruction de cache. Le risque pratique est une collision, jamais + // une salle fausse créée par la seule différence de padding. + CP_TunnelFP = FCrc::MemCrc32(&CP_Tunnel, sizeof(CP_Tunnel)); + break; + } + CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord); + + // ── OPSTACK : (re)construire la pile de ce chunk. ── + // Toute strate est générée par une pile d'opérateurs. La construction est par chunk + // (comme le refetch de params juste au-dessus), jamais par voxel. + // Every strate is generated by an operator stack. An editor composer candidate or a + // cooked-season recipe supplies its own stack; otherwise — or if that stack cannot be + // built — the archetype's native stack is used. + CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile + CP_UseOpStack = false; +#if WITH_EDITOR + if (CP_UseComposerRegions) + { + FVoxelOpContext RegionContext; + if (VF_BuildStrateRegionStack( + CP_ComposerRegions, OriginSpineRadius, StrateManager, + CP_OpStack, RegionContext, nullptr)) + { + RegionContext.ChunkCoord = ChunkCoord; + RegionContext.Step = 1; + RegionContext.LayoutVersion = LayoutVersion; + RegionContext.WorldRadiusVoxels = WorldRadiusVoxels; + RegionContext.EdgeSealThickness = EdgeSealThickness; + CP_OpStack.PrepareChunk(RegionContext); + CP_UseOpStack = true; + } + else + { + CP_OpStack = FVoxelOpStack(); + CP_UseComposerRegions = false; + } + } +#endif + if (!CP_UseOpStack && CP_UseCustomRecipe) + { + FVoxelOpContext RecipeContext; + if (VF_BuildStackFromRecipe( + CP_CustomRecipe, CP_CustomParams, CP_CustomRecipeSeed, + OriginSpineRadius, StrateManager, CP_OpStack, RecipeContext, nullptr)) + { + RecipeContext.ChunkCoord = ChunkCoord; + RecipeContext.Step = 1; + RecipeContext.LayoutVersion = LayoutVersion; + // The recipe builder supplies the candidate's vertical seal to the + // structural post. The XY edge seal is global and uses live settings. + RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels; + RecipeContext.EdgeSealThickness = EdgeSealThickness; + CP_OpStack.PrepareChunk(RecipeContext); + CP_UseOpStack = true; + } + else + { + // Import/load validates recipes before a season becomes active. If a future + // schema still fails here, fall back to the archetype's native stack below. + CP_OpStack = FVoxelOpStack(); + CP_UseCustomRecipe = false; + } + } + if (!CP_UseOpStack) + { + FVoxelOpContext OpCtx; + OpCtx.ChunkCoord = ChunkCoord; + OpCtx.Seed = (uint32)Seed; + OpCtx.LayoutVersion = LayoutVersion; + OpCtx.WorldRadiusVoxels = WorldRadiusVoxels; + OpCtx.EdgeSealThickness = EdgeSealThickness; + + // ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE + // PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu + // d'une pile construite autrement serait un trou. Ici on ne fait que fournir les + // params déjà cherchés juste au-dessus. + FVoxelStackParamRefs Refs; + Refs.Slab = &CP_Slab; + Refs.Maze = &CP_Maze; + Refs.Vert = &CP_Vert; + Refs.Float = &CP_Float; + Refs.Tunnel = &CP_Tunnel; + + // SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le + // générateur, et TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField`, + // ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur. + TArray PerBiome; + if (CP_GenType == ECaveGeneratorType::SurfaceWorld) + { + Refs.Surface = &CP_Surface; + if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) + { + PerBiome = CP_SurfaceBiomeParams; + Refs.SurfaceBiomeParams = &PerBiome; + Refs.BiomeField = MakeUnique( + this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z); + } + } + + CP_UseOpStack = VF_BuildOpStackForChunk( + CP_GenType, Refs, Seed, OriginSpineRadius, + StrateManager, CP_OpStack, OpCtx); + + // PrepareChunk runs here exactly as in the tests, so an op that hoists real + // per-chunk work behaves the same in game. `Step` reste 1 : GetDensityAt ne connaît + // pas le pas d'échantillonnage du mesher (voir le contrat T2.b dans VoxelDensityOp.h). + if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); } + } + + // An operator-stack tunnel source publishes the world-space core after EvalMC, so + // preparing a second full graph/core cache here would only duplicate the work that made + // coarse streaming expensive in the first place. Only an editor composer region stack, + // which does not publish that hand-off, still gets the prepared native core. + const bool bNativeTunnelCore = !CP_UseCustomRecipe + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater); +#if WITH_EDITOR + const bool bCanPrepareNativeTunnelCore = bNativeTunnelCore + && (!CP_UseOpStack || CP_UseComposerRegions); +#else + const bool bCanPrepareNativeTunnelCore = bNativeTunnelCore && !CP_UseOpStack; +#endif + if (bCanPrepareNativeTunnelCore) + { + PrepareTunnelCoreCache( + *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP, + LayoutVersion, ManagerLifetimeId, DensityCacheOwnerId, GTunnelCoreCache); + } + else + { + GTunnelCoreCache.bValid = false; + } + + const bool bNativeTunnelCacheCandidate = !CP_UseCustomRecipe + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater); +#if WITH_EDITOR + const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate + && !CP_UseComposerRegions + // A native direct core uses the worker-global state below. Keeping it in one + // per-exact-chunk entry would move the tile graph out and force the next chunk to + // rebuild it. Operator stacks do not prepare this native core, so they retain the + // existing prepared-stack cache even when the tile context is active. + && (!bTileCacheContext || CP_UseOpStack); +#else + const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate + && (!bTileCacheContext || CP_UseOpStack); +#endif + if (!bLoadedTunnelDensityCache && bCanStoreTunnelDensityCache) + { + FTunnelNetworkDensityCacheEntry& CachedEntry = TunnelDensityCacheEntryForWrite + ? *TunnelDensityCacheEntryForWrite + : CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; + CachedEntry.bValid = false; + CachedEntry.OwnerId = DensityCacheOwnerId; + CachedEntry.ManagerLifetimeId = ManagerLifetimeId; + CachedEntry.Chunk = TunnelCacheKey; + CachedEntry.LayoutVersion = LayoutVersion; + CachedEntry.GenType = CP_GenType; + CachedEntry.Tunnel = CP_Tunnel; + CachedEntry.TunnelFingerprint = CP_TunnelFP; + CachedEntry.Disturbance = CP_Dist; + CachedEntry.bUseOpStack = CP_UseOpStack; + CachedEntry.bUsesTileCacheWindow = bTileCacheContext; + CachedEntry.OpStack = MoveTemp(CP_OpStack); + CachedEntry.TunnelCore = MoveTemp(GTunnelCoreCache); + GTunnelCoreCache = FTunnelCoreCacheState(); + CachedEntry.bValid = true; + CP_ActiveTunnelDensityCache = &CachedEntry; + ActiveOpStack = &CachedEntry.OpStack; + ActiveTunnelCoreCache = &CachedEntry.TunnelCore; + ReportTunnelDensityCacheFootprint(CP_TunnelDensityCache); + } + else if (!bLoadedTunnelDensityCache) + { + ActiveOpStack = &CP_OpStack; + ActiveTunnelCoreCache = >unnelCoreCache; + } + + } + + DensityPrologueTimer.End(); + VoxelDensityProfile::FScopedTimer DensityCoreTimer( + VoxelDensityProfile::EBucket::DensityCore); + + bool bUsedOpBlockSample = false; + float BlockDensity = 0.f; + bool bBlockHasTunnelCore = false; + float BlockTunnelCoreSDF = FLT_MAX; + bool bBlockTunnelCoreRoomFloor = false; + bool bBlockHasTunnelCoreSweptFloor = false; + float BlockTunnelCoreSweptFloorZ = -FLT_MAX; + float BlockTunnelCoreSweptFloorRadius = 0.0f; + bool bUsedFusedEvaluator = false; + FTunnelCoreWorldEvaluation FusedTunnelCore; + bool bRoomOwnsBottom = false; + bool bRoomOwnsBottomValid = false; + + // Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention + // MC (négatif = solide), que les disturbances et la couche de diff qui suivent attendent. + if (CP_UseOpStack) + { + const bool bCanUseFusedEvaluator = + GVoxelForgeUseFusedEvaluator != 0 + && !CP_UseCustomRecipe + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater) +#if WITH_EDITOR + && !CP_UseComposerRegions +#endif + && ActiveOpStack->GetFusedEvaluator() + == EVoxelOpFusedEvaluator::TunnelNetwork; + if (bCanUseFusedEvaluator) + { + // This is the lowered canonical graph. It returns the same MC core as the + // interpreted stack, plus the source's structural hand-off, without allocating + // FVoxelOpSample or traversing the graph one operator at a time for this voxel. + bUsedFusedEvaluator = true; + VoxelDensityProfile::FScopedTimer FusedEvaluatorTimer( + VoxelDensityProfile::EBucket::FusedEvaluator); + Result = GetDensityWithParams( + WorldX, WorldY, WorldZ, CP_Tunnel, CP_TunnelFP, LayoutVersion, + /*bApplyLegacyStructuralPosts=*/false, + &FusedTunnelCore, + /*bCollectFusedDiagnostics=*/true, + &bRoomOwnsBottom); + bRoomOwnsBottomValid = true; + } + + const bool bIntegerLatticePoint = + WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY) + && WorldZ == FMath::FloorToFloat(WorldZ); + const bool bBlockSafeGenerator = + CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater; + if (!bUsedFusedEvaluator && bIntegerLatticePoint && bBlockSafeGenerator + && GVoxelDensityBlockSession.bActive) + { + // Keep the rich hand-off object in the fallback branch. The lowered evaluator + // must not construct a per-sample FVoxelOpSample merely because this compatibility + // path still needs one when the block session is enabled. + FVoxelOpSample BlockCoreSample; + { + VoxelDensityProfile::FScopedTimer OperatorBlockTimer( + VoxelDensityProfile::EBucket::OperatorBlock); + bUsedOpBlockSample = GVoxelDensityBlockSession.FillChunk( + *ActiveOpStack, + ChunkCoord, + FIntVector( + FMath::RoundToInt(WorldX), + FMath::RoundToInt(WorldY), + FMath::RoundToInt(WorldZ)), + BlockCoreSample); + } + if (bUsedOpBlockSample) + { + BlockDensity = BlockCoreSample.Density; + bBlockHasTunnelCore = BlockCoreSample.bHasTunnelCoreWorldEvaluation; + BlockTunnelCoreSDF = BlockCoreSample.TunnelCoreWorldSDF; + bBlockTunnelCoreRoomFloor = BlockCoreSample.bTunnelCoreRoomFloor; + bBlockHasTunnelCoreSweptFloor = + BlockCoreSample.bHasTunnelCoreSweptFloor; + BlockTunnelCoreSweptFloorZ = + BlockCoreSample.TunnelCoreSweptFloorZ; + BlockTunnelCoreSweptFloorRadius = + BlockCoreSample.TunnelCoreSweptFloorRadius; + } + } + if (!bUsedFusedEvaluator) + { + if (bUsedOpBlockSample) + { + Result = -BlockDensity; + } + else + { + VoxelDensityProfile::FScopedTimer InterpretedOpStackTimer( + VoxelDensityProfile::EBucket::InterpretedOpStack); + Result = ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ); + } + } + } + else + { + // Only a degenerate strate (zero height) has no stack. It is open air, like the + // archetype functions the stack replaced. + Result = 1.0f; + } + DensityCoreTimer.End(); + +#if WITH_EDITOR + // The editor region program asks this one routed sample to fill every point in the + // already-evaluated operator block. Its bricks are contained by one 8-voxel research + // bin and one 32-voxel chunk, so the stack, params, route, and cache key above are shared. + BatchSampleCount = GVoxelDensityBlockSession.SizeX + * GVoxelDensityBlockSession.SizeY * GVoxelDensityBlockSession.SizeZ; + bEvaluateDensityBatch = GVoxelDensityBlockSession.bBatchRequested + && bUsedOpBlockSample && !bUsedFusedEvaluator && CP_UseOpStack + && !bIsGapChunk && StrateManager != nullptr + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater) + && GVoxelDensityBlockSession.Samples.Num() == BatchSampleCount + && GVoxelDensityBlockSession.BatchResults.Num() == BatchSampleCount; + DensityBatchCount = bEvaluateDensityBatch ? BatchSampleCount : 1; + bool bBrickHasDisturbanceCandidate = true; + bool bBrickHasPassagePostCandidate = true; + if (bEvaluateDensityBatch) + { + const FIntVector BrickLast = GVoxelDensityBlockSession.Origin + FIntVector( + (GVoxelDensityBlockSession.SizeX - 1) * GVoxelDensityBlockSession.Step, + (GVoxelDensityBlockSession.SizeY - 1) * GVoxelDensityBlockSession.Step, + (GVoxelDensityBlockSession.SizeZ - 1) * GVoxelDensityBlockSession.Step); + const FBox BrickBox( + FVector(GVoxelDensityBlockSession.Origin.X, + GVoxelDensityBlockSession.Origin.Y, + GVoxelDensityBlockSession.Origin.Z), + FVector(BrickLast.X, BrickLast.Y, BrickLast.Z)); + bBrickHasDisturbanceCandidate = !VoxelDensityAblation::IsDisturbancesOff() + && VF_AnyDisturbanceCanTouchBox( + BrickBox, CP_Dist, static_cast(Seed)); + + const bool bAllPassagePostsOff = + VoxelDensityAblation::IsPassageStructuralPostsOff() + && VoxelDensityAblation::IsNativeFloorOff() + && VoxelDensityAblation::IsLandingPostsOff(); + if (bAllPassagePostsOff) + { + bBrickHasPassagePostCandidate = false; + } + else if (!VoxelDensityAblation::IsPassageCarvingOff()) + { + bBrickHasPassagePostCandidate = StrateManager->AnyPassageNearBox( + BrickBox.Min, BrickBox.Max); + } + bBrickHasDiffMods = DiffLayer != nullptr && DiffLayer->HasAnyMods(); + } + const bool bApplyBrickDisturbances = + !bEvaluateDensityBatch || bBrickHasDisturbanceCandidate; + const bool bApplyBrickPassagePosts = + !bEvaluateDensityBatch || bBrickHasPassagePostCandidate; +#else + const bool bApplyBrickDisturbances = true; + const bool bApplyBrickPassagePosts = true; +#endif + // Chunk parameters and composer/custom recipe structure are fixed by the route above. + // Resolve the landing writer's base once for the whole brick, including edge bricks. + float BrickLandingBaseDensity = CP_Dist.BaseDensity; +#if WITH_EDITOR + if (CP_UseComposerRegions && CP_ComposerRegions.bHasGlobalStructuralParams) + { + BrickLandingBaseDensity = CP_ComposerRegions.BaseDensity; + } + else +#endif + if (CP_UseCustomRecipe) + { + switch (CP_CustomRecipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: + BrickLandingBaseDensity = CP_CustomParams.TunnelNetworkParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Slab: + BrickLandingBaseDensity = CP_CustomParams.SlabParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Maze: + BrickLandingBaseDensity = CP_CustomParams.MazeParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Surface: + BrickLandingBaseDensity = CP_CustomParams.SurfaceParams.BaseDensity; + break; + case EVoxelStrateParamBlock::VerticalShaft: + BrickLandingBaseDensity = CP_CustomParams.VerticalShaftParams.BaseDensity; + break; + case EVoxelStrateParamBlock::FloatingIsland: + BrickLandingBaseDensity = CP_CustomParams.FloatingIslandParams.BaseDensity; + break; + default: + break; + } + } + else + { + switch (CP_GenType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + BrickLandingBaseDensity = CP_Slab.BaseDensity; + break; + case ECaveGeneratorType::Maze: + BrickLandingBaseDensity = CP_Maze.BaseDensity; + break; + case ECaveGeneratorType::SurfaceWorld: + BrickLandingBaseDensity = CP_Surface.BaseDensity; + break; + case ECaveGeneratorType::VerticalShafts: + BrickLandingBaseDensity = CP_Vert.BaseDensity; + break; + case ECaveGeneratorType::FloatingIslands: + BrickLandingBaseDensity = CP_Float.BaseDensity; + break; + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + default: + BrickLandingBaseDensity = CP_Tunnel.BaseDensity; + break; + } + } + for (int32 DensityBatchIndex = 0; +#if WITH_EDITOR + DensityBatchIndex < DensityBatchCount; +#else + DensityBatchIndex < 1; +#endif + ++DensityBatchIndex) + { +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + const int32 BatchX = DensityBatchIndex % GVoxelDensityBlockSession.SizeX; + const int32 BatchY = (DensityBatchIndex / GVoxelDensityBlockSession.SizeX) + % GVoxelDensityBlockSession.SizeY; + const int32 BatchZ = DensityBatchIndex + / (GVoxelDensityBlockSession.SizeX * GVoxelDensityBlockSession.SizeY); + WorldX = static_cast(GVoxelDensityBlockSession.Origin.X + + BatchX * GVoxelDensityBlockSession.Step); + WorldY = static_cast(GVoxelDensityBlockSession.Origin.Y + + BatchY * GVoxelDensityBlockSession.Step); + WorldZ = static_cast(GVoxelDensityBlockSession.Origin.Z + + BatchZ * GVoxelDensityBlockSession.Step); + + const FVoxelOpSample& BatchSample = + GVoxelDensityBlockSession.Samples[DensityBatchIndex]; + Result = -BatchSample.Density; + bUsedOpBlockSample = true; + bBlockHasTunnelCore = BatchSample.bHasTunnelCoreWorldEvaluation; + BlockTunnelCoreSDF = BatchSample.TunnelCoreWorldSDF; + bBlockTunnelCoreRoomFloor = BatchSample.bTunnelCoreRoomFloor; + bBlockHasTunnelCoreSweptFloor = BatchSample.bHasTunnelCoreSweptFloor; + BlockTunnelCoreSweptFloorZ = BatchSample.TunnelCoreSweptFloorZ; + BlockTunnelCoreSweptFloorRadius = BatchSample.TunnelCoreSweptFloorRadius; + bUsedFusedEvaluator = false; + bRoomOwnsBottomValid = false; + } +#endif + // Resolve the graph's authored floor before the disturbance pass. The operator-stack + // source has already computed this exact result while publishing its sample; native + // tunnel generation uses the prepared immutable cache. Keeping the result here means the + // disturbance layer can respect the shape-level floor and the common tail can reuse the + // same query instead of discovering a floor only after it has been damaged. + FTunnelCoreWorldEvaluation PreDisturbanceTunnelCore; + bool bHavePreDisturbanceTunnelCore = false; + if (CP_UseOpStack) + { + if (bUsedFusedEvaluator) + { + PreDisturbanceTunnelCore = FusedTunnelCore; + bHavePreDisturbanceTunnelCore = true; + } + else if (bUsedOpBlockSample && bBlockHasTunnelCore) + { + PreDisturbanceTunnelCore.SDF = BlockTunnelCoreSDF; + PreDisturbanceTunnelCore.bRoomFloor = bBlockTunnelCoreRoomFloor; + PreDisturbanceTunnelCore.bHasSweptFloor = + bBlockHasTunnelCoreSweptFloor; + PreDisturbanceTunnelCore.SweptFloorZ = BlockTunnelCoreSweptFloorZ; + PreDisturbanceTunnelCore.SweptFloorRadius = + BlockTunnelCoreSweptFloorRadius; + bHavePreDisturbanceTunnelCore = true; + } + else + { + bHavePreDisturbanceTunnelCore = ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation( + PreDisturbanceTunnelCore); + } + } + if (!VoxelDensityAblation::IsTunnelCoreOff() + && !bHavePreDisturbanceTunnelCore && ActiveTunnelCoreCache->bValid) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCorePosts); + FVector RoomQueryPosition(WorldX, WorldY, WorldZ); + if (CP_Tunnel.VerticalScale != 1.0f && CP_Tunnel.VerticalScale > 0.0f) + { + RoomQueryPosition.Z = WorldZ / CP_Tunnel.VerticalScale; + } + RoomQueryPosition = VoxelCaveMorphology::ApplyCaveWarp( + RoomQueryPosition, CP_Tunnel, static_cast(Seed)); + PreDisturbanceTunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, nullptr, + VoxelGenLOD::ShouldUseSpatialIndex( + CP_UseOpStack ? bUsedFusedEvaluator : true), + &RoomQueryPosition, + bRoomOwnsBottomValid ? &bRoomOwnsBottom : nullptr); + bHavePreDisturbanceTunnelCore = true; + } + + // Disturbance layer (the "wow" post-process) — cached params, MC convention. The + // vertical shaft tree opens a walkable route before this shared pass, so a bridge/ridge + // must not refill it. Both native and operator-stack paths use the same marker helper. + VoxelDensityProfile::FScopedTimer DensityDisturbancesTimer( + VoxelDensityProfile::EBucket::DensityDisturbances); + const bool bProtectVerticalShaftAir = + VoxelPassageGeometry::VerticalShaftConnectorAirMarker(); + if (bApplyBrickDisturbances) + { + ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed, + bProtectVerticalShaftAir); + } + DensityDisturbancesTimer.End(); + + // A disturbance is allowed to add visual detail, but it must not refill the landing's + // measured air volume or turn its support slab back into a hole. Reassert the landing + // air first, then the structural floor, in MC space before the final XY seal. The two + // calls use the same landing geometry already evaluated by the op-stack structural post. + VoxelDensityProfile::FScopedTimer DensityStructuralPostsTimer( + VoxelDensityProfile::EBucket::DensityStructuralPosts); + VoxelDensityProfile::FScopedTimer StructuralTailTimer( + VoxelDensityProfile::EBucket::StructuralTail); + const float LandingBaseDensity = BrickLandingBaseDensity; + auto ApplyTunnelCoreShape = [&](const FTunnelCoreWorldEvaluation& Core) + { + if (!VoxelMath::IsFinite(Core.SDF)) + { + return; + } + const bool bOwnsSweptBottom = + !Core.bRoomFloor + && + Core.bHasSweptFloor + && VoxelMath::IsFinite(Core.SweptFloorZ) + && VoxelMath::IsFinite(Core.SweptFloorRadius) + && WorldZ <= Core.SweptFloorZ + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; + if (bOwnsSweptBottom) + { + Result = FMath::Min(Result, -Core.SDF); + } + else if (Core.SDF < 0.0f) + { + Result = FMath::Max(Result, -Core.SDF); + } + }; + // Origin rooms are structural too. Reassert their air before either support writer so a + // bridge/ridge cannot plug a landing, while the two floor writers remain last. + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingAir); + VF_ApplyOriginLandingAirMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + } + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + } + if (bApplyBrickPassagePosts) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageStructuralPosts); + StrateManager->ApplyPassageStructuralPostsMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity, + CP_Dist.BoundarySealThickness); + } + // Disturbance features are authored as a generic MC-space post and may add a ridge or + // bridge over a graph tunnel. Reassert the native cached tunnel core here, after every + // solid floor writer but before the global XY seal. This cache is built once per chunk, + // never once per voxel. + VoxelGenLOD::FScopedReachCost TunnelCoreTailReachCost( + VoxelGenLOD::ETileReachCostKind::TunnelCoreTail); + const bool bTunnelCoreReachable = VoxelGenLOD::IsTunnelCoreReachable(); + const bool bTunnelCoreBypass = VoxelGenLOD::IsTilePostReachBypassActive(); + auto ApplyGraphLandingFloors = [&]() + { + // These are passage/origin landing posts, not tunnel-core writes. Their air and + // structural post already ran before disturbances; the tunnel shape below reopens + // its own continuous interior after the disturbance pass. Keep only the floor + // reassertions here, in the existing order. + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + } + if (bApplyBrickPassagePosts) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + StrateManager->ApplyPassageLandingFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } + if (bApplyBrickPassagePosts) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingRoomFloor); + StrateManager->ApplyPassageLandingRoomFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } + }; + if (!VoxelDensityAblation::IsTunnelCoreOff() + && (ActiveTunnelCoreCache->bValid || CP_UseOpStack)) + { + FTunnelCoreWorldEvaluation TunnelCore; + bool bHaveTunnelCore = bHavePreDisturbanceTunnelCore; + if (VoxelGenLOD::bTileCoreReachProofEnabled + && VoxelGenLOD::bTileReachDiagnosticTile + && !bTunnelCoreReachable && !bTunnelCoreBypass) + { + // The fused/source hand-off is also empty on the skipped path. Do not let the + // default object turn this into a second tail evaluation. + bHaveTunnelCore = false; + } + if (bHaveTunnelCore) + { + TunnelCore = PreDisturbanceTunnelCore; + } + if (!bHaveTunnelCore && ActiveTunnelCoreCache->bValid + && (bTunnelCoreReachable || bTunnelCoreBypass)) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCorePosts); + FVector RoomQueryPosition(WorldX, WorldY, WorldZ); + if (CP_Tunnel.VerticalScale != 1.0f && CP_Tunnel.VerticalScale > 0.0f) + { + RoomQueryPosition.Z = WorldZ / CP_Tunnel.VerticalScale; + } + RoomQueryPosition = VoxelCaveMorphology::ApplyCaveWarp( + RoomQueryPosition, CP_Tunnel, static_cast(Seed)); + TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, + nullptr, + VoxelGenLOD::ShouldUseSpatialIndex( + CP_UseOpStack ? bUsedFusedEvaluator : true), + &RoomQueryPosition, + bRoomOwnsBottomValid ? &bRoomOwnsBottom : nullptr); + bHaveTunnelCore = true; + } + if (bHaveTunnelCore) + { + // Reassert the actual swept-core field after generic writers. The old tail used a + // fixed positive air value once the core was deeper than an inset; that hard + // switch discarded the SDF magnitude and made marching-cubes intersections snap + // to the sample lattice. The source now contains the same continuous world-chain + // tunnel field. Union its interior air back over disturbances, and use the same + // signed field only on the bottom side identified by the swept shape itself. This + // restores the tunnel's carved floor when the surrounding room is already air, + // without adding an authored slab or turning the whole room into tunnel geometry. + ApplyTunnelCoreShape(TunnelCore); + + } + if (bHaveTunnelCore) + { + if (!TunnelCore.bRoomFloor) + { + ApplyGraphLandingFloors(); + } + } + else if (!bTunnelCoreReachable && !bTunnelCoreBypass) + { + ApplyGraphLandingFloors(); + } + } + // Inter-strate passages own the same final D-floor contract as graph tunnels. This is + // composed from the immutable construction-time profile after generic writers and the + // graph overlap, so the legacy passage support slab can be disabled without losing the + // floor to a disturbance refill. + if (bApplyBrickPassagePosts) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); + StrateManager->ApplyPassageNativeFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } + StructuralTailTimer.End(); + DensityStructuralPostsTimer.End(); +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + GVoxelDensityBlockSession.BatchResults[DensityBatchIndex] = Result; + } +#endif + } + } + else + { + // ── FALLBACK (no strate manager) ── + // Use default TunnelNetwork params — produces generic caves. + // `static` : ces params sont constants (construction par défaut), donc leur empreinte l'est + // aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin. + // `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse + // périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres. + DensityPrologueTimer.End(); + VoxelDensityProfile::FScopedTimer DensityCoreTimer( + VoxelDensityProfile::EBucket::DensityCore); + static const FStrateGenerationParams FallbackParams; + static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams)); + Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0); + DensityCoreTimer.End(); + } + +#if WITH_EDITOR + const int32 FinalDensityBatchCount = bEvaluateDensityBatch ? BatchSampleCount : 1; +#else + const int32 FinalDensityBatchCount = 1; +#endif + for (int32 FinalDensityBatchIndex = 0; + FinalDensityBatchIndex < FinalDensityBatchCount; + ++FinalDensityBatchIndex) + { +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + const int32 BatchX = FinalDensityBatchIndex % GVoxelDensityBlockSession.SizeX; + const int32 BatchY = (FinalDensityBatchIndex / GVoxelDensityBlockSession.SizeX) + % GVoxelDensityBlockSession.SizeY; + const int32 BatchZ = FinalDensityBatchIndex + / (GVoxelDensityBlockSession.SizeX * GVoxelDensityBlockSession.SizeY); + WorldX = static_cast(GVoxelDensityBlockSession.Origin.X + + BatchX * GVoxelDensityBlockSession.Step); + WorldY = static_cast(GVoxelDensityBlockSession.Origin.Y + + BatchY * GVoxelDensityBlockSession.Step); + WorldZ = static_cast(GVoxelDensityBlockSession.Origin.Z + + BatchZ * GVoxelDensityBlockSession.Step); + Result = GVoxelDensityBlockSession.BatchResults[FinalDensityBatchIndex]; + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + } +#endif + // The edge is the final generated structural invariant. Passage carving is ordered before it + // in every archetype/stack, and this MC-facing pass also covers out-of-layout air plus any + // disturbance that might otherwise carve back into the shell. Player edits remain separate + // below and are deliberately still the user override path. + VoxelDensityProfile::FScopedTimer DensityBoundarySealTimer( + VoxelDensityProfile::EBucket::DensityBoundarySeal); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::XYEdgeSealOp); + VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); + } + DensityBoundarySealTimer.End(); + + //========================================================================= + // PLAYER MODIFICATIONS (diff layer) + //========================================================================= + // Applied LAST — player carving/filling overrides everything. + // The diff layer returns density in internal convention (negative = carve). + // Since Result is already in MC convention (negative = solid, positive = air), + // we subtract the diff offset: + // Carve (diff < 0) → Result -= negative → Result increases → more air ✓ + // Fill (diff > 0) → Result -= positive → Result decreases → more solid ✓ + VoxelDensityProfile::FScopedTimer DensityDiffLayerTimer( + VoxelDensityProfile::EBucket::DensityDiffLayer); + // The sync-remesh cache keeps the exact procedural field after every structural post, but + // before player edits. The cached lattice is replayed with the current DiffLayer snapshot so + // a carve sees the same subtraction order and float result as this canonical path. + if (OutPreEditDensity != nullptr) + { + *OutPreEditDensity = Result; + } + const bool bDiffLayerHasModsForSample = DiffLayer != nullptr && +#if WITH_EDITOR + (bEvaluateDensityBatch ? bBrickHasDiffMods : DiffLayer->HasAnyMods()); +#else + DiffLayer->HasAnyMods(); +#endif + if (DiffLayer && bDiffLayerHasModsForSample) + { + // ── PER-CHUNK MOD SNAPSHOT ── + // The old HasModifications + GetDensityOffset pair took the diff layer's RWLock + a TMap find + // TWICE per voxel once any carve existed (~86k lock ops per tile task — during carve gameplay, + // exactly when re-mesh latency matters). Snapshot a chunk's mod list ONCE per (chunk, version) + // on this worker and evaluate it lock-free. DIRECT-MAPPED by the chunk coord's low bits (2 bits + // per axis → 64 slots): the mesher's ±1 margin ring touches up to 27 neighbouring chunk coords + // per tile, and any two coords within ±3 of each other land in DIFFERENT slots — so a tile task + // never thrashes its own working set. Version bump (carve / clear) invalidates lazily per slot. + struct FDiffSlot + { + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint64 OwnerId = 0; + uint32 Version = 0; + TArray Mods; + }; + static thread_local FDiffSlot DiffSlots[64]; + + const uint64 DiffLayerOwnerId = DiffLayer->GetCacheLifetimeId(); + const uint32 V = DiffLayer->GetModsVersion(); + FDiffSlot& Slot = DiffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)]; + if (Slot.OwnerId != DiffLayerOwnerId || Slot.Chunk != ChunkCoord || Slot.Version != V) + { + Slot.OwnerId = DiffLayerOwnerId; + Slot.Chunk = ChunkCoord; + Slot.Version = V; + DiffLayer->GetChunkModsSnapshot(ChunkCoord, Slot.Mods); // ONE lock per chunk, not per voxel + } + if (Slot.Mods.Num() > 0) + { + Result -= UVoxelDiffLayer::EvaluateMods(Slot.Mods, WorldX, WorldY, WorldZ); + } + } + DensityDiffLayerTimer.End(); + +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + GVoxelDensityBlockSession.BatchResults[FinalDensityBatchIndex] = Result; + } +#endif + } + +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + GVoxelDensityBlockSession.bBatchSucceeded = true; + } +#endif + + VoxelDensityProfile::FScopedTimer DensityTailTimer( + VoxelDensityProfile::EBucket::DensityTail); + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + if (bCompareFinalField) + { + const bool bFinalFieldDifferent = + !VoxelGenLOD::SameFloatBits(Result, CanonicalFinalField); + VoxelGenLOD::RecordSkippedPostComparison( + VoxelGenLOD::ETilePostComparisonKind::FinalField, + bFinalFieldDifferent); + } + Result = FStandaloneResearchTunnels::Apply( + Result,WorldX,WorldY,WorldZ,static_cast(Seed)); + return Result; +} + +float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params, + uint32 ParamsFingerprint, uint32 LayoutVersion, + bool bApplyLegacyStructuralPosts, + FTunnelCoreWorldEvaluation* OutTunnelCore, + bool bCollectFusedDiagnostics, + bool* OutRoomOwnsBottom) const +{ + //========================================================================= + // STRATE DENSITY FUNCTION (Morphology Pipeline) + //========================================================================= + // The density pipeline for underground caves: + // + // 1. Vertical scale + // 2. Base density (everything starts solid) + // 3. Cave warp: domain warp coordinates before SDF (bends rooms/tunnels organically) + // 4. SDF morphology: rooms + tunnels carve the cave structure (using warped coords) + // 4b. Surface roughness: 3D noise near cave walls (fBM/Ridged/Cellular + domain warp) + // 4c. Terrain operations: terracing, layer lines, ribbing, cliff, scallop, arch, overhangs + // 4d. Columns/Pillars: hash-placed vertical cylinders adding solid rock + // 4e. Pits/Shafts: hash-placed tapered vertical voids carved into cave floors + // 4f. Chimneys/Shafts: hash-placed tapered vertical voids carved UPWARD + // 4g. Domes: hemispherical ceiling sculpting in cave chambers + // 4h. Pinch/Bottleneck: ellipsoidal passage narrowing for chokepoints + // 5. Worm tunnels: additional organic connectivity + // 6. Boundary seal: solid rock at strate top/bottom + // 7. Modifiers: passages between strates (punch through vertical seals) + // 8. XY edge seal: global bounded-world shell wins over passages at the rim + // + // Convention: positive density = solid, negative = air (internally). + // At the end, we negate for the MC table (negative = solid there). + //========================================================================= + + if (OutTunnelCore != nullptr) + { + *OutTunnelCore = FTunnelCoreWorldEvaluation(); + } + if (OutRoomOwnsBottom != nullptr) + { + *OutRoomOwnsBottom = false; + } + if (bCollectFusedDiagnostics && VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::FusedTunnelSamples); + } + + const uint32 SeedU = (uint32)Seed; + + //========================================================================= + // STEP 1: VERTICAL SCALE + //========================================================================= + float EffectiveZ = WorldZ; + if (Params.VerticalScale != 1.0f && Params.VerticalScale > 0.0f) + { + EffectiveZ = WorldZ / Params.VerticalScale; + } + + //========================================================================= + // STEP 2: BASE DENSITY (everything starts solid) + //========================================================================= + float Density = Params.BaseDensity; + + //========================================================================= + // STEP 3: CAVE WARP (domain warp the SDF skeleton) + //========================================================================= + // This is the KEY step that makes caves look natural instead of graph-like. + // + // The SDF morphology places rooms as geometric primitives (ellipsoids, boxes) + // connected by straight capsule tunnels. Without warping, you can clearly see + // the room-corridor-room graph structure — it looks artificial. + // + // By warping the world coordinates BEFORE evaluating the SDF, we bend the + // entire cave field. Rooms become irregular blobs, tunnels become winding + // passages. The skeleton is still there as the backbone, but noise pushes + // and pulls it into shapes that look carved by geological forces. + // + // Two octaves: + // - Large: CaveWarpFrequency → broad sweeping bends (room-scale) + // - Medium: 3x frequency, 0.3x strength → wall-scale irregularity + // + // IMPORTANT: Only the SDF query uses warped coordinates. Roughness, terrain + // ops, and hash-placed features use the REAL world position so they stay + // geologically correct (terracing stays horizontal, columns stay vertical, etc.) + float WarpedX = WorldX; + float WarpedY = WorldY; + float WarpedZ = EffectiveZ; + + if (!VoxelDensityAblation::IsCaveWarpOff() && Params.CaveWarpStrength > 0.0f) + { + const float WF = Params.CaveWarpFrequency; + const float WS = Params.CaveWarpStrength; + + // Three independent Perlin fields offset by irrational-ish numbers keep the X/Y/Z warp + // channels decorrelated. Perlin3D_x3 evaluates these same three scalar channels in one + // SIMD batch on x86; its non-SIMD fallback is the original three-call expression. + float WarpX, WarpY, WarpZ; + VoxelNoise::Perlin3D_x3( + WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f), + WorldY * WF + 1.3f, + EffectiveZ * WF + 5.7f, + WorldX * WF + 7.1f, + WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f), + EffectiveZ * WF + 2.3f, + WorldX * WF + 11.3f, + WorldY * WF + 9.7f, + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f), + WarpX, WarpY, WarpZ); + WarpedX += WarpX * VOXEL_NOISE_SCALE * WS; + WarpedY += WarpY * VOXEL_NOISE_SCALE * WS; + WarpedZ += WarpZ * VOXEL_NOISE_SCALE * WS; + } + + //========================================================================= + // STEP 4: SDF MORPHOLOGY (rooms + tunnels) — CACHED PER CHUNK + //========================================================================= + // The room list and tunnel connections are IDENTICAL for all voxels in a + // chunk. Without caching, we rebuild them 32,768 times (32³ voxels). + // With thread_local caching: build once, evaluate 32K times with just SDF math. + // + // The cache is keyed on (ChunkX, ChunkY, StrateIndex, Seed). When the key + // changes (new chunk or strate), the cache is rebuilt. thread_local ensures + // each task thread has its own cache — no locking needed. + // + // The warped position is used for SDF evaluation (bends rooms/tunnels), + // but the cache search area is expanded by CaveWarpStrength to ensure + // all reachable rooms are included regardless of warp displacement. + // + // Declared before the RoomDensity if-block so SDFCache and NearestRoomIdx + // are also visible to the terrain ops block further below. + thread_local FChunkSDFCache SDFCache; + // Cache validity is tracked by the SEARCH BOX the cache was built for, NOT by chunk + // equality. Gradient-normal sampling queries at WorldX±1 (and warp displacement) can + // step a voxel outside the chunk; with chunk-equality keying that flipped the key and + // rebuilt the (now expensive) cache every boundary cell. Since the stored rooms cover + // the search box + MaxInfluence, any query INSIDE the box is correct — so we only + // rebuild when the query actually leaves the box. Result: one build per chunk, no thrash. + thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // start invalid (min > max) + thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f; + thread_local int32 CachedStrate = INT32_MIN; + thread_local uint32 CachedSeed = 0; + // ⚠️ AUDIT §C2 — `CachedFingerprint` et `CachedLayout` ci-dessous sont OBLIGATOIRES dans cette clé. + // La clé ci-dessus décrit la GÉOMÉTRIE de la fenêtre (boîte, strate, seed) et rien de ce qui + // détermine les PARAMS avec lesquels les salles ont été cuites. Comme `GetGenerationParams` + // blende à l'intérieur d'une strate (`Alpha` = f(chunk Z), et f(chunk XY) aussi en + // `Interleaved`), deux chunks voisins produisent la MÊME clé avec des params DIFFÉRENTS, et le + // deuxième se sert des salles du premier. Non déterministe entre pairs, parce que l'ordre des + // workers décide lequel est « le premier » — exactement ce que §2.6.1 interdit. + // + // Pourquoi ça ne casse PAS l'invariant de perf de §8.10 : la clé reste une BOÎTE, donc les + // sondes de gradient à `WorldX ± 1` ne font toujours pas tourner le cache. Ce qui le fait + // tourner en plus, c'est un changement RÉEL de params — une fois par chunk dans une bande de + // transition, ce qui est exactement le nombre de reconstructions nécessaire. + thread_local uint32 CachedFingerprint = 0xFFFFFFFFu; + thread_local uint32 CachedLayout = 0xFFFFFFFFu; + thread_local bool CachedUsesFusedCacheWindow = false; + thread_local bool CachedUsesTileCacheWindow = false; + thread_local FIntVector CachedTileOrigin = FIntVector::ZeroValue; + thread_local int32 CachedTileStep = 0; + thread_local int32 CachedTileCells = 0; + thread_local uint64 CachedManagerLifetimeId = 0; + thread_local const TArray* CachedTerrainOps = nullptr; + // The envelope proof is constant for a cache-policy key. Do not run the complete room/edge + // reach calculation for every density sample: that would turn a build-time guard into a + // per-sample cost on the LOD0 floor. + thread_local bool CachedTilePolicyValid = false; + thread_local bool CachedTilePolicyResult = false; + thread_local bool CachedTilePolicyHasContext = false; + thread_local bool CachedTilePolicyFusedPath = false; + thread_local int32 CachedTilePolicySampleStep = 0; + thread_local FIntVector CachedTilePolicyOrigin = FIntVector::ZeroValue; + thread_local int32 CachedTilePolicyStep = 0; + thread_local int32 CachedTilePolicyCells = 0; + thread_local uint32 CachedTilePolicyFingerprint = 0xFFFFFFFFu; + thread_local uint32 CachedTilePolicyLayout = 0xFFFFFFFFu; + thread_local uint64 CachedTilePolicyManagerLifetimeId = 0; + thread_local const TArray* CachedTilePolicyTerrainOps = nullptr; + + const bool bUseFusedCacheWindow = OutTunnelCore != nullptr; + const bool bUseSpatialIndex = VoxelGenLOD::ShouldUseSpatialIndex(bUseFusedCacheWindow); + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = 1; + int32 RequestedTileCells = 0; + const uint64 CurrentManagerLifetimeId = StrateManager + ? StrateManager->GetCacheLifetimeId() : 0; + const int32 QueryChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); + const int32 QueryChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); + const int32 QueryChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + const int32 CacheRegionChunks = bUseFusedCacheWindow ? 4 : 1; + const int32 CacheRegionSize = CHUNK_SIZE * CacheRegionChunks; + const int32 CacheRegionX = FMath::FloorToInt( + WorldX / static_cast(CacheRegionSize)); + const int32 CacheRegionY = FMath::FloorToInt( + WorldY / static_cast(CacheRegionSize)); + // BuildChunkCache bakes the selected immutable terrain-operation pool into each stored room. + // A tile may cross a strate-definition boundary in XY, so the pool identity is part of the + // cache key just like the generation-parameter fingerprint. The manager lifetime guards an + // allocator-reused definition address after a live layout/world change. + const TArray* TerrainOps = nullptr; + if (StrateManager) + { + UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk( + FIntVector( + bUseFusedCacheWindow ? QueryChunkX : CacheRegionX, + bUseFusedCacheWindow ? QueryChunkY : CacheRegionY, + QueryChunkZ)); + if (Def) TerrainOps = &Def->TerrainOperations; + } + const bool bTileWindowEligible = VoxelGenLOD::IsTileCacheWindowEnabled( + bUseFusedCacheWindow); + const bool bHasTileWindowContext = VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells); + const bool bTilePolicyKeyChanged = + !CachedTilePolicyValid + || CachedTilePolicyHasContext != (bTileWindowEligible && bHasTileWindowContext) + || CachedTilePolicyFusedPath != bUseFusedCacheWindow + || CachedTilePolicySampleStep != RequestedTileStep + || CachedTilePolicyOrigin != RequestedTileOrigin + || CachedTilePolicyStep != RequestedTileStep + || CachedTilePolicyCells != RequestedTileCells + || CachedTilePolicyFingerprint != ParamsFingerprint + || CachedTilePolicyLayout != LayoutVersion + || CachedTilePolicyManagerLifetimeId != CurrentManagerLifetimeId + || CachedTilePolicyTerrainOps != TerrainOps; + if (bTilePolicyKeyChanged) + { + CachedTilePolicyValid = true; + CachedTilePolicyHasContext = bTileWindowEligible && bHasTileWindowContext; + CachedTilePolicyFusedPath = bUseFusedCacheWindow; + CachedTilePolicySampleStep = RequestedTileStep; + CachedTilePolicyOrigin = RequestedTileOrigin; + CachedTilePolicyStep = RequestedTileStep; + CachedTilePolicyCells = RequestedTileCells; + CachedTilePolicyFingerprint = ParamsFingerprint; + CachedTilePolicyLayout = LayoutVersion; + CachedTilePolicyManagerLifetimeId = CurrentManagerLifetimeId; + CachedTilePolicyTerrainOps = TerrainOps; + CachedTilePolicyResult = CachedTilePolicyHasContext + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(Params, TerrainOps); + } + const bool bUseTileCacheWindow = CachedTilePolicyResult; + + // Index of the room with the smallest (most-inside) SDF for this voxel. + // Written by EvaluateSDFCached, read by the terrain ops block to pick the + // per-room terrain op. -1 means no room is nearby (deep-solid or no rooms). + thread_local int32 NearestRoomIdx = -1; + + float CaveSDF = FLT_MAX; + + if (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) + { + // Get strate index for unique caves per strate. + // MEMOISED: the index only changes at chunk-Z boundaries, yet this ran the strate layout's + // linear scan PER VOXEL. Key = (chunk-Z, layout version) — the version guards against editor + // rebuilds (RebuildStrates / live edit) serving a stale index. The lookup queries the BAND + // CENTRE so the result is a pure function of the key (order-independent, window-invariant). + int32 StrateIdx = 0; + if (StrateManager) + { + thread_local int32 SI_ChunkZ = INT32_MAX; + thread_local uint32 SI_Version = 0xFFFFFFFFu; + thread_local int32 SI_Index = 0; + const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + const uint32 LV = StrateManager->GetLayoutVersion(); + if (QZ != SI_ChunkZ || LV != SI_Version) + { + SI_ChunkZ = QZ; + SI_Version = LV; + // GetStrateIndex expects Unreal world units (divides by VOXEL_SIZE internally); + // WorldZ here is in voxels, so multiply by VOXEL_SIZE. + SI_Index = StrateManager->GetStrateIndex(((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); + } + StrateIdx = SI_Index; + } + + // Rebuild only when the WARPED query (what EvaluateSDFCached uses) leaves the + // cached search box, or the strate/seed changed. + const bool bNeedRebuild = + StrateIdx != CachedStrate || (uint32)Seed != CachedSeed || + ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout || + CurrentManagerLifetimeId != CachedManagerLifetimeId || + TerrainOps != CachedTerrainOps || + bUseFusedCacheWindow != CachedUsesFusedCacheWindow || + bUseTileCacheWindow != CachedUsesTileCacheWindow || + (bUseTileCacheWindow + && (RequestedTileOrigin != CachedTileOrigin + || RequestedTileStep != CachedTileStep + || RequestedTileCells != CachedTileCells)) || + WarpedX < CachedSMinX || WarpedX > CachedSMaxX || + WarpedY < CachedSMinY || WarpedY > CachedSMaxY; + + if (bNeedRebuild) + { + // The lowered evaluator uses the same window-invariance contract as the prepared + // room-graph source. With the switch enabled, the search box is the exact requesting + // tile footprint plus the one-sample normal halo and deterministic warp margin. A + // tile worker can visit neighbouring chunk keys in an arbitrary order without + // rebuilding the graph at every coarse sample. Point queries without a tile context + // retain the historical fused four-chunk / ordinary one-chunk fallback. + const float ChunkMinX = CacheRegionX * (float)CacheRegionSize; + const float ChunkMinY = CacheRegionY * (float)CacheRegionSize; + const float ChunkMaxX = ChunkMinX + (float)CacheRegionSize; + const float ChunkMaxY = ChunkMinY + (float)CacheRegionSize; + const float WarpMargin = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f; + float SMinX = ChunkMinX; + float SMinY = ChunkMinY; + float SMaxX = ChunkMaxX; + float SMaxY = ChunkMaxY; + if (bUseTileCacheWindow) + { + const float TileMinX = static_cast(RequestedTileOrigin.X); + const float TileMinY = static_cast(RequestedTileOrigin.Y); + const float TileExtent = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + const float TileMaxX = TileMinX + TileExtent; + const float TileMaxY = TileMinY + TileExtent; + const float TileHalo = static_cast(RequestedTileStep) + 2.0f; + const float Expansion = WarpMargin + TileHalo; + SMinX = TileMinX - Expansion; + SMinY = TileMinY - Expansion; + SMaxX = TileMaxX + Expansion; + SMaxY = TileMaxY + Expansion; + } + else + { + const float Expansion = WarpMargin + 2.0f; + SMinX = ChunkMinX - Expansion; + SMinY = ChunkMinY - Expansion; + SMaxX = ChunkMaxX + Expansion; + SMaxY = ChunkMaxY + Expansion; + } + + VoxelCaveMorphology::BuildChunkCache( + SDFCache, + SMinX, SMinY, SMaxX, SMaxY, + Params, (uint32)Seed, StrateIdx, + TerrainOps, + ERoomGraphBuildSite::GeneratorTile + ); + UpdateTunnelCoreTileReach(SDFCache, bUseTileCacheWindow); + + CachedSMinX = SMinX; CachedSMaxX = SMaxX; + CachedSMinY = SMinY; CachedSMaxY = SMaxY; + CachedStrate = StrateIdx; + CachedSeed = (uint32)Seed; + CachedFingerprint = ParamsFingerprint; + CachedLayout = LayoutVersion; + CachedUsesFusedCacheWindow = bUseFusedCacheWindow; + CachedUsesTileCacheWindow = bUseTileCacheWindow; + CachedTileOrigin = bUseTileCacheWindow + ? RequestedTileOrigin : FIntVector::ZeroValue; + CachedTileStep = bUseTileCacheWindow ? RequestedTileStep : 0; + CachedTileCells = bUseTileCacheWindow ? RequestedTileCells : 0; + CachedManagerLifetimeId = CurrentManagerLifetimeId; + CachedTerrainOps = TerrainOps; + } + + // Evaluate SDF using cached rooms and tunnels (WARPED coordinates). + // Also writes NearestRoomIdx — the room with minimum SDF contribution + // at this voxel's position. Used by terrain ops below to pick per-room params. + NearestRoomIdx = -1; + const FVector WorldTunnelPosition(WorldX, WorldY, WorldZ); + CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( + WarpedX, WarpedY, WarpedZ, + SDFCache, Params.SDFBlendRadius, + &NearestRoomIdx, + bUseSpatialIndex, + &WorldTunnelPosition, + OutRoomOwnsBottom + ); + + // ── PIT & CHIMNEY SDF INTEGRATION ── + // Pits and chimneys are SmoothMin'd into CaveSDF here, using REAL + // (unwarped) world coordinates. This is intentional: pit positions come + // from unwarped room centers, so evaluating them in warped space would + // misalign them. Rooms also use unwarped positions — warping is purely a + // query-coordinate bend, not a data-space transform. + // + // Including pits in CaveSDF means: + // - The CarveFactor block below handles their density naturally + // - SmoothMin at the pit-to-room junction creates the same organic + // transition as tunnel-to-room (no hard seam at PitTopZ) + // - bNearCaveSurface becomes true inside the shaft — roughness clamp + // prevents fill-back, so this is safe + if (!VoxelDensityAblation::IsPitChimneySDFOff()) + { + auto EvaluatePit = [&](int32 PitIndex) + { + const FCachedPit& Pit = SDFCache.Pits[PitIndex]; + const float DZ = WorldZ - Pit.TopZ; // Negative = below anchor (shaft) + if (DZ >= Pit.BlendK) return; // Above even the blend fringe + if (-DZ > Pit.Depth + Pit.BlendK) return; + + const float DX = WorldX - Pit.CenterX; + const float DY = WorldY - Pit.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) return; + + float PitSDF; + if (DZ <= 0.0f) + { + // Shaft: tapered cylinder with flared opening + float DepthBelow = -DZ; + float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor; + PitSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + // Above anchor: only the blend fringe matters here. + // We still pass a SDF so SmoothMin can soften the rim from above. + // Treat this zone as a flat disc at TopZ (XY cylinder, no Z factor) + // so the blend fades horizontally into the room floor. + PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); + }; + VF_ForEachChunkSDFSpatialCandidate( + SDFCache.PitSpatialIndex, SDFCache.Pits.Num(), WorldX, WorldY, EvaluatePit, + bUseSpatialIndex); + + auto EvaluateChimney = [&](int32 ChimneyIndex) + { + const FCachedChimney& Chim = SDFCache.Chimneys[ChimneyIndex]; + const float DZ = WorldZ - Chim.BottomZ; // Positive = above anchor (shaft) + if (-DZ >= Chim.BlendK) return; // Below even the blend fringe + if (DZ > Chim.Height + Chim.BlendK) return; + + const float DX = WorldX - Chim.CenterX; + const float DY = WorldY - Chim.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chim.BoundXYRadiusSq) return; + + float ChmSDF; + if (DZ >= 0.0f) + { + float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor; + ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + // Below anchor: flat disc blend into room ceiling + ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK); + }; + VF_ForEachChunkSDFSpatialCandidate( + SDFCache.ChimneySpatialIndex, SDFCache.Chimneys.Num(), + WorldX, WorldY, EvaluateChimney, bUseSpatialIndex); + } + + // Convert SDF to density carving: + // CaveSDF < 0 means we're inside a room/tunnel/pit → carve to air + // CaveSDF > 0 means we're in solid rock → no change + // The transition zone around SDF=0 gives smooth cave walls + if (CaveSDF < Params.SDFBlendRadius) + { + // Smooth carving factor: 1.0 deep inside cave, 0.0 at blend edge + float CarveFactor = FMath::Clamp( + (Params.SDFBlendRadius - CaveSDF) / FMath::Max(Params.SDFBlendRadius * 2.0f, 1.0f), + 0.0f, 1.0f + ); + // Smoothstep for less abrupt transitions + CarveFactor = SmoothStep01(CarveFactor); + Density -= CarveFactor * Params.BaseDensity * 2.0f; + } + } + + // The canonical stack's source publishes this same structural result before its detail + // modifiers run. The lowered path asks the scalar evaluator for it directly, so the common + // post-disturbance tail can consume the result without allocating an FVoxelOpSample or + // invoking the interpreted stack a second time for this sample. + if (OutTunnelCore != nullptr + && !VoxelDensityAblation::IsTunnelCoreOff() + && Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) + { + // The no-column form is the exact scalar reference path: it performs the world-bound and + // floor projection for the current point without eagerly materialising a whole XY table. + // The interpreted source has a lazy worker LRU for block evaluation; building that table + // here would turn a cache-window change into hundreds of unnecessary column builds. + const FVector RoomQueryPosition(WarpedX, WarpedY, WarpedZ); + *OutTunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, SDFCache, nullptr, bUseSpatialIndex, + &RoomQueryPosition, OutRoomOwnsBottom); + } + + //========================================================================= + // EARLY-OUT: Skip detail work for deep-solid voxels + //========================================================================= + // ~70% of voxels in a chunk are deep inside solid rock, far from any cave. + // Roughness, terrain ops, columns, pits, domes, pinch — ALL of these only + // matter near cave surfaces. Skipping them for deep-solid voxels is the + // single biggest performance win. + // + // We still need to run worm tunnels (they carve independently) and boundary + // seal / modifiers, so we jump to Step 5 instead of returning early. + const float DetailThreshold = Params.SDFBlendRadius * 3.0f; + const bool bNearCaveSurface = (CaveSDF < DetailThreshold) && (CaveSDF < FLT_MAX); + const bool bRunDetail = !VoxelDensityAblation::IsDetailOpsOff() && bNearCaveSurface; + if (bCollectFusedDiagnostics && VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + bRunDetail + ? VoxelDensityProfile::ECounter::FusedTunnelDetailSamples + : VoxelDensityProfile::ECounter::FusedTunnelDetailSkipped); + } + + VoxelDensityProfile::FScopedTimer FusedDetailTimer( + VoxelDensityProfile::EBucket::FusedDetail); + + //========================================================================= + // STEP 4b: SURFACE ROUGHNESS (volumetric, SDF-based) + //========================================================================= + // 3D noise near cave surfaces (where SDF ≈ 0) creates rocky detail: + // overhangs, ledges, bumps, cracks. The SDF value directly tells us + // how far from the surface we are — no need for separate floor/ceiling tracking. + // + // The noise type (fBM, Ridged, Mixed) and optional domain warping are + // per-strate settings, so each strate can have fundamentally different + // wall character — smooth lava tubes vs craggy erosion cliffs. + if (bRunDetail && Params.SurfaceRoughness > 0.0f) + { + float RoughnessDepth = Params.SurfaceRoughness * 2.0f; + float DistFromSurface = FMath::Abs(CaveSDF); + + if (DistFromSurface < RoughnessDepth) + { + float RF = Params.RoughnessFrequency; + + // Base noise input positions (with seed offsets for uniqueness) + FVector3f MainPos( + WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f), + WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f), + EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f) + ); + FVector3f FinePos( + WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f, + WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f, + EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f + ); + + // DOMAIN WARPING: distort noise coordinates with a secondary field. + // This breaks up repetitive patterns — coordinates are "bent" by noise, + // making walls look like they were shaped by flowing water or pressure. + // Each axis uses a different seed offset for independent warping. + if (Params.DomainWarpStrength > 0.0f) + { + float WF = Params.DomainWarpFrequency; + float WS = Params.DomainWarpStrength; + + // Sample three independent noise fields for X, Y, Z warp + float WarpX = VoxelNoise::Perlin3D(FVector3f( + WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f), + WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f), + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f) + )) * VOXEL_NOISE_SCALE * WS; + + float WarpY = VoxelNoise::Perlin3D(FVector3f( + WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f), + WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f), + EffectiveZ * WF + 300.0f + )) * VOXEL_NOISE_SCALE * WS; + + float WarpZ = VoxelNoise::Perlin3D(FVector3f( + WorldX * WF + 400.0f, + WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f), + EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f) + )) * VOXEL_NOISE_SCALE * WS; + + // Apply warp to both noise positions + FVector3f WarpOffset(WarpX, WarpY, WarpZ); + MainPos += WarpOffset; + FinePos += WarpOffset; + } + + // NOISE TYPE SELECTION: sample the right noise function. + // Octave counts go through VoxelGenLOD::Eff — far tiles (Step>1) drop + // the sub-cell tail octaves (T2.b); LOD0 keeps the full counts. + float RoughNoise, FineNoise; + const int32 Oct3 = VoxelGenLOD::Eff(3); + const int32 Oct2 = VoxelGenLOD::Eff(2); + + switch (Params.RoughnessNoiseType) + { + case EVoxelNoiseType::Ridged: + // Ridged multifractal: sharp, craggy features + RoughNoise = RidgedNoise3D(MainPos, Oct3); + FineNoise = RidgedNoise3D(FinePos, Oct2); + break; + + case EVoxelNoiseType::Mixed: + // Blend: ridged structure softened by fBM + RoughNoise = FractalNoise3D(MainPos, Oct3) * 0.5f + + RidgedNoise3D(MainPos, Oct3) * 0.5f; + FineNoise = FractalNoise3D(FinePos, Oct2) * 0.5f + + RidgedNoise3D(FinePos, Oct2) * 0.5f; + break; + + case EVoxelNoiseType::Cellular: + // Worley/cellular: grotto, bubble-like patterns + // Scale down because cellular noise has different frequency behavior + RoughNoise = CellularNoise3D(MainPos); + FineNoise = CellularNoise3D(FinePos); + break; + + case EVoxelNoiseType::FBM: + default: + // Standard fBM: smooth, organic + RoughNoise = FractalNoise3D(MainPos, Oct3); + FineNoise = FractalNoise3D(FinePos, Oct2); + break; + } + + // Scale by VOXEL_NOISE_SCALE (all noise functions return ~[-1,1] but UE + // Perlin internally returns ~[-0.8,0.8]) + RoughNoise *= VOXEL_NOISE_SCALE; + FineNoise *= VOXEL_NOISE_SCALE; + + float TotalRough = RoughNoise * Params.SurfaceRoughness + + FineNoise * Params.SurfaceRoughness * 0.4f; + + // Inside definite cave air (CaveSDF < 0), roughness must never add solid back. + // Without this clamp, barely-negative voxels near tunnel junctions or pit rims + // get pushed back to solid by roughness → thin lids, membrane walls, bad seams. + // Roughness can still carve further into walls (negative values), just not fill. + if (CaveSDF < 0.0f) + { + TotalRough = FMath::Min(TotalRough, 0.0f); + } + + // Fade out toward cave center (SurfaceFade = 1.0 at surface, 0.0 far away) + float SurfaceFade = 1.0f - (DistFromSurface / RoughnessDepth); + SurfaceFade = SurfaceFade * SurfaceFade; // Quadratic: concentrate near surface + + Density += TotalRough * SurfaceFade; + } + } + + //========================================================================= + // STEP 4c-4h: TERRAIN OPERATIONS (only near cave surfaces) + //========================================================================= + // All terrain ops only affect density near cave walls. + // Deep-solid voxels skip this entire block (bNearCaveSurface = false). + if (bRunDetail) + { + //========================================================================= + // PER-ROOM TERRAIN PARAMS + //========================================================================= + // Terrain ops are now per-room (not global). NearestRoomIdx was written by + // EvaluateSDFCached above — it's the room with minimum SDF at this voxel. + // + // We copy Params and apply the nearest room's terrain op on top. + // Base Params has all terrain op fields = 0 (disabled) since + // BuildParamsFromDefinition does not merge them globally. + // + // Result: voxels inside different rooms see different terrain ops. + // Rooms with no assigned op leave terrain fields at 0 → no terrain op. Clean. + FStrateGenerationParams LocalTerrainParams = Params; + if (NearestRoomIdx >= 0 && SDFCache.Rooms.IsValidIndex(NearestRoomIdx)) + { + const FCachedRoom& NR = SDFCache.Rooms[NearestRoomIdx]; + if (NR.RoomOp) + { + // Writes only the op's specific fields (e.g. TerraceStepHeight for Terrace). + // All other fields keep Params values unchanged. + NR.RoomOp->ApplyTo(LocalTerrainParams, NR.RoomOpWeight); + } + } + // Shadow outer Params inside this block so all terrain op code below + // automatically uses the per-room values without any other changes. + // The shadow is deliberate (per-room override), so silence C4457 here only. + PRAGMA_DISABLE_SHADOW_VARIABLE_WARNINGS + const FStrateGenerationParams& Params = LocalTerrainParams; + PRAGMA_ENABLE_SHADOW_VARIABLE_WARNINGS + + // Geological features applied after roughness. These modify the density + // field near cave surfaces to create specific shapes: terracing (step-like + // ledges), layer lines (horizontal grooves), and overhangs (horizontal + // shelf protrusions). Each operation is controlled by params in the strate + // definition — set the main param to 0 to disable any operation. + + // --- TERRACING --- + // Creates a staircase pattern on cave walls by offsetting density with + // a smooth staircase function of world Z. The staircase quantizes the + // cave surface into flat shelves connected by short cliff faces. + // + // Math: terracedZ = staircase(worldZ, stepH, hardness) + // offset = terracedZ - worldZ + // Where offset > 0 → more solid (shelf floor to walk on) + // Where offset < 0 → more air (gap under the shelf above) + if (Params.TerraceStepHeight > 0.0f && CaveSDF < FLT_MAX) + { + const float StepH = Params.TerraceStepHeight; + const float DistFromSurface = FMath::Abs(CaveSDF); + const float TerraceRange = StepH * 3.0f; // How far from surface the effect reaches + + if (DistFromSurface < TerraceRange) + { + // Surface orientation test: terracing only makes sense on horizontal surfaces + // (cave floors). On vertical walls (pit shafts, tunnel sides) it creates ugly + // horizontal ridges. We sample the SDF gradient in Z by querying Z±1: + // an SDF has ≈unit gradient, so |dSDF/dZ| alone IS the normalized vertical + // component — near 1 = floor/ceiling, near 0 = wall. (Z-only approximation: + // the full 6-sample gradient normalization was 3× the SDF cost for the same + // orientation signal; SmoothMin regions where |∇SDF| < 1 read slightly + // "flatter", shifting where terraces fade on slopes — accepted visual delta.) + // + // We use the cached SDF so this costs two extra SDF evaluations per voxel, + // only when near a surface — the common case is cheap (DistFromSurface > TerraceRange). + const FVector WorldTunnelPositionZp1(WorldX, WorldY, WorldZ + 1.0f); + const FVector WorldTunnelPositionZm1(WorldX, WorldY, WorldZ - 1.0f); + float SDF_Zp1 = VoxelCaveMorphology::EvaluateSDFCached( + WorldX, WorldY, WorldZ + 1.0f, SDFCache, Params.SDFBlendRadius, + nullptr, bUseSpatialIndex, &WorldTunnelPositionZp1); + float SDF_Zm1 = VoxelCaveMorphology::EvaluateSDFCached( + WorldX, WorldY, WorldZ - 1.0f, SDFCache, Params.SDFBlendRadius, + nullptr, bUseSpatialIndex, &WorldTunnelPositionZm1); + float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f; + // Normalized vertical component: 1 = perfectly horizontal surface (floor/ceiling) + // 0 = perfectly vertical surface (wall) + float SurfaceHorizontality = FMath::Clamp(FMath::Abs(GZ), 0.0f, 1.0f); + // Only apply terrace where the surface is mostly horizontal (> ~45 degrees) + // Smooth transition to avoid a hard cutoff at exactly 45 degrees + float TerraceOrientFactor = FMath::Clamp((SurfaceHorizontality - 0.3f) / 0.4f, 0.0f, 1.0f); + // Noise displacement: perturb Z before staircase to break up straight edges + float NoisedZ = WorldZ; + if (Params.TerraceNoiseDisplacement > 0.0f) + { + float DispNoise = FractalNoise3D(FVector3f( + WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f), + WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f), + WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f) + ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; + NoisedZ += DispNoise * Params.TerraceNoiseDisplacement * StepH; + } + + // Smooth staircase function: + // K = NoisedZ / StepH (which "step" are we in?) + // Frac = fractional part [0, 1) — position within the step + // Edge controls transition sharpness (narrow edge = sharp cliff face) + float K = NoisedZ / StepH; + float FloorK = FMath::FloorToFloat(K); + float Frac = K - FloorK; // Always [0, 1) + + // Build the stair profile: 0 in lower half, 1 in upper half, smooth transition + float Edge = FMath::Lerp(0.45f, 0.02f, Params.TerraceHardness); + float StairValue; + if (Frac < 0.5f - Edge) + { + StairValue = 0.0f; // Lower flat region (below transition) + } + else if (Frac > 0.5f + Edge) + { + StairValue = 1.0f; // Upper flat region (above transition) + } + else + { + // Smoothstep through the transition zone + float T = (Frac - (0.5f - Edge)) / (2.0f * Edge); + StairValue = SmoothStep01(T); + } + + // Reconstruct the terraced Z and compute offset from real Z + float TerracedZ = (FloorK + StairValue) * StepH; + float Offset = TerracedZ - NoisedZ; + // Offset range: approximately [-StepH/2, +StepH/2] + // Positive → below a shelf surface → add density (solid floor) + // Negative → above a shelf → subtract density (air gap under next shelf) + + // Fade based on distance from cave surface (no effect deep in rock) + float Fade = 1.0f - (DistFromSurface / TerraceRange); + Fade = Fade * Fade; // Quadratic: concentrate near surface + + // TerraceOrientFactor suppresses terrace on vertical walls (pit shafts, etc.) + Density += Offset * Fade * TerraceOrientFactor; + } + } + + // --- LAYER LINES --- + // Horizontal grooves in cave walls — visible geological strata. + // A sine wave along Z, sharpened to create thin lines, subtracts density + // near cave surfaces. This carves narrow horizontal channels into walls + // at regular intervals, like sedimentary rock layers in cross-section. + if (Params.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float LineRange = Params.LayerLineSpacing * 1.5f; // Influence depth into rock + + if (DistFromSurface < LineRange) + { + // Sine wave along Z: peaks at each line position + float LinePhase = WorldZ * (2.0f * PI) / Params.LayerLineSpacing; + float LineValue = VoxelMath::DetSin(LinePhase); + + // Sharpen to thin grooves: only carve where sine > 0, then cube it. + // sin → max(sin, 0) → pow(_, 3) turns broad sine humps into thin spikes + LineValue = FMath::Max(LineValue, 0.0f); + LineValue = LineValue * LineValue * LineValue; // Cubic sharpening + + // Fade near surface + float Fade = 1.0f - (DistFromSurface / LineRange); + Fade = Fade * Fade; + + // Subtract density to carve the groove + Density -= LineValue * Params.LayerLineDepth * Fade; + } + } + + // --- RIBBING --- + // Parallel ridge patterns on walls/ceiling (like lava tubes). + // Uses the same sine-along-Z approach as layer lines, but ADDS density + // (protruding ribs) instead of subtracting (grooves). The sine is + // half-wave rectified and smoothed to create rounded bumps. + if (Params.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float RibRange = Params.RibbingSpacing * 1.5f; + + if (DistFromSurface < RibRange) + { + // Sine wave along Z, offset by half-period from layer lines + float RibPhase = WorldZ * (2.0f * PI) / Params.RibbingSpacing + PI * 0.5f; + float RibValue = VoxelMath::DetSin(RibPhase); + + // Half-wave rectify (only positive → ribs, not grooves) then smooth + RibValue = FMath::Max(RibValue, 0.0f); + RibValue = RibValue * RibValue; // Quadratic: rounder bump profile + + // Fade near surface + float Fade = 1.0f - (DistFromSurface / RibRange); + Fade = Fade * Fade; + + // Add density to create protruding ribs + Density += RibValue * Params.RibbingDepth * Fade; + } + } + + // --- OVERHANGS --- + // Horizontal shelf-like protrusions from cave walls. + // Uses 3D noise with much lower Z frequency than XY, so features extend + // horizontally for long stretches before varying vertically. This creates + // natural rocky overhangs and ledges independent of the terracing system. + // + // Only positive noise values create protrusions (asymmetric: rock extends + // INTO the cave, never away). This gives scattered shelf-like features + // rather than uniform displacement. + if (Params.OverhangStrength > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float OverhangRange = Params.OverhangDepth * 2.0f; + + if (DistFromSurface < OverhangRange) + { + // Low Z frequency (0.15x of XY) → features extend horizontally + float OverhangNoise = FractalNoise3D(FVector3f( + WorldX * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f), + WorldY * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f), + EffectiveZ * Params.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f) + ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; + + // Only where noise is positive → protrusions (not recesses) + if (OverhangNoise > 0.0f) + { + float Fade = 1.0f - (DistFromSurface / OverhangRange); + Fade = Fade * Fade; + + // Add density = extend solid rock into cave = overhang shelf + Density += OverhangNoise * Params.OverhangDepth + * Params.OverhangStrength * Fade; + } + } + } + + // --- CLIFF SHARPENING --- + // Steepens vertical faces by amplifying the Z-axis density gradient. + // Where the cave surface is already somewhat vertical (density changes + // quickly along Z), this pushes it toward a sheer cliff face. + // + // Math: sample density at Z+1 and Z-1, compute vertical gradient. + // Where gradient is steep AND we're near the cave surface: + // If we're in the UPPER portion of the cliff → subtract density (more air) + // If we're in the LOWER portion → add density (more solid) + // This squeezes the transition zone, making it near-vertical. + if (Params.CliffStrength > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float CliffRange = 8.0f; // Only affect voxels within 8 of surface + + if (DistFromSurface < CliffRange) + { + // Approximate vertical gradient via the CaveSDF sign and position. + // Near the surface (CaveSDF ≈ 0), the sign of CaveSDF tells us which + // side we're on: negative = inside cave, positive = solid rock. + // We use a noise-modulated vertical gradient to detect steep faces. + float VertGrad = VoxelNoise::Perlin3D(FVector3f( + WorldX * 0.05f + VoxelHash::SeedOffset(SeedU, 71.3f), + WorldY * 0.05f + VoxelHash::SeedOffset(SeedU, 73.7f), + EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3x faster in Z → detects vertical features + )) * VOXEL_NOISE_SCALE; + + // VertGrad near ±1 means terrain is changing fast vertically. + // Multiply by sign of CaveSDF to get direction: + // Positive result (solid side, gradient pointing up) → add more solid + // Negative result (air side) → carve more air + float CliffEffect = VertGrad * CaveSDF * Params.CliffStrength; + + // Only apply where gradient is significant (abs > 0.3) + // and fade with distance from surface + if (FMath::Abs(VertGrad) > 0.3f) + { + float Fade = 1.0f - (DistFromSurface / CliffRange); + Fade = Fade * Fade; + Density += CliffEffect * Fade * 3.0f; + } + } + } + + // --- SCALLOP --- + // Water-erosion-like concave patterns on cave walls. + // Uses cellular (Worley) noise near cave surfaces — the distance-to-nearest + // feature point creates natural bowl-shaped indentations. Where the cellular + // noise value is high (far from feature points = center of a cell), we + // subtract density to carve shallow bowls into the wall. + // + // This gives limestone caves their characteristic scalloped appearance — + // rows of smooth, concave depressions covering the walls. + if (Params.ScallopStrength > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float ScallopRange = Params.ScallopStrength * 4.0f; + + if (DistFromSurface < ScallopRange) + { + // Cellular noise: returns ~[-1, 1] where positive = cell interior (bowl) + float SF = Params.ScallopFrequency; + float ScallopNoise = CellularNoise3D(FVector3f( + WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f), + WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f), + EffectiveZ * SF + VoxelHash::SeedOffset(SeedU, 97.7f) + )); + + // Only carve where noise is positive (cell interiors = bowl centers) + if (ScallopNoise > 0.0f) + { + float Fade = 1.0f - (DistFromSurface / ScallopRange); + Fade = Fade * Fade; + + // Subtract density to carve concave bowls + Density -= ScallopNoise * Params.ScallopStrength * Fade; + } + } + } + + // --- ARCH / BRIDGE (room-relative) --- + // Horizontal rock bridges spanning the room interior. + // Anchored to the nearest room: each arch spans from one side of the room + // to the other at a hash-derived height within the room's Z range. + // Up to MaxArches per room; ArchDensity = probability per slot. + if (Params.ArchDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0) + { + const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; + const FVector VoxPos(WorldX, WorldY, WorldZ); + + for (int32 i = 0; i < 3; i++) + { + const FCachedArch& Arch = Room.Arches[i]; + if (!Arch.bActive) { continue; } + const float ArchSDF = VoxelSDF::Capsule( + VoxPos, Arch.EndpointA, Arch.EndpointB, Arch.Radius); + + const float ArchBlend = 2.0f; + if (ArchSDF < ArchBlend) + { + float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + Density += Fill * Arch.BaseDensity * 1.5f; + } + } + } + + //========================================================================= + // STEP 4d: COLUMNS (pre-baked from BuildChunkCache) + //========================================================================= + // Columns are now pre-baked into SDFCache.Columns — no NearestRoomIdx needed. + // The old per-voxel approach had columns appear/disappear mid-height when + // the owning room changed (NearestRoomIdx switch). Pre-baking fixes this. + auto EvaluateColumn = [&](int32 ColumnIndex) + { + const FCachedColumn& Col = SDFCache.Columns[ColumnIndex]; + const float DX = WorldX - Col.CenterX; + const float DY = WorldY - Col.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Col.BoundXYRadiusSq) return; + + const float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius; + + const float ColBlend = 3.0f; + if (CylSDF < ColBlend) + { + float Fill = FMath::Clamp((ColBlend - CylSDF) / (ColBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + Density += Fill * Col.BaseDensity * 1.5f; + } + }; + VF_ForEachChunkSDFSpatialCandidate( + SDFCache.ColumnSpatialIndex, SDFCache.Columns.Num(), WorldX, WorldY, EvaluateColumn, + bUseSpatialIndex); + + //========================================================================= + // STEP 4g: DOMES (room-relative hemispherical ceilings) + //========================================================================= + // Domes carve upward from the room's upper area, creating cathedral ceilings. + // DmCenterZ anchored to the room's Z center so the dome sits naturally + // in the ceiling zone rather than floating at arbitrary strate heights. + if (Params.DomeDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0) + { + const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; + // Up to 2 domes per room (large rooms can have multiple cathedral pockets) + const int32 MaxDomes = 2; + + for (int32 i = 0; i < MaxDomes; i++) + { + uint32 DH = VoxelHash::Mix(Room.Hash ^ (0xD0AE0u + (uint32)i * 8191u)); + + if (VoxelHash::ToFloat01(DH) > Params.DomeDensity) continue; + + // XY: near room center (domes are wide, keep them centered) + uint32 DH2 = VoxelHash::Mix(DH ^ 0xD0A0u); + float DmX = Room.Center.X + VoxelHash::ToFloatSigned(DH2) * Room.RadiusXY * 0.4f; + float DmY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(DH2)) * Room.RadiusXY * 0.4f; + + // Radius: cap at room radius so dome fits inside the room + uint32 DH3 = VoxelHash::Mix(DH2 ^ 0x90DEu); + float DmRadius = FMath::Min( + FMath::Lerp(Params.DomeMinRadius, Params.DomeMaxRadius, VoxelHash::ToFloat01(DH3)), + Room.RadiusXY * 0.85f + ); + + // Dome center Z: upper portion of the room (ceiling area) + uint32 DH4 = VoxelHash::Mix(DH3 ^ 0xCAFEu); + float DmCenterZ = Room.Center.Z + Room.RadiusZ * 0.2f + + VoxelHash::ToFloat01(DH4) * Room.RadiusZ * 0.3f; + + float DmHeight = DmRadius * Params.DomeHeightRatio; + + // Quick Z reject + if (WorldZ > DmCenterZ + DmHeight + 3.0f || WorldZ < DmCenterZ - 3.0f) continue; + + float DXDm = WorldX - DmX; + float DYDm = WorldY - DmY; + float DZDm = WorldZ - DmCenterZ; + + // Only carve upward from the dome center anchor + if (DZDm < 0.0f) continue; + + // Half-ellipsoid SDF (upward only) + float NormX = DXDm / DmRadius; + float NormY = DYDm / DmRadius; + float NormZ = DZDm / DmHeight; + float EllipDist = FMath::Sqrt(NormX * NormX + NormY * NormY + NormZ * NormZ) - 1.0f; + float DomeSDF = EllipDist * FMath::Min(DmRadius, DmHeight); + + const float DmBlend = 3.0f; + if (DomeSDF < DmBlend) + { + float Carve = FMath::Clamp((DmBlend - DomeSDF) / (DmBlend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + Density -= Carve * Params.BaseDensity * 1.5f; + } + } + } + + //========================================================================= + // STEP 4h: PINCH / BOTTLENECK (room-relative passage narrowing) + //========================================================================= + // Pinches squeeze a passage from the sides. Placed at the room's perimeter + // area (high-radius offset from center) so they act on tunnel entrances + // and room edges, not the open center of the room. + if (Params.PinchDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0) + { + const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; + for (int32 i = 0; i < 3; i++) + { + const FCachedPinch& Pinch = Room.Pinches[i]; + if (!Pinch.bActive) { continue; } + const float DXPn = WorldX - Pinch.CenterX; + const float DYPn = WorldY - Pinch.CenterY; + const float DZPn = WorldZ - Pinch.CenterZ; + + // Quick reject + if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > Pinch.MaxExtent) continue; + + const float Along = DXPn * Pinch.CosAngle + DYPn * Pinch.SinAngle; + const float Across = -DXPn * Pinch.SinAngle + DYPn * Pinch.CosAngle; + + const float NAlong = Along / Pinch.HalfLength; + const float NAcross = Across / Pinch.HalfNarrow; + const float NUp = DZPn / Pinch.HalfVertical; + const float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp; + + if (EllipDist < 1.0f) + { + float Fill = 1.0f - EllipDist; + Fill = SmoothStep01(Fill); + const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp); + const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f); + Density += Fill * SideFactor * Pinch.BaseDensity * 1.5f; + } + } + } + + //========================================================================= + // FLOOR BIAS + //========================================================================= + // Adds density in the lower portion of rooms to counteract surface roughness + // making floors bumpy and hard to walk on. Works by knowing how far below + // the nearest room center we are — the closer to the floor, the more density + // is added back, smoothing the roughness-induced relief. + // + // Only applies inside cave air (CaveSDF < 0) so it doesn't re-solidify walls. + // The roughness clamp already prevents fill-back in confirmed air; this works + // WITH that to give floors a gentler character than walls/ceilings. + if (Params.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f) + { + const FCachedRoom& NR = SDFCache.Rooms[NearestRoomIdx]; + // NormZ: -1 = at room bottom, 0 = center, +1 = at ceiling + const float NormZ = (WorldZ - NR.Center.Z) / FMath::Max(NR.RadiusZ, 1.0f); + + // Only apply below room center (floor zone). + // Quadratic fade: strongest at floor, zero at center. + if (NormZ < 0.0f) + { + float FloorFactor = NormZ * NormZ; // 0 at center, 1 at bottom + Density += FloorFactor * Params.FloorBias; + } + } + + } // end bNearCaveSurface (terrain ops) + FusedDetailTimer.End(); + + // Steps 4e / 4f (pits and chimneys) are now handled by the CaveSDF SmoothMin + // block inserted above (between EvaluateSDFCached and the CarveFactor section). + // The CarveFactor system carves them with the same density logic as rooms and + // tunnels. No separate density subtraction here — that would double-carve. + + //========================================================================= + // STEP 5: WORM TUNNELS (additional organic connectivity) + //========================================================================= + // Worm tunnels add secondary passages and organic connections + // that the SDF graph doesn't create. They're noise-based, so they + // produce natural winding paths that complement the room-and-corridor structure. + // + // HORIZONTAL BIAS: Z frequency is scaled up so tunnels prefer horizontal paths. + // + // NETWORK MASK: |N1|+|N2| < threshold carves tubes near the intersection of two noise + // zero-surfaces — but wherever the fields merely GRAZE the threshold it leaves pinhole + // pockets, and none of it is connected to anything (the far-field "confetti"). + // WormNetworkRange masks the carve by distance to the room/tunnel network (CaveSDF is + // already computed by Step 4 — free): full strength at the network, smooth fade to zero + // at Range. Worms become braids/shortcuts hugging the cave system; no isolated speckle. + // Range = 0 → unmasked legacy behaviour. Bonus: fully-masked voxels skip both Perlins. + if (Params.WormStrength > 0.0f && Params.WormThreshold > 0.0f) + { + VoxelDensityProfile::FScopedTimer FusedWormTimer( + VoxelDensityProfile::EBucket::FusedWorm); + float NetworkMask = 1.0f; + if (Params.WormNetworkRange > 0.0f) + { + if (CaveSDF >= Params.WormNetworkRange) // also true when no network (FLT_MAX) + { + NetworkMask = 0.0f; + } + else if (CaveSDF > 0.0f) + { + NetworkMask = 1.0f - SmoothStep01(CaveSDF / Params.WormNetworkRange); + } + } + + if (NetworkMask > 0.0f) + { + const bool bCollectWormDiagnostics = + bCollectFusedDiagnostics && VoxelDensityProfile::AreCountersEnabled(); + if (bCollectWormDiagnostics) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::WormEligibleSamples); + } + + bool bWormBlockProofComputed = false; + const bool bSkipWormBlock = GVoxelForgeWormBlockSkip != 0 + && VF_TryGetWormBlockSkip( + DensityCacheOwnerId, SeedU, ParamsFingerprint, LayoutVersion, + WorldX, WorldY, WorldZ, Params, bWormBlockProofComputed); + if (bWormBlockProofComputed && bSkipWormBlock && bCollectWormDiagnostics) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::WormBlockProofs); + } + if (bSkipWormBlock) + { + if (bCollectWormDiagnostics) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::WormBlockSkippedSamples); + } + } + else + { + const VoxelWormField::FParameters WormParameters{ + Params.WormFrequency, + Params.WormHorizontalBias, + Params.VerticalScale, + SeedU}; + const float WormValue = VoxelWormField::Evaluate( + WorldX, WorldY, WorldZ, Params.WormThreshold, + WormParameters); + + if (WormValue < Params.WormThreshold) + { + const float t = 1.0f - (WormValue / Params.WormThreshold); + Density -= t * Params.WormStrength * NetworkMask; + } + } + } + FusedWormTimer.End(); + } + + //========================================================================= + // STEP 6: STRATE BOUNDARY SEAL (haut + bas) + //========================================================================= + VoxelDensityProfile::FScopedTimer FusedStructuralTimer( + VoxelDensityProfile::EBucket::FusedStructural); + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::OriginSpineOp); + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + } + + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::BoundarySealOp); + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + } + + //========================================================================= + // STEP 7: INTER-STRATE PASSAGES (perce le seal) + //========================================================================= + if (StrateManager) + { + if (bApplyLegacyStructuralPosts) + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageModifier); + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); + } + else + { + // GetDensityAt owns one common MC-space structural tail after either the legacy or + // operator-stack branch. Keep the actual tube carve here, but do not repeat its + // landing air/floor/tunnel-air posts before the shared disturbance/post sequence. + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageCarveOp); + StrateManager->ApplyPassageCarvingOnly( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); + } + } + + if (bApplyLegacyStructuralPosts) + { + // Direct callers retain the legacy structural tail. The tunnel core itself is still + // composed here as a continuous field; it is not converted into a fixed-density slab. + const bool bTunnelCoreEnabled = !VoxelDensityAblation::IsTunnelCoreOff(); + const float TunnelCoreSDF = bTunnelCoreEnabled + && (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) + ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + WorldX, WorldY, WorldZ, SDFCache, bUseSpatialIndex) + : FLT_MAX; + if (VoxelMath::IsFinite(TunnelCoreSDF) && TunnelCoreSDF < 0.0f) + { + Density = FMath::Min( + Density, + TunnelCoreSDF); + } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + // Fourth structural post: the XY edge wins over a passage near the rim. + VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, + WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); + } + + FusedStructuralTimer.End(); + + // Convention MC: négatif = solide, positif = air. + // La logique interne utilise positif = solide (plus lisible), donc on négate. + return -Density; +} + +//============================================================================= +// SURFACE-WORLD GENERATOR (ECaveGeneratorType::SurfaceWorld) +//============================================================================= +// A heightfield terrain (fBM continents + ridged mountains + fine detail) under a +// high solid "sky cap" ceiling, with a flattened beach band around the water line. +// Open air fills the gap between ground and ceiling; water is a render-side overlay. + +float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params, float& OutM) const +{ + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + const uint32 SeedU = (uint32)Seed; + const float BottomZ = Params.StrateBottomWorldZ; + + // --- Heightfield (a function of XY only — Z is a fixed seed slice) --- + const float GroundBase = BottomZ + H * Params.BaseGroundRelative; + + // Domain-warp the STRUCTURAL query coords (continents + mountains) so coastlines and + // ridgelines wind organically instead of looking like axis-aligned noise blobs. Detail + // noise stays on the real XY so fine bumps remain crisp and uncorrelated with the warp. + float QX = WorldX, QY = WorldY; + if (Params.HeightWarpStrength > 0.0f) + { + const float WF = Params.HeightWarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f))); + QX += wx * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; + QY += wy * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; + } + + // Macro relief map [0,1]: where this XY sits on the plains <-> mountains spectrum. + // M is the "mountainous-ness"; ReliefStrength=0 → M=1 everywhere (uniform terrain). + const float Relief = SampleRelief(WorldX, WorldY, Params.ReliefFrequency, Params.ReliefContrast); + const float M = FMath::Lerp(1.0f, Relief, Params.ReliefStrength); + + float Cont = FractalNoise3D(FVector3f( + QX * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f), + QY * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f), + VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1] + + float Detail = FractalNoise3D(FVector3f( + WorldX * Params.DetailFrequency + 11.0f, + WorldY * Params.DetailFrequency + 22.0f, + VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1] + + float Mountain = 0.0f; + if (Params.MountainStrength > 0.0f) + { + float Ridge = RidgedNoise3D(FVector3f( + QX * Params.MountainFrequency + 99.0f, + QY * Params.MountainFrequency + 77.0f, + VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1] + Ridge = Ridge * 0.5f + 0.5f; // [0,1] peaks + Mountain = Ridge * Params.MountainStrength * M; // mountains rise only in high-relief regions + } + + // Plains keep a fraction of the continental swell; highlands get the full range. + const float ContScale = FMath::Lerp(0.45f, 1.0f, M); + + float Terrain = GroundBase + + Cont * Params.ElevationRange * 0.5f * ContScale + + Mountain * Params.ElevationRange + + Detail * Params.SurfaceRoughness; + + OutM = M; + return Terrain; +} + +float UVoxelGenerator::ComputeSurfaceTerrainZ(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params) const +{ + // Raw structural ground + the relief "mountainous-ness" M (reused to relief-gate the ops). + float M = 1.0f; + float Terrain = SampleSurfaceStructuralZ(WorldX, WorldY, Params, M); + + // --- F20 heightfield terrain ops (all off by default → byte-identical when unset) --- + + // CLIFF (slope-conditioned STEEPENING): where the surface is already steep, push the height + // AWAY from the local mean so gentle slopes become sheer walls / canyon faces; gentle ground + // stays untouched. Slope + local mean come from central differences of the STRUCTURAL field + // (no op feedback), so it tracks the real landform. The one op that costs extra samples — + // only when enabled (4 structural resamples, per-column-cheap). + if (Params.CliffStrength > 0.0f) + { + const float D = FMath::Max(Params.CliffSampleDist, 0.5f); + float Ms; // relief scratch (unused — we only need the heights) + const float Zxp = SampleSurfaceStructuralZ(WorldX + D, WorldY, Params, Ms); + const float Zxm = SampleSurfaceStructuralZ(WorldX - D, WorldY, Params, Ms); + const float Zyp = SampleSurfaceStructuralZ(WorldX, WorldY + D, Params, Ms); + const float Zym = SampleSurfaceStructuralZ(WorldX, WorldY - D, Params, Ms); + const float dZdX = (Zxp - Zxm) / (2.0f * D); + const float dZdY = (Zyp - Zym) / (2.0f * D); + const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY); // rise per voxel of XY + + const float Thr = FMath::Max(Params.CliffSlopeThreshold, 0.05f); + // 0 below the threshold, ramps to 1 within one threshold-width above it. + const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + if (SlopeGate > 0.0f) + { + // Local smoothed reference; push the true height away from it → steepen the wall. + const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym); + const float Gain = Params.CliffStrength * SlopeGate * Params.CliffSharpness; + Terrain += (Terrain - Ref) * Gain; + } + } + + // TERRACE (relief-gated): quantize height into plateaus, only in high-relief regions and only + // as strongly as TerraceStrength asks. TerraceHardness controls the riser: soft rounded steps + // (0) → crisp flat-topped mesas with near-vertical walls (1). Layered cliffs/mesas up top, + // smooth lowlands. + if (Params.TerraceStrength > 0.0f && Params.TerraceHeight > 0.0f) + { + const float StepH = Params.TerraceHeight; + const float T = Terrain / StepH; + const float K = FMath::FloorToFloat(T); + const float Frac = T - K; // [0,1) position within the step + // Hardness widens the flat plateau and sharpens the riser: half-transition width goes + // from 0.5 (a smooth S-curve, no plateau) at Hardness=0 to ~0.03 (sharp wall) at 1. + const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(Params.TerraceHardness, 0.0f, 1.0f)); + const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f)); + const float Stepped = (K + Fs) * StepH; + Terrain = FMath::Lerp(Terrain, Stepped, Params.TerraceStrength * M); + } + + // LAYER LINES: fine sedimentary shelves cut into slopes (exposed rock strata). Pull the + // surface weakly toward each band plane; reads on slopes, invisible on flats (uniform shift). + if (Params.LayerLineDepth > 0.0f && Params.LayerLineSpacing > 0.0f) + { + const float Phase = Terrain * (2.0f * PI / Params.LayerLineSpacing); + Terrain -= VoxelMath::DetSin(Phase) * Params.LayerLineDepth; + } + + // Beach: flatten terrain toward the water line within BeachWidth. (Water level is + // strate-global — forced from the strate — so the water plane stays continuous.) + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + const float WaterZ = Params.StrateBottomWorldZ + H * Params.WaterLevelRelative; + if (Params.WaterLevelRelative > 0.0f && Params.BeachWidth > 0.0f) + { + const float DAbs = FMath::Abs(Terrain - WaterZ); + if (DAbs < Params.BeachWidth) + { + float T = SmoothStep01(DAbs / Params.BeachWidth); + Terrain = FMath::Lerp(WaterZ, Terrain, T); + } + } + + return Terrain; +} + +float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params) const +{ + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + const uint32 SeedU = (uint32)Seed; + float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative; + + // Domain-warp the broad/ridge query coords so ceiling ridgelines and valleys wind + // organically (mirrors the ground heightfield's HeightWarp). Fine bumps below stay on the + // true XY so detail remains crisp and uncorrelated. 0 ⇒ no warp. + float QX = WorldX, QY = WorldY; + if (Params.CeilingWarpStrength > 0.0f) + { + const float WF = Params.CeilingWarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f))); + QX += wx * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength; + QY += wy * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength; + } + + // Broad SIGNED swell: raises/lowers the whole cap → big inverted hills and valleys. + if (Params.CeilingUndulation > 0.0f) + { + const float Swell = FractalNoise3D(FVector3f( + QX * Params.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f), + QY * Params.CeilingUndulationFrequency + 13.0f, + VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1] + CeilZ += Swell * VOXEL_NOISE_SCALE * Params.CeilingUndulation; + } + + // Downward hang: everything here is >= 0 so features only protrude into the void (never + // punch up into the seal). Fine bumps + sharp ridged blades sum together. + float Hang = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + Hang += FMath::Abs(FractalNoise3D(FVector3f( + WorldX * Params.CeilingRoughnessFrequency + 5.0f, + WorldY * Params.CeilingRoughnessFrequency + 6.0f, + VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness; + } + if (Params.CeilingRidgeStrength > 0.0f) + { + float Ridge = RidgedNoise3D(FVector3f( + QX * Params.CeilingRidgeFrequency + 31.0f, + QY * Params.CeilingRidgeFrequency + 47.0f, + VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1] + Ridge = Ridge * 0.5f + 0.5f; // [0,1] hanging ridgelines + Hang += Ridge * Params.CeilingRidgeStrength; + } + + return CeilZ - Hang; +} + +void UVoxelGenerator::ResolveSurfaceChunkParams(const FIntVector& ChunkCoord, + FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx, + TArray& OutBiomeParams) const +{ + OutSurface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord); + OutBiomeCtx = StrateManager->GetBiomeContextForChunk(ChunkCoord); + + // Per-biome surface params: each biome's override (when it overrides SurfaceWorld) else the + // strate's, with STRUCTURAL fields forced from the strate (Z bounds, seal, base, water level) + // so seals/spine/water stay intact. + OutBiomeParams.Reset(); + if (OutBiomeCtx.IsValid()) + { + const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord); + OutBiomeParams.Reserve(OutBiomeCtx.Biomes.Num()); + for (const FBiomeResolved& BR : OutBiomeCtx.Biomes) + { + FSurfaceGenerationParams P = OutSurface; // strate base (+ structural) + const UVoxelBiomeDefinition* B = + (Def && Def->Biomes.IsValidIndex(BR.Index)) ? Def->Biomes[BR.Index] : nullptr; + if (B && B->bOverrideTerrain && B->GeneratorType == ECaveGeneratorType::SurfaceWorld) + { + P = B->SurfaceParams; // biome shape + P.StrateTopWorldZ = OutSurface.StrateTopWorldZ; + P.StrateBottomWorldZ = OutSurface.StrateBottomWorldZ; + P.BoundarySealThickness = OutSurface.BoundarySealThickness; + P.BaseDensity = OutSurface.BaseDensity; + P.WaterLevelRelative = OutSurface.WaterLevelRelative; // shared water plane + } + OutBiomeParams.Add(P); + } + } +} + +void UVoxelGenerator::ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ, + const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx, + const TArray& BiomeParams, FChunkBiomeCache& BiomeCache, + float& OutTerrainZ, float& OutCeilSurf, + float& OutOverhangAmp, float& OutDirX, float& OutDirY) const +{ + const FSurfaceGenerationParams* PD = &BaseSurface; + const FSurfaceGenerationParams* PN = &BaseSurface; + float W = 0.0f; + if (BiomeCtx.IsValid() && BiomeParams.Num() > 0) + { + const FBiomeSample Smp = ResolveBiomeSampleAt(WorldX, WorldY, ChunkZ, BiomeCtx, BiomeCache); + const int32 Di = BiomeParams.IsValidIndex(Smp.DominantIndex) ? Smp.DominantIndex : 0; + PD = &BiomeParams[Di]; + if (Smp.NeighborWeight > 0.0f && BiomeParams.IsValidIndex(Smp.NeighborIndex)) + { + PN = &BiomeParams[Smp.NeighborIndex]; + W = Smp.NeighborWeight; + } + } + OutTerrainZ = ComputeSurfaceTerrainZ(WorldX, WorldY, *PD); + if (W > 0.0f) OutTerrainZ = FMath::Lerp(OutTerrainZ, ComputeSurfaceTerrainZ(WorldX, WorldY, *PN), W); + OutCeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, *PD); + + // F20 phase 2 — resolve the per-column OVERHANG gate (strength·slope-gate, biome-blended) and the + // UPHILL direction. Slope + gradient come from a forward-diff of the STRUCTURAL field (the real + // landform), computed ONCE here. Off ⇒ amp 0 and the whole block is skipped (no extra samples). + OutOverhangAmp = 0.0f; + OutDirX = 0.0f; OutDirY = 0.0f; + if (PD->OverhangStrength > 0.0f || (W > 0.0f && PN->OverhangStrength > 0.0f)) + { + // Sample the gradient at ~reach scale (a spot over the void must "see" the nearby cliff to know + // uphill), but CLAMPED to [4,16] — an un-clamped large Reach would average the slope over a huge + // span and read even a real cliff as flat (killing the gate; the "big Reach = nothing" bug). + const float SD = FMath::Clamp(PD->OverhangReach, 4.0f, 16.0f); + float Ms; + const float Z0 = SampleSurfaceStructuralZ(WorldX, WorldY, *PD, Ms); + const float GX = (SampleSurfaceStructuralZ(WorldX + SD, WorldY, *PD, Ms) - Z0) / SD; // dZ/dX + const float GY = (SampleSurfaceStructuralZ(WorldX, WorldY + SD, *PD, Ms) - Z0) / SD; // dZ/dY + const float Slope = FMath::Sqrt(GX * GX + GY * GY); + + auto Amp = [Slope](const FSurfaceGenerationParams& P) -> float + { + if (P.OverhangStrength <= 0.0f) return 0.0f; + const float Thr = FMath::Max(P.OverhangSlopeThreshold, 0.05f); + const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + return P.OverhangStrength * Gate; // [0,1]; reach comes from the param at apply time + }; + OutOverhangAmp = (W > 0.0f) ? FMath::Lerp(Amp(*PD), Amp(*PN), W) : Amp(*PD); + + // Unit UPHILL direction (the gradient points uphill). The shelf borrows rock from uphill and + // extends it downhill over the void. Degenerate on flats — amp is 0 there anyway. + if (Slope > KINDA_SMALL_NUMBER) { OutDirX = GX / Slope; OutDirY = GY / Slope; } + } +} + +bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ, + float& OutTerrainZ, float& OutCeilSurf) const +{ + if (!StrateManager) return false; + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + ChunkZ); + if (StrateManager->GetGeneratorTypeForChunk(ChunkCoord) != ECaveGeneratorType::SurfaceWorld) + { + return false; + } + + // Own per-chunk cache (independent of GetDensityAt's CP_*) so interleaved gen/deco tasks on the + // same worker don't thrash each other. All columns of one deco cell share one chunk ⇒ resolve once. + thread_local FIntVector OC_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local FSurfaceGenerationParams OC_Surface; + thread_local FBiomeContext OC_BiomeCtx; + thread_local TArray OC_BiomeParams; + thread_local FChunkBiomeCache OC_BiomeCache; + thread_local uint32 OC_Version = 0xFFFFFFFFu; // AUDIT C2 + thread_local uint64 OC_OwnerId = 0; + thread_local uint64 OC_ManagerLifetimeId = 0; + + const uint32 OC_LayoutVersion = StrateManager->GetLayoutVersion(); + const uint64 OC_CurrentOwnerId = DensityCacheOwnerId; + const uint64 OC_CurrentManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + if (ChunkCoord != OC_Chunk || OC_LayoutVersion != OC_Version + || OC_CurrentOwnerId != OC_OwnerId + || OC_CurrentManagerLifetimeId != OC_ManagerLifetimeId) + { + if (OC_LayoutVersion != OC_Version || OC_CurrentOwnerId != OC_OwnerId + || OC_CurrentManagerLifetimeId != OC_ManagerLifetimeId) + { + OC_BiomeCache.Invalidate(); + } + OC_Version = OC_LayoutVersion; + OC_OwnerId = OC_CurrentOwnerId; + OC_ManagerLifetimeId = OC_CurrentManagerLifetimeId; + OC_Chunk = ChunkCoord; + ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams); + } + float OcAmp, OcDirX, OcDirY; // overhang is a density-only 3D shelf; the height oracle ignores it + ComputeSurfaceColumn(WorldX, WorldY, ChunkZ, OC_Surface, OC_BiomeCtx, OC_BiomeParams, OC_BiomeCache, + OutTerrainZ, OutCeilSurf, OcAmp, OcDirX, OcDirY); + return true; +} + +//============================================================================= +// TRIVIAL-TILE CLASSIFICATION (T1.d) +//============================================================================= +// ~84 % des tuiles générées sortent VIDES (tout-roc sous le terrain, tout-air +// au-dessus, cap solide) mais payaient quand même le pré-échantillonnage 33³+ +// complet (une trace : 83 925 GenerateMesh pour 13 227 maillages réels). +// ⚠️ Pas de borne analytique GLOBALE du plafond : pas assez conservative (cap +// bas ⇒ trous dans le toit). Les colonnes sont donc ÉCHANTILLONNÉES sur le treillis +// exact du mesher (mêmes fonctions ⇒ mêmes floats ⇒ verdict exact, pas une +// estimation), et le cas tout-solide porte des gardes spine/passages/ +// disturbances/diff. Tout ce qui n'est pas prouvable ⇒ Mixed (le seul coût d'un +// faux Mixed est du CPU ; un faux AllSolid/AllAir serait un trou). + +// A full tile can be Mixed even when every smaller region is provably the same class. This is +// particularly common at level 0: broad operator bounds overlap an unbounded carve, while the +// actual tile-sized sub-boxes are all air. Refine only an already-Mixed proof. Every child must +// resolve, and all resolved children must agree; an unresolved or conflicting child remains Mixed. +// This is a proof refinement, never a density/sample shortcut. +static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, + const FBox& VoxelBox, + const FVoxelOpContext& Context, + const UVoxelGenerator* Generator, + const UVoxelStrateManager* StructuralManager, + float StructuralBaseDensity = 8.0f, + FVoxelTileClassificationStats* Stats = nullptr) +{ + // The ordinary proof stays shallow. The finite-domain warp retry is paid only by a + // tile that already failed the cheap proof, and can afford one more split level because + // its purpose is to discharge the LOD0 false-Mixed cases without sampling the field. + const int32 MaxRefinementDepth = Context.bTightenWarpProof ? 5 : 2; + + // Exact certificates are still the mesher's lattice, never a reduced-resolution shortcut. + // Keep the samples local to this one classifier call. The old recursive fallback evaluated + // the same shared lattice vertices once per depth-3 leaf (up to 64,000 calls for one LOD0 + // tile); besides being wasteful, that made an empty tile look like a brute-force generation. + // A byte validity plane is intentional: it is transient, bounded by one tile, and must not + // become another process-wide cache. + int32 ExactCacheX0 = 0, ExactCacheY0 = 0, ExactCacheZ0 = 0; + int32 ExactCacheNX = 0, ExactCacheNY = 0, ExactCacheNZ = 0; + TArray ExactCoreValues; + TArray ExactFinalValues; + TArray ExactCoreValid; + TArray ExactFinalValid; + // A negative structural result is monotone over nested boxes. Compute it once for the root + // lattice and carry that absence through the refinement tree; otherwise every AllSolid child + // repeats the same 33³ post-air scan even though a false root result already excludes all of + // its descendants. A positive result is not cached as absent and keeps the old conservative + // per-child checks. + bool bRootPassageAirKnownAbsent = false; + bool bRootOriginAirKnownAbsent = false; + const bool bUseExactCache = Context.bUseLatticeProof && Context.Step > 0 + && [&]() + { + const float Step = static_cast(Context.Step); + const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), + static_cast(Context.LatticeOriginVoxels.Y), + static_cast(Context.LatticeOriginVoxels.Z)); + ExactCacheX0 = FMath::CeilToInt((static_cast(VoxelBox.Min.X) - Origin.X) + / Step - 1.0e-4f); + ExactCacheY0 = FMath::CeilToInt((static_cast(VoxelBox.Min.Y) - Origin.Y) + / Step - 1.0e-4f); + ExactCacheZ0 = FMath::CeilToInt((static_cast(VoxelBox.Min.Z) - Origin.Z) + / Step - 1.0e-4f); + const int32 X1 = FMath::FloorToInt((static_cast(VoxelBox.Max.X) - Origin.X) + / Step + 1.0e-4f); + const int32 Y1 = FMath::FloorToInt((static_cast(VoxelBox.Max.Y) - Origin.Y) + / Step + 1.0e-4f); + const int32 Z1 = FMath::FloorToInt((static_cast(VoxelBox.Max.Z) - Origin.Z) + / Step + 1.0e-4f); + ExactCacheNX = X1 - ExactCacheX0 + 1; + ExactCacheNY = Y1 - ExactCacheY0 + 1; + ExactCacheNZ = Z1 - ExactCacheZ0 + 1; + const int64 Count = static_cast(ExactCacheNX) + * static_cast(ExactCacheNY) + * static_cast(ExactCacheNZ); + if (ExactCacheNX <= 0 || ExactCacheNY <= 0 || ExactCacheNZ <= 0 + || Count <= 0 || Count > 100000) + { + ExactCacheNX = ExactCacheNY = ExactCacheNZ = 0; + return false; + } + + const int32 NumVertices = static_cast(Count); + ExactCoreValues.SetNumUninitialized(NumVertices); + ExactFinalValues.SetNumUninitialized(NumVertices); + ExactCoreValid.Init(0, NumVertices); + ExactFinalValid.Init(0, NumVertices); + return true; + }(); + + auto GetLatticeBounds = [&](const FBox& Box, + int32& OutX0, int32& OutY0, int32& OutZ0, + int32& OutX1, int32& OutY1, int32& OutZ1, + int64& OutCount) -> bool + { + if (!Context.bUseLatticeProof || Context.Step <= 0) + { + return false; + } + const float Step = static_cast(Context.Step); + const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), + static_cast(Context.LatticeOriginVoxels.Y), + static_cast(Context.LatticeOriginVoxels.Z)); + OutX0 = FMath::CeilToInt((static_cast(Box.Min.X) - Origin.X) + / Step - 1.0e-4f); + OutY0 = FMath::CeilToInt((static_cast(Box.Min.Y) - Origin.Y) + / Step - 1.0e-4f); + OutZ0 = FMath::CeilToInt((static_cast(Box.Min.Z) - Origin.Z) + / Step - 1.0e-4f); + OutX1 = FMath::FloorToInt((static_cast(Box.Max.X) - Origin.X) + / Step + 1.0e-4f); + OutY1 = FMath::FloorToInt((static_cast(Box.Max.Y) - Origin.Y) + / Step + 1.0e-4f); + OutZ1 = FMath::FloorToInt((static_cast(Box.Max.Z) - Origin.Z) + / Step + 1.0e-4f); + OutCount = static_cast(OutX1 - OutX0 + 1) + * static_cast(OutY1 - OutY0 + 1) + * static_cast(OutZ1 - OutZ0 + 1); + return OutX1 >= OutX0 && OutY1 >= OutY0 && OutZ1 >= OutZ0 && OutCount > 0; + }; + + auto EvaluateExactLatticeValue = [&](int32 IX, int32 IY, int32 IZ, + bool bUseFinalField) -> float + { + const float Step = static_cast(Context.Step); + const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), + static_cast(Context.LatticeOriginVoxels.Y), + static_cast(Context.LatticeOriginVoxels.Z)); + TArray& Values = bUseFinalField ? ExactFinalValues : ExactCoreValues; + TArray& Valid = bUseFinalField ? ExactFinalValid : ExactCoreValid; + int32 CacheIndex = INDEX_NONE; + if (bUseExactCache + && IX >= ExactCacheX0 && IX < ExactCacheX0 + ExactCacheNX + && IY >= ExactCacheY0 && IY < ExactCacheY0 + ExactCacheNY + && IZ >= ExactCacheZ0 && IZ < ExactCacheZ0 + ExactCacheNZ) + { + CacheIndex = (IZ - ExactCacheZ0) * ExactCacheNY * ExactCacheNX + + (IY - ExactCacheY0) * ExactCacheNX + + (IX - ExactCacheX0); + if (Valid[CacheIndex] != 0) + { + if (Stats) + { + if (bUseFinalField) { ++Stats->ExactFinalCacheHits; } + else { ++Stats->ExactCoreCacheHits; } + } + return Values[CacheIndex]; + } + } + + const float WorldX = Origin.X + static_cast(IX) * Step; + const float WorldY = Origin.Y + static_cast(IY) * Step; + const float WorldZ = Origin.Z + static_cast(IZ) * Step; + // The operator stack is the cheap proof field. A passage/landing post is applied after + // that stack by GetDensityAt; for a child intersecting one of those modifiers, certify + // the final MC-facing field rather than trusting the conservative core proof. + const float Density = bUseFinalField + ? -Generator->GetDensityAt(WorldX, WorldY, WorldZ) + : Stack.EvalInternal(WorldX, WorldY, WorldZ); + if (Stats) + { + if (bUseFinalField) { ++Stats->ExactFinalSamples; } + else { ++Stats->ExactCoreSamples; } + } + if (CacheIndex != INDEX_NONE) + { + Values[CacheIndex] = Density; + Valid[CacheIndex] = 1; + } + return Density; + }; + + auto CornersHaveOneSign = [&](const FBox& Box, bool bUseFinalField) -> bool + { + int32 IX0 = 0, IY0 = 0, IZ0 = 0, IX1 = 0, IY1 = 0, IZ1 = 0; + int64 Count = 0; + if (!GetLatticeBounds(Box, IX0, IY0, IZ0, IX1, IY1, IZ1, Count)) + { + return false; + } + bool bHaveSign = false; + bool bPositive = false; + const int32 Xs[2] = { IX0, IX1 }; + const int32 Ys[2] = { IY0, IY1 }; + const int32 Zs[2] = { IZ0, IZ1 }; + for (const int32 IZ : Zs) + for (const int32 IY : Ys) + for (const int32 IX : Xs) + { + const float Density = EvaluateExactLatticeValue(IX, IY, IZ, bUseFinalField); + if (!VoxelMath::IsFinite(Density) || Density == 0.0f) + { + return false; + } + const bool bThisPositive = Density > 0.0f; + if (!bHaveSign) + { + bHaveSign = true; + bPositive = bThisPositive; + } + else if (bPositive != bThisPositive) + { + return false; + } + } + return bHaveSign; + }; + + auto ClassifyExactLatticeLeaf = [&](const FBox& Box, bool bUseFinalField) -> EVoxelTileClass + { + const uint64 ExactStartCycles = Stats ? FPlatformTime::Cycles64() : 0; + int32 IX0 = 0, IY0 = 0, IZ0 = 0, IX1 = 0, IY1 = 0, IZ1 = 0; + int64 Count = 0; + if (!GetLatticeBounds(Box, IX0, IY0, IZ0, IX1, IY1, IZ1, Count) + || Count > 8192) + { + return EVoxelTileClass::Mixed; + } + if (Stats) + { + if (bUseFinalField) { ++Stats->ExactFinalLeaves; } + else { ++Stats->ExactCoreLeaves; } + } + + bool bAllSolid = true; + bool bAllAir = true; + int32 PositiveCount = 0; + int32 NegativeCount = 0; + int32 ZeroOrInvalidCount = 0; + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float Density = EvaluateExactLatticeValue(IX, IY, IZ, bUseFinalField); + if (!VoxelMath::IsFinite(Density) || Density == 0.0f) + { + ++ZeroOrInvalidCount; + IZ = IZ1; + IY = IY1; + break; + } + if (Density > 0.0f) { ++PositiveCount; bAllAir = false; } + else { ++NegativeCount; bAllSolid = false; } + if (!bAllSolid && !bAllAir) + { + IZ = IZ1; + IY = IY1; + break; + } + } + const EVoxelTileClass Verdict = ZeroOrInvalidCount > 0 || (PositiveCount > 0 && NegativeCount > 0) + ? EVoxelTileClass::Mixed + : (bAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir); + if (Stats) + { + const uint64 Elapsed = FPlatformTime::Cycles64() - ExactStartCycles; + if (bUseFinalField) { Stats->ExactFinalCycles += Elapsed; } + else { Stats->ExactCoreCycles += Elapsed; } + } + return Verdict; + }; + auto Refine = [&](auto&& Self, const FBox& Box, int32 Depth) -> EVoxelTileClass + { + if (Stats) { ++Stats->RefineNodes; } + const uint64 StackStartCycles = Stats ? FPlatformTime::Cycles64() : 0; + const EVoxelTileClass Whole = Stack.ClassifyBox(Box, Context); + if (Stats) + { + ++Stats->StackBoxCalls; + Stats->StackBoxCycles += FPlatformTime::Cycles64() - StackStartCycles; + switch (Whole) + { + case EVoxelTileClass::Mixed: ++Stats->WholeMixedNodes; break; + case EVoxelTileClass::AllSolid: ++Stats->WholeSolidNodes; break; + case EVoxelTileClass::AllAir: ++Stats->WholeAirNodes; break; + default: break; + } + Stats->MaxRefinementDepth = FMath::Max( + Stats->MaxRefinementDepth, static_cast(Depth)); + } + // The graph stack is not the complete MC field: GetDensityAt reasserts the walkable + // tunnel core after the stack and after the disturbance/landing posts. The source's + // box audit tells us whether that post can have a tunnel candidate in this exact box. + // Keep this test local to room-graph stacks; the diagnostic is thread-local and would be + // stale for a slab/maze stack. A candidate only widens the work, never grants a skip. + if (Depth == 0 && Context.bUseLatticeProof && StructuralManager != nullptr) + { + bRootPassageAirKnownAbsent = !StructuralManager->AnyPassageAirPostNearLattice( + VoxelBox, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + bRootOriginAirKnownAbsent = !StructuralManager->AnyOriginLandingAirNearLattice( + VoxelBox, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + } + const bool bHasRoomGeometry = Stack.ProvidesResource(VoxelOpResources::RoomGeometry); + const VoxelDensityOps::FRoomBoxDiagnostic RoomBox = bHasRoomGeometry + ? VoxelDensityOps::GetLastRoomBoxDiagnostic() + : VoxelDensityOps::FRoomBoxDiagnostic(); + if (Stats && bHasRoomGeometry) + { + Stats->RoomTailQueries += RoomBox.ExactTailQueries; + Stats->RoomTailEvaluated += RoomBox.ExactTailEvaluated; + Stats->RoomTailCycles += RoomBox.ExactTailCycles; + Stats->RoomPropagateCycles += RoomBox.PropagateCycles; + Stats->RoomExactPrimitiveCycles += RoomBox.ExactPrimitiveCycles; + Stats->RoomCacheWindowCycles += RoomBox.CacheWindowCycles; + Stats->RoomNumRooms = RoomBox.NumRooms; + Stats->RoomNumTunnels = RoomBox.NumTunnels; + Stats->RoomNumRoomFloorJoins = RoomBox.NumRoomFloorJoins; + Stats->RoomNumPits = RoomBox.NumPits; + Stats->RoomNumChimneys = RoomBox.NumChimneys; + } + const bool bHasTunnelAirCandidate = bHasRoomGeometry + && RoomBox.bMayHaveTunnelCoreAir; + const bool bHasTunnelSupportCandidate = bHasRoomGeometry + && RoomBox.bMayHaveTunnelSupportFloor; + const bool bHasTunnelCoreTailCandidate = bHasRoomGeometry + && RoomBox.bMayHaveTunnelCoreTail; + // The final graph tail has two polarities: it can reopen tunnel air and it can write a + // solid swept floor below an otherwise-air stack box. Resolved children use the broad + // reach candidate directly. A Mixed ancestor remains on the cheap interval path until + // the leaf-sized refinement, where the same candidate requests an exact final-field + // certificate; this avoids paying the final evaluator at every ancestor node. + const bool bHasTunnelCoreCandidate = Whole == EVoxelTileClass::AllSolid + ? bHasTunnelAirCandidate + : Whole == EVoxelTileClass::AllAir + ? (bHasTunnelSupportCandidate + || bHasTunnelAirCandidate + || bHasTunnelCoreTailCandidate) + : false; + const bool bHasMixedTunnelCoreCandidate = Whole == EVoxelTileClass::Mixed + && Depth >= 3 + && bHasTunnelCoreTailCandidate; + // FPassageCarveOp and FOriginSpine are already in the stack. Reusing their broad spatial + // candidates here was the LOD0 bug: a passage that the stack had already proved to miss + // the sampled sign still forced a final-field refinement of every mixed parent. Only the + // writers that run after the stack remain candidates in this section: post-disturbance + // air, and support floors that can add solid. Unknown/non-finite input remains a + // candidate in each exact helper, so this separation cannot authorize a false skip. + // A Mixed core is not a verdict yet. Descend until the interval proof resolves its + // polarity; an air post matters to an AllSolid child, while a solid floor/support post + // matters to an AllAir child. Checking both writers on every Mixed ancestor was the + // remaining LOD0 classifier tax and could not improve the answer before the child split. + const bool bNeedsPostAirCandidate = Whole == EVoxelTileClass::AllSolid; + const bool bPassageAirCandidate = bNeedsPostAirCandidate + && !bRootPassageAirKnownAbsent + && StructuralManager != nullptr + && StructuralManager->AnyPassageAirPostNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + const bool bOriginAirCandidate = bNeedsPostAirCandidate + && !bRootOriginAirKnownAbsent + && StructuralManager != nullptr + && StructuralManager->AnyOriginLandingAirNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + // Solid structural writers matter not only when the interval is already AllAir: the + // refinement may first see Mixed, then prove AllAir with its exact *core* lattice + // certificate. If that child is near a landing/native floor, the core certificate is not + // the final MC field and would skip a solid sample. Keep the query conservative on every + // non-AllSolid child; only small children pay the final-field walk below. + const bool bHasCarveCandidate = bPassageAirCandidate || bOriginAirCandidate; + const bool bHasSolidStructuralCandidate = StructuralManager != nullptr + && (StructuralManager->AnyLandingFloorAtLattice( + Box, Context.LatticeOriginVoxels, Context.Step) + // The passage's authored D-floor is a final MC-space writer, not part of the + // interpreted stack, and no support-floor query covers it; omitting the + // native-floor reach here lets an exact core-lattice AllAir claim skip a tile + // whose final density is solid. + || StructuralManager->AnyPassageStructuralPostNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step)); + const bool bHasStructuralCandidate = Whole == EVoxelTileClass::AllSolid + ? bHasCarveCandidate + : bHasSolidStructuralCandidate; + // A resolved core box still needs a finite-lattice certificate when a common MC tail can + // rewrite it (passage/landing posts or the graph tunnel-core backstop). Without this, + // the interval proves the stack's "all solid" field and silently skips a core tunnel + // that GetDensityAt opens afterwards. Boxes with no tail candidate retain the cheap + // interval path. + const bool bNeedsFinalField = Generator != nullptr + && (bHasStructuralCandidate || bHasTunnelCoreCandidate + || bHasMixedTunnelCoreCandidate); + if (Stats && bNeedsFinalField) + { + ++Stats->NeedsFinalFieldNodes; + } + // A final-field certificate is exact on the same MC lattice as GenerateMesh. Use it for + // the small block-sized tail-risk boxes, including an already-resolved AllSolid/AllAir + // box. The common streamed tile is deliberately not a final-field sampling job: a + // coarse tile can contain thousands of MC vertices, and its proof must stay interval-only. + // Larger tail-risk boxes remain conservative Mixed until the mesher's normal sampling. + bool bAttemptedExact = false; + const float FinalFieldBoxExtent = FMath::Max3( + static_cast(Box.Max.X - Box.Min.X), + static_cast(Box.Max.Y - Box.Min.Y), + static_cast(Box.Max.Z - Box.Min.Z)); + const bool bSmallFinalFieldBox = bNeedsFinalField + && FinalFieldBoxExtent <= 8.0f; + + // A room-graph SDF interval can be conservative at the parent while a fine child still + // resolves uniformly through a state-aware SDF writer (for example the worm mask). Before + // using that fine interval to skip geometry, certify the exact MC lattice in the child. + // This runs only after a mixed parent has been refined to a small room-graph child; clear + // uniform roots and all non-room stacks keep the interval-only fast path. + const bool bNeedsExactRoomIntervalCheck = bHasRoomGeometry + && Context.bUseLatticeProof && Context.bTightenWarpProof + && !bNeedsFinalField && Whole != EVoxelTileClass::Mixed + && Depth >= 3 && FinalFieldBoxExtent <= 8.0f; + if (bNeedsExactRoomIntervalCheck) + { + int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0; + int32 ExactIX1 = 0, ExactIY1 = 0, ExactIZ1 = 0; + int64 ExactCount = 0; + if (GetLatticeBounds(Box, ExactIX0, ExactIY0, ExactIZ0, + ExactIX1, ExactIY1, ExactIZ1, ExactCount) + && ExactCount <= 8192) + { + if (Stats) { ++Stats->ExactCoreIntervalChecks; } + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, false); + if (Exact != Whole && Stats) + { + ++Stats->ExactCoreIntervalDisagreements; + } + return Exact; + } + } + if (bSmallFinalFieldBox) + { + int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0; + int32 ExactIX1 = 0, ExactIY1 = 0, ExactIZ1 = 0; + int64 ExactCount = 0; + const bool bHasExactBounds = GetLatticeBounds( + Box, ExactIX0, ExactIY0, ExactIZ0, + ExactIX1, ExactIY1, ExactIZ1, ExactCount) + && ExactCount <= 8192; + if (bHasExactBounds) + { + bAttemptedExact = true; + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, true); + if (Exact != EVoxelTileClass::Mixed) + { + return Exact; + } + return EVoxelTileClass::Mixed; + } + } + + if (Whole != EVoxelTileClass::Mixed && !bNeedsFinalField) + { + return Whole; + } + // At this size the box contains at most 5^3 exact MC vertices. Evaluating those same + // vertices is a small, sound certificate for the lattice field the mesher will consume; + // it is not a lower-resolution fallback and never changes GenerateMesh's sample count. + // This discharges the last conservative SDF interval misses without walking another + // refinement level or rebuilding the morphology bounds for every half-box. + // The corner check is only a work gate. It is not a proof: matching corners still pay the + // complete exact lattice certificate, so a thin cave or a zero/invalid sample cannot be + // skipped accidentally. For a genuinely uniform LOD0 empty box this collapses the old + // 585-node/64k-sample fallback to at most the 33^3 unique lattice vertices. + // A depth-1 box still contains 17^3 MC vertices; probing its corners and then certifying + // the whole child is more expensive than the interval refinement it was meant to avoid. + // Wait until the depth-3 tail (at most 9^3 vertices) before paying an exact core walk. + const bool bCornersHaveOneSign = !bNeedsFinalField && Depth >= 3 + && CornersHaveOneSign(Box, false); + if (bCornersHaveOneSign) + { + bAttemptedExact = true; + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, false); + if (Exact != EVoxelTileClass::Mixed) + { + return Exact; + } + } + + // A two-voxel unresolved tail is still cheap to certify on the final MC lattice (3^3 + // points). Doing this before the last split avoids eight more interval folds for the + // localized passage/post boundary that caused the original LOD0 false-Mixed bail. + if (!bAttemptedExact && Whole == EVoxelTileClass::Mixed && !bNeedsFinalField + && Context.bUseLatticeProof && Depth >= 4 && FinalFieldBoxExtent <= 2.0f) + { + const EVoxelTileClass ExactFinal = ClassifyExactLatticeLeaf(Box, true); + if (ExactFinal != EVoxelTileClass::Mixed) + { + return ExactFinal; + } + bAttemptedExact = true; + } + if (!bAttemptedExact && Depth >= 3 && (!bNeedsFinalField || bSmallFinalFieldBox)) + { + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, bSmallFinalFieldBox); + if (Exact != EVoxelTileClass::Mixed) + { + return Exact; + } + // The final-field leaf is deliberately a conservative sampling certificate. A + // mixed result means this child really contains both final signs (or an invalid + // sample); splitting it again cannot prove the parent uniform and only repeats the + // expensive post-aware evaluator. + if (bNeedsFinalField) + { + return EVoxelTileClass::Mixed; + } + } + if (Depth >= MaxRefinementDepth) + { + // The operator interval can remain Mixed at a one-voxel tail even though the final + // MC-facing field is uniform after a structural post. Certify that tiny tail on the + // exact lattice before conceding the whole tile to the mesher. This is a proof over + // all corners the mesher would consume; zero/non-finite values remain Mixed. + if (Whole == EVoxelTileClass::Mixed && !bNeedsFinalField + && Context.bUseLatticeProof && FinalFieldBoxExtent <= 1.0f) + { + const EVoxelTileClass ExactFinal = ClassifyExactLatticeLeaf(Box, true); + if (ExactFinal != EVoxelTileClass::Mixed) + { + return ExactFinal; + } + } + return bNeedsFinalField ? EVoxelTileClass::Mixed : Whole; + } + + const FVector Mid = (Box.Min + Box.Max) * 0.5f; + if (Stats) { ++Stats->SplitNodes; } + bool bSawSolid = false; + bool bSawAir = false; + for (int32 Z = 0; Z < 2; ++Z) + for (int32 Y = 0; Y < 2; ++Y) + for (int32 X = 0; X < 2; ++X) + { + const FVector ChildMin( + X == 0 ? Box.Min.X : Mid.X, + Y == 0 ? Box.Min.Y : Mid.Y, + Z == 0 ? Box.Min.Z : Mid.Z); + const FVector ChildMax( + X == 0 ? Mid.X : Box.Max.X, + Y == 0 ? Mid.Y : Box.Max.Y, + Z == 0 ? Mid.Z : Box.Max.Z); + const EVoxelTileClass Child = Self(Self, FBox(ChildMin, ChildMax), Depth + 1); + if (Child == EVoxelTileClass::Mixed) + { + return EVoxelTileClass::Mixed; + } + bSawSolid |= Child == EVoxelTileClass::AllSolid; + bSawAir |= Child == EVoxelTileClass::AllAir; + if (bSawSolid && bSawAir) + { + return EVoxelTileClass::Mixed; + } + } + return bSawSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; + }; + const EVoxelTileClass Result = Refine(Refine, VoxelBox, 0); + return Result; +} + +// The ordinary proof is deliberately cheap. Only a box that remains Mixed after its bounded +// spatial refinement pays for the finite-domain warp interval; this keeps the common empty-tile +// path at its existing cost while giving the few conservative misses a second sound chance. +static EVoxelTileClass VF_ClassifyBoxWithWarpRetry(const FVoxelOpStack& Stack, + const FBox& VoxelBox, + const FVoxelOpContext& Context, + const UVoxelGenerator* Generator = nullptr, + const UVoxelStrateManager* StructuralManager = nullptr, + float StructuralBaseDensity = 8.0f, + FVoxelTileClassificationStats* Stats = nullptr) +{ + const EVoxelTileClass Initial = VF_ClassifyBoxRefined( + Stack, VoxelBox, Context, Generator, StructuralManager, StructuralBaseDensity, Stats); + // A Mixed fine tile is not evidence of geometry: it can be the conservative result of a + // coarse operator interval. Keep the bounded retry for every LOD; skipping it recreates + // the false-Mixed empty tiles that send the mesher through the expensive path. + if (Initial != EVoxelTileClass::Mixed + || Context.bTightenWarpProof) + { + return Initial; + } + + FVoxelOpContext TightContext = Context; + TightContext.bTightenWarpProof = true; + return VF_ClassifyBoxRefined( + Stack, VoxelBox, TightContext, Generator, StructuralManager, StructuralBaseDensity, Stats); +} + +EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, + int32 Step, int32 CellsPerAxis) const +{ + return ClassifyTile(OriginVoxels, Step, CellsPerAxis, nullptr); +} + +bool UVoxelGenerator::TryProveSealedSolidTile( + const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const +{ + return VF_TryProveSealedSolidTile( + *this, OriginVoxels, Step, CellsPerAxis, /*DisabledGuards=*/0); +} + +bool UVoxelGenerator::TryProveOutOfLayoutAirTile( + const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const +{ + return VF_TryProveOutOfLayoutAirTile(*this, OriginVoxels, Step, CellsPerAxis); +} + +#if WITH_DEV_AUTOMATION_TESTS +bool UVoxelGenerator::TryProveSealedSolidTileForTest( + const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, + uint8 DisabledGuards) const +{ + return VF_TryProveSealedSolidTile( + *this, OriginVoxels, Step, CellsPerAxis, DisabledGuards); +} +#endif + +EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis, + FVoxelTileClassificationStats* OutStats) const +{ + if (OutStats) + { + *OutStats = FVoxelTileClassificationStats(); + } + + const int32 NormalizedStep = FMath::Max(1, Step); + const int32 NormalizedCellsPerAxis = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TileClassifyCalls); + +#if WITH_EDITOR + // The ordinary tile classifier proves the live field's interval bounds. While the separate + // research field is selected, decline those proofs until its own complete final-field interval + // contract is wired into streaming; a conservative Mixed verdict keeps the test mesh complete. + if (VoxelForgeRedefinedDensityField::IsEnabled()) + { + return EVoxelTileClass::Mixed; + } +#endif + + const EVoxelTileClass Verdict = ClassifyTileUncached( + OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, OutStats); + if (Verdict == EVoxelTileClass::AllSolid) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TileClassifyAllSolid); + } + else if (Verdict == EVoxelTileClass::AllAir) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TileClassifyAllAir); + } + else + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TileClassifyMixed); + } + + return Verdict; +} + +EVoxelTileClass UVoxelGenerator::ClassifyTileUncached( + const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, + FVoxelTileClassificationStats* OutStats) const +{ + if (OutStats) { *OutStats = FVoxelTileClassificationStats(); } + // Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné. + Step = FMath::Max(1, Step); + // ClassifyTile runs before GenerateMesh installs its LOD TLS. The room-graph source uses this + // value only to choose a sound, window-invariant cache region; leaving the previous worker + // value (normally 1) makes an LOD4 proof rebuild fine per-chunk graph windows across the + // coarse tile. Scope it exactly like the mesher does so standalone classifier calls are also + // keyed by their actual lattice step. + TGuardValue ClassifySampleStepGuard(VoxelGenLOD::SampleStep, Step); + const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); + TGuardValue ClassifyTileOriginGuard( + VoxelGenLOD::TileOriginVoxels, OriginVoxels); + TGuardValue ClassifyTileCellsGuard( + VoxelGenLOD::TileCellsPerAxis, CPA); + const int32 GridDim = CPA + 1; // cell-vertex count: g ∈ [0, CPA] + + // The mesher's outer one-sample halo is used only for central-difference normals. It cannot + // create a marching-cubes cell: every case index reads vertices g ∈ [0, CPA]. Classify those + // actual cell vertices, not the halo. Including g=-1/CPA+1 made a coarse tile look Mixed when + // the halo crossed a surface outside the tile while every cell in the tile was uniform. + const int32 MinX = OriginVoxels.X, MaxX = OriginVoxels.X + CPA * Step; + const int32 MinY = OriginVoxels.Y, MaxY = OriginVoxels.Y + CPA * Step; + const int32 MinZ = OriginVoxels.Z, MaxZ = OriginVoxels.Z + CPA * Step; + // Division entière PLANCHER (les coords négatives tronquent vers 0 en C++ — pas floor). + auto FloorDivC = [](int32 A, int32 B) -> int32 + { + const int32 Q = A / B, R = A % B; + return (R != 0 && ((R < 0) != (B < 0))) ? Q - 1 : Q; + }; + + // ── Garde diff layer : un mod joueur peut creuser OU remplir n'importe où. ── + if (DiffLayer && DiffLayer->HasAnyMods()) + { + const FIntVector MinChunk(FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), FloorDivC(MinZ, CHUNK_SIZE)); + const FIntVector MaxChunk(FloorDivC(MaxX, CHUNK_SIZE), FloorDivC(MaxY, CHUNK_SIZE), FloorDivC(MaxZ, CHUNK_SIZE)); + if (DiffLayer->HasAnyModInChunkRange(MinChunk, MaxChunk)) return EVoxelTileClass::Mixed; + } + + // The bounded shell is global: it also covers gaps and out-of-layout air, so prove it before + // consulting the strate layout. The final MC edge pass uses a positive base, hence 1.0 is a + // deliberately conservative proof input here; the stack op performs the same proof with the + // archetype base for its own sub-box. + const FBox TileVoxelBox(FVector((float)MinX, (float)MinY, (float)MinZ), + FVector((float)MaxX, (float)MaxY, (float)MaxZ)); + if (VF_XYEdgeSealBoxIsForcedSolid(TileVoxelBox, WorldRadiusVoxels, + EdgeSealThickness, 1.0f)) + { + return EVoxelTileClass::AllSolid; + } + + if (!StrateManager) return EVoxelTileClass::Mixed; + + // A selected recipe must be classified by the exact same materialised stack that supplies + // density. Search every touched chunk-Z first: the minimum corner may be in a gap or adjacent + // slot, and letting that fall through to the native factory would prove the wrong field. + int32 RecipeSeed = 0; + ECaveGeneratorType RecipeArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams RecipeParams; + FVoxelOpStackRecipe Recipe; + bool bTouchesRecipe = false; + const int32 RecipeMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE); + const int32 RecipeMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE); + for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ) + { + if (StrateManager->GetRecipeForChunk( + FIntVector(0, 0, CZ), RecipeSeed, RecipeArchetype, + RecipeParams, Recipe)) + { + bTouchesRecipe = true; + break; + } + } + if (bTouchesRecipe) + { + int32 RecipeTopChunkZ = 0; + int32 RecipeBottomChunkZ = 0; + if (!StrateManager->GetStrateChunkZBounds( + RecipeMinChunkZ, RecipeTopChunkZ, RecipeBottomChunkZ) + || RecipeMinChunkZ < RecipeBottomChunkZ + || RecipeMaxChunkZ > RecipeTopChunkZ) + { + return EVoxelTileClass::Mixed; + } + + // Recipes and disturbances are selected from the strate slot's Z only. The manager + // copies the recipe arrays into the output parameters, so repeating this check for every + // XY key in the sampled box turns one coarse tile into thousands of identical deep copies. + // Keep one representative per distinct Z: this is equivalent for the APIs above and still + // rejects a tile when any touched Z changes recipe, bounds, or disturbance state. + for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ) + { + int32 TouchedTop = 0; + int32 TouchedBottom = 0; + if (!StrateManager->GetStrateChunkZBounds(CZ, TouchedTop, TouchedBottom) + || TouchedTop != RecipeTopChunkZ || TouchedBottom != RecipeBottomChunkZ) + { + return EVoxelTileClass::Mixed; + } + int32 TouchedSeed = 0; + ECaveGeneratorType TouchedArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams TouchedParams; + FVoxelOpStackRecipe TouchedRecipe; + const FIntVector TouchedChunk(0, 0, CZ); + if (!StrateManager->GetRecipeForChunk( + TouchedChunk, TouchedSeed, TouchedArchetype, + TouchedParams, TouchedRecipe) + || TouchedSeed != RecipeSeed + || TouchedArchetype != RecipeArchetype) + { + return EVoxelTileClass::Mixed; + } + + const FStrateDisturbanceParams Disturbances = + StrateManager->GetDisturbanceParamsForChunk(TouchedChunk); + if (VF_AnyDisturbanceCanTouchBox(TileVoxelBox, Disturbances, (uint32)Seed)) + { + return EVoxelTileClass::Mixed; + } + } + + FVoxelOpStack RecipeStack; + FVoxelOpContext RecipeContext; + if (!VF_BuildStackFromRecipe( + Recipe, RecipeParams, RecipeSeed, OriginSpineRadius, + StrateManager, RecipeStack, RecipeContext, nullptr)) + { + return EVoxelTileClass::Mixed; + } + RecipeContext.ChunkCoord = FIntVector( + FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), RecipeMinChunkZ); + RecipeContext.Step = Step; + RecipeContext.LayoutVersion = StrateManager->GetLayoutVersion(); + RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels; + RecipeContext.EdgeSealThickness = EdgeSealThickness; + RecipeContext.bUseLatticeProof = true; + RecipeContext.LatticeOriginVoxels = OriginVoxels; + RecipeStack.PrepareChunk(RecipeContext); + const EVoxelTileClass RecipeVerdict = + VF_ClassifyBoxWithWarpRetry(RecipeStack, TileVoxelBox, RecipeContext, + this, StrateManager, 8.0f, OutStats); + if (RecipeVerdict == EVoxelTileClass::Mixed) + { + } + return RecipeVerdict; + } + +#if WITH_EDITOR + // A multi-region composer override is already a complete parent stack: it owns the lateral + // blend and the four global structural posts. Classify it as one field only when the complete + // mesher box stays inside that one strate; otherwise the Z-category pass below deliberately + // returns Mixed. Disturbances and the diff layer remain outside the stack, so either one also + // vetoes a skip. + FVoxelStrateRegionManifest ComposerRegions; + const FIntVector RegionProbeChunk( + FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), FloorDivC(MinZ, CHUNK_SIZE)); + if (StrateManager->GetComposerRegionOverrideForChunk(RegionProbeChunk, ComposerRegions)) + { + int32 RegionTopChunkZ = 0; + int32 RegionBottomChunkZ = 0; + const int32 RegionMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE); + const int32 RegionMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE); + if (!StrateManager->GetStrateChunkZBounds(RegionProbeChunk.Z, + RegionTopChunkZ, RegionBottomChunkZ) + || RegionMinChunkZ < RegionBottomChunkZ + || RegionMaxChunkZ > RegionTopChunkZ) + { + return EVoxelTileClass::Mixed; + } + + // A representative chunk is not enough for a proof: the mesher box can cross an XY + // chunk edge, and disturbances are chunk-local. Require every touched chunk to remain + // in the same slot and to have no active disturbance before delegating the proof to the + // complete lateral stack. Any uncertainty is Mixed; a false uniform answer here can + // skip a real seam or carve. + const int32 RegionMinChunkX = FloorDivC(MinX, CHUNK_SIZE); + const int32 RegionMaxChunkX = FloorDivC(MaxX, CHUNK_SIZE); + const int32 RegionMinChunkY = FloorDivC(MinY, CHUNK_SIZE); + const int32 RegionMaxChunkY = FloorDivC(MaxY, CHUNK_SIZE); + for (int32 CZ = RegionMinChunkZ; CZ <= RegionMaxChunkZ; ++CZ) + { + int32 TouchedTopChunkZ = 0; + int32 TouchedBottomChunkZ = 0; + if (!StrateManager->GetStrateChunkZBounds( + CZ, TouchedTopChunkZ, TouchedBottomChunkZ) + || TouchedTopChunkZ != RegionTopChunkZ + || TouchedBottomChunkZ != RegionBottomChunkZ) + { + return EVoxelTileClass::Mixed; + } + for (int32 CY = RegionMinChunkY; CY <= RegionMaxChunkY; ++CY) + { + for (int32 CX = RegionMinChunkX; CX <= RegionMaxChunkX; ++CX) + { + const FIntVector TouchedChunk(CX, CY, CZ); + FVoxelStrateRegionManifest TouchedRegions; + if (!StrateManager->GetComposerRegionOverrideForChunk( + TouchedChunk, TouchedRegions) + || TouchedRegions.StrateIndex != ComposerRegions.StrateIndex + || TouchedRegions.Seed != ComposerRegions.Seed + || TouchedRegions.RegionCount != ComposerRegions.RegionCount + || TouchedRegions.PartitionSeed != ComposerRegions.PartitionSeed + || TouchedRegions.LatticeCellSize != ComposerRegions.LatticeCellSize + || TouchedRegions.BlendWidth != ComposerRegions.BlendWidth) + { + return EVoxelTileClass::Mixed; + } + + const FStrateDisturbanceParams Disturbances = + StrateManager->GetDisturbanceParamsForChunk(TouchedChunk); + if (VF_AnyDisturbanceCanTouchBox(TileVoxelBox, Disturbances, (uint32)Seed)) + { + return EVoxelTileClass::Mixed; + } + } + } + } + + FVoxelOpStack RegionStack; + FVoxelOpContext RegionContext; + if (!VF_BuildStrateRegionStack(ComposerRegions, OriginSpineRadius, + StrateManager, RegionStack, RegionContext, nullptr)) + { + return EVoxelTileClass::Mixed; + } + RegionContext.ChunkCoord = RegionProbeChunk; + RegionContext.Step = Step; + RegionContext.LayoutVersion = StrateManager->GetLayoutVersion(); + RegionContext.WorldRadiusVoxels = WorldRadiusVoxels; + RegionContext.EdgeSealThickness = EdgeSealThickness; + RegionContext.bUseLatticeProof = true; + RegionContext.LatticeOriginVoxels = OriginVoxels; + RegionStack.PrepareChunk(RegionContext); + const EVoxelTileClass RegionVerdict = + VF_ClassifyBoxWithWarpRetry(RegionStack, TileVoxelBox, RegionContext, + this, StrateManager, 8.0f, OutStats); + if (RegionVerdict == EVoxelTileClass::Mixed) + { + } + return RegionVerdict; + } + +#endif + + bool bCanSolid = true; // "tout le treillis est solide" encore prouvable + bool bCanAir = true; // "tout le treillis est air" encore prouvable + // ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ; + // SurfaceWorld = test colonne ; un slot cave = verdict de pile sur sa sous-boîte. ── + struct FSurfSlot + { + int32 BotChunkZ = INT32_MAX; // identité du slot (borne basse de la strate, en chunks) + int32 RepChunkZ = 0; // chunk Z représentatif pour la résolution des params + int32 StrateKey = MIN_int32; // même clé que GetDensityAt (StrateBottomWorldZ arrondi) + float OverhangMargin = 0.0f; // F20 : hauteur max de corniche (= max OverhangHeight strate+biomes) + FSurfaceGenerationParams Params; + FBiomeContext BiomeCtx; + TArray BiomeParams; + TArray InteriorZ; // z hors bandes de seal → testés par colonne + }; + // thread_local : les TArray gardent leur capacité d'un appel à l'autre (zéro malloc/tuile). + static thread_local FSurfSlot Slots[2]; + static thread_local FChunkBiomeCache TC_BiomeCache; // biome grid du classifieur (valeurs ≡ CP_BiomeCache) + // AUDIT C2 — même discipline que le chemin densité : la grille de biome du classifieur survit + // d'un appel à l'autre et sa boîte de validité ne dit rien du contexte qui l'a produite. + static thread_local uint32 TC_SeenVersion = 0xFFFFFFFFu; + static thread_local uint64 TC_SeenOwnerId = 0; + static thread_local uint64 TC_SeenManagerLifetimeId = 0; + const uint32 TC_LayoutVersion = StrateManager->GetLayoutVersion(); + const uint64 TC_OwnerId = DensityCacheOwnerId; + const uint64 TC_ManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + if (TC_LayoutVersion != TC_SeenVersion + || TC_OwnerId != TC_SeenOwnerId + || TC_ManagerLifetimeId != TC_SeenManagerLifetimeId) + { + TC_SeenVersion = TC_LayoutVersion; + TC_SeenOwnerId = TC_OwnerId; + TC_SeenManagerLifetimeId = TC_ManagerLifetimeId; + TC_BiomeCache.Invalidate(); + } + int32 NumSlots = 0; + + // ── T1.d GÉNÉRIQUE : la pile d'opérateurs classe les archétypes de CAVE ── + // Ces valeurs suivent l'UNIQUE slot de cave que la tuile touche. La pile classera seulement la + // sous-boîte Z de ses échantillons ; les catégories gap/surface/hors-layout plient séparément + // leurs hypothèses dans `bCanSolid` / `bCanAir`. + // These values track the ONE cave slot touched by the tile. The stack classifies only its + // sampled Z sub-box; gap/surface/out-of-layout fold their hypotheses separately. + int32 CaveBotChunkZ = INT32_MAX; // identité du slot de cave (borne basse, en chunks) + int32 CaveRepChunkZ = 0; + int32 CaveMinZ = MAX_int32; + int32 CaveMaxZ = MIN_int32; + bool bAnyCave = false; + bool bAnyGap = false; + bool bAnySurface = false; + bool bAnyOutOfLayout = false; + + int32 MemoChunkZ = INT32_MAX; + int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile), 3 = hors layout + int32 MemoSlotIdx = -1; + + // « Ce chunk Z appartient-il à une strate ? » sous forme publique : `FindSlotIndexForChunkZ` + // est `protected`, `GetStrateChunkZBounds` rend false pour exactement le même cas. + auto VF_ChunkZHasSlot = [&](int32 Z) -> bool + { + int32 UnusedTopCZ = 0, UnusedBotCZ = 0; + return StrateManager->GetStrateChunkZBounds(Z, UnusedTopCZ, UnusedBotCZ); + }; + for (int32 g = 0; g <= CPA; ++g) + { + const int32 Zi = OriginVoxels.Z + g * Step; + const int32 ChunkZ = FloorDivC(Zi, CHUNK_SIZE); + if (ChunkZ != MemoChunkZ) + { + MemoChunkZ = ChunkZ; + const FIntVector CC(0, 0, ChunkZ); // les requêtes de layout ne dépendent que de Z + if (StrateManager->IsGapChunk(CC)) + { + MemoCat = 0; + bAnyGap = true; + } + //================================================================= + // ⛔ HORS LAYOUT = AIR CONSTANT. C'ÉTAIT LE BLOCAGE DE T1.d. + //================================================================= + // `GetGeneratorTypeForChunk` rend `TunnelNetwork` pour tout chunk hors de la pile de + // strates (« le chemin de repli produit de la roche de toute façon » — CE COMMENTAIRE + // EST FAUX) et `IsGapChunk` rend false au-dessus du sommet (« open air, NOT a gap »). + // Résultat : chaque tuile touchant l'air libre au-dessus du monde entrait dans la + // BRANCHE DE CAVE, n'y trouvait aucun slot, et abandonnait — mesuré en jeu à 83 % des + // tuiles classées. + // + // La vérité est dans `GetGenerationParams` : hors layout il rend `BaseDensity = -1`, + // `RoomDensity = 0`, `WormStrength = 0` — un champ CONSTANT, donc de l'air, sans salle + // ni ver pour le percer. Une telle tuile est prouvable sans échantillonner. + // + // Out-of-layout is a CONSTANT AIR field, not a cave archetype. Every tile touching the + // open air above the world was being routed into the cave branch and bailing there. + // `GetStrateChunkZBounds` (PUBLIC) rend false exactement quand `FindSlotIndexForChunkZ` + // rend -1 — ce dernier est `protected`, et cette fonction l'utilise déjà deux fois pour + // la même question. Pas de nouvelle surface d'API pour un prédicat qui existe. + // GetStrateChunkZBounds is the public form of "has a layout slot"; the index accessor + // is protected and this function already uses the bounds call twice for the same test. + else if (!VF_ChunkZHasSlot(ChunkZ)) + { + MemoCat = 3; + bAnyOutOfLayout = true; + + // Les disturbances sont appliquées APRÈS la densité d'archétype et peuvent AJOUTER + // de la roche (ponts, arêtes). Même prudence que les branches gap et cave : si + // l'une peut agir ici, on ne prouve rien. Les chasms ne font que creuser ⇒ ils ne + // menacent pas un verdict d'air. + const FStrateDisturbanceParams DOut = StrateManager->GetDisturbanceParamsForChunk(CC); + if (VF_AnyBridgeCanTouchBox(TileVoxelBox, DOut, (uint32)Seed) + || VF_AnyRidgeCanTouchBox(TileVoxelBox, DOut, (uint32)Seed)) + { + return EVoxelTileClass::Mixed; + } + } + else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld) + { + // Retrouve (ou résout) le slot de strate — l'identité vient des bornes chunk-Z du layout. + int32 TopCZ = 0, BotCZ = 0; + if (!StrateManager->GetStrateChunkZBounds(ChunkZ, TopCZ, BotCZ)) + { + return EVoxelTileClass::Mixed; + } + MemoSlotIdx = -1; + for (int32 s = 0; s < NumSlots; ++s) + { + if (Slots[s].BotChunkZ == BotCZ) { MemoSlotIdx = s; break; } + } + if (MemoSlotIdx < 0) + { + if (NumSlots >= 2) + { + return EVoxelTileClass::Mixed; // >2 strates surface dans une tuile : improbable + } + MemoSlotIdx = NumSlots++; + FSurfSlot& S = Slots[MemoSlotIdx]; + S.BotChunkZ = BotCZ; + S.RepChunkZ = ChunkZ; + S.InteriorZ.Reset(); + ResolveSurfaceChunkParams(CC, S.Params, S.BiomeCtx, S.BiomeParams); + S.StrateKey = FMath::RoundToInt(S.Params.StrateBottomWorldZ); + + // F20 : marge d'overhang = hauteur max (OverhangHeight) sur laquelle une corniche + // peut AJOUTER de la roche AU-DESSUS du sol. La corniche est plafonnée à + // TerrainZ+Height ⇒ au-delà, air prouvable ; dans (TerrainZ, TerrainZ+Height], ni air + // ni solide prouvable → colonne forcée en Mixed. (L'union n'enlève jamais de roche ⇒ + // sous le terrain reste solide prouvable : marge vers le HAUT uniquement.) + S.OverhangMargin = (S.Params.OverhangStrength > 0.0f) ? S.Params.OverhangHeight : 0.0f; + for (const FSurfaceGenerationParams& BP : S.BiomeParams) + { + if (BP.OverhangStrength > 0.0f) + S.OverhangMargin = FMath::Max(S.OverhangMargin, BP.OverhangHeight); + } + + // Disturbances de cette strate : les chasms CREUSENT (cassent AllSolid), les + // ponts/arêtes AJOUTENT de la roche dans l'intérieur (cassent AllAir). + const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(CC); + if (D.ChasmDensity > 0.0f) bCanSolid = false; + if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false; + } + MemoCat = 1; + bAnySurface = true; + } + else + { + // ── ARCHÉTYPE DE CAVE ── Jusqu'ici : `return Mixed`, sans appel. Désormais on tente + // le pliage générique de la pile — mais SEULEMENT sous des conditions vérifiables, + // parce qu'un faux verdict ici est un trou (pas de géométrie, pas de collision). + // + // Condition 1 : le slot doit avoir une pile (une définition d'archétype connue). + // Sinon on classerait un champ que le mesher ne produira pas. + if (!StrateManager->UsesOperatorStackForChunk(CC)) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailNoStack); + return EVoxelTileClass::Mixed; + } + + // Condition 2 : un seul slot de cave par tuile. Deux slots = deux jeux de params = + // deux piles, et une pile ne sait répondre que pour SA strate. + int32 CaveTopCZ = 0, CaveBotCZ = 0; + if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ)) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout); + return EVoxelTileClass::Mixed; // hors layout + } + if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots); + return EVoxelTileClass::Mixed; + } + CaveBotChunkZ = CaveBotCZ; + CaveRepChunkZ = ChunkZ; + bAnyCave = true; + MemoCat = 2; + } + } + + if (MemoCat == 2) + { + // La pile répond après la boucle, sur la sous-boîte Z contenant exactement les + // échantillons cave (XY reste la boîte complète du treillis). + CaveMinZ = FMath::Min(CaveMinZ, Zi); + CaveMaxZ = FMath::Max(CaveMaxZ, Zi); + } + else if (MemoCat == 0) + { + bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé) + } + else if (MemoCat == 3) + { + // Hors layout = air constant (BaseDensity = -1, aucune salle, aucun ver). L'hypothèse + // « tout solide » meurt ; « tout air » survit. Les passages et la spine ne font que + // creuser — ils sont déjà gardés plus haut et ne peuvent pas rendre ce z solide. + // Out of layout = constant air: AllSolid dies, AllAir survives. + bCanSolid = false; + } + else + { + FSurfSlot& S = Slots[MemoSlotIdx]; + // Bande de seal ? Mêmes inégalités qu'ApplyBoundarySeal : à l'intérieur, la densité est + // Max(…, SealFactor·BaseDensity) avec SealFactor > 0 ⇒ solide garanti si BaseDensity > 0. + const float Z = (float)Zi; + const float DistTop = S.Params.StrateTopWorldZ - Z; + const float DistBot = Z - S.Params.StrateBottomWorldZ; + const float Th = S.Params.BoundarySealThickness; + const bool bInBand = Th > 0.0f && S.Params.BaseDensity > 0.0f + && ((DistTop >= 0.0f && DistTop < Th) || (DistBot >= 0.0f && DistBot < Th)); + if (bInBand) { bCanAir = false; } + else { S.InteriorZ.Add(Z); } + } + if (!bCanSolid && !bCanAir) + { + return EVoxelTileClass::Mixed; + } + } + + // Out-of-layout is a constant-air field. Passage tubes and origin rooms can only carve that + // field, so they cannot invalidate AllAir; only their guaranteed support floors can add solid + // voxels. Do this after the cheap Z categorisation so open-air LOD tiles avoid the exact + // passage-carve lattice walk and the room scan entirely. A floor-only tile remains Mixed and + // is meshed normally, preserving the support backstops. + if (bAnyOutOfLayout && !bAnyCave && !bAnySurface && !bAnyGap) + { + if (StrateManager->AnyPassageLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step) + || StrateManager->AnyOriginLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step) + || StrateManager->AnyPassageStructuralPostNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + return EVoxelTileClass::Mixed; + } + return EVoxelTileClass::AllAir; + } + + // ── Structural passage/origin guards for non-open-air categories. ── + // Carvers kill AllSolid but never add rock; landing and origin support floors kill AllAir. + const bool bHasPassageCarvingCandidate = + StrateManager->AnyPassageNearLattice( + TileVoxelBox, OriginVoxels, Step); + if (bHasPassageCarvingCandidate) + { + bCanSolid = false; + } + const bool bHasPassageLandingFloorCandidate = + StrateManager->AnyPassageLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step); + const bool bHasOriginLandingFloorCandidate = + StrateManager->AnyOriginLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step); + // These final solid writers are outside the core operator verdict. Keep their candidate + // state after the stack fold too: the AllAir branch below must not re-enable a polarity that + // an earlier structural guard already killed. + const bool bHasPassageStructuralPostCandidate = + StrateManager->AnyPassageStructuralPostNearLattice( + TileVoxelBox, OriginVoxels, Step); + const bool bHasSolidStructuralCandidate = bHasPassageLandingFloorCandidate + || bHasOriginLandingFloorCandidate + || bHasPassageStructuralPostCandidate; + if (bHasPassageLandingFloorCandidate) + { + bCanAir = false; + } + if (StrateManager->AnyOriginLandingNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + bCanSolid = false; + } + if (bHasOriginLandingFloorCandidate) + { + bCanAir = false; + } + // Native passage D-floors are authored structural posts applied after the operator stack. + // They add solid MC samples and therefore must kill the AllAir hypothesis before a tile can + // be skipped. + if (bHasPassageStructuralPostCandidate) + { + bCanAir = false; + } + // ── LE PLIAGE DE LA PILE D'OPÉRATEURS, POUR LES ARCHÉTYPES DE CAVE ── + //========================================================================= + // ⚠️ ERREUR ICI = TROU, PAS RÉGRESSION. Un verdict non-Mixed fait SAUTER `GenerateMesh` : pas + // de triangles, pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers. Toutes les + // gardes ci-dessous ÉCHOUENT EN MIXED ; aucune ne donne le bénéfice du doute. + // + // Le verdict lui-même ne peut pas être meilleur que la pile : `FVoxelOpStack::ClassifyBox` plie + // chaque opérateur avec `VF_FoldOp` et rend `Mixed` dès que les deux hypothèses meurent. Ce que + // ce bloc ajoute, c'est la vérification que la pile interrogée est bien CELLE QUI PRODUIRA LA + // DENSITÉ de cette tuile — même fabrique, mêmes params, même drapeau. + if (bAnyCave) + { + // Diagnostic de PRÉSENCE avant les gardes : le signal reste visible même si le pliage rend + // finalement AllSolid/AllAir. Ces compteurs ne sont pas exclusifs entre eux sur une tuile + // très haute ; chacun répond exactement à « cette catégorie était-elle aussi présente ? ». + // Presence diagnostics run before the guards, so a successful fold cannot hide the mix. + // They are not mutually exclusive for a very tall tile; each answers one exact question. + if (bAnyOutOfLayout || bAnyGap || bAnySurface) + { + if (bAnyOutOfLayout) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout); } + if (bAnyGap) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixGap); } + if (bAnySurface) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixSurfaceWorld); } + } + + const FIntVector RepCC(0, 0, CaveRepChunkZ); + const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC); + + //--------------------------------------------------------------------- + // ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA SOUS-BOÎTE CAVE + //--------------------------------------------------------------------- + // `GetGenerationParams` et ses homologues BLENDENT les params dans les bandes de transition : + // `Alpha` dépend du chunk Z pour `Gradient`, et du chunk XY EN PLUS pour `Interleaved`. Deux + // chunks d'une même sous-boîte peuvent donc porter des params différents — c'est le constat de + // `AUDIT §C2` — et UNE pile ne peut pas représenter DEUX + // champs. On construit donc les params pour CHAQUE coordonnée de chunk que la boîte touche et + // on exige qu'ils soient identiques bit à bit. + // + // `Memcmp` sur un POD : un padding différent ne peut produire qu'un FAUX ÉCART, donc un + // `Mixed` de trop. On se trompe du côté du CPU, jamais du côté du trou. + const int32 CX0 = FloorDivC(MinX, CHUNK_SIZE), CX1 = FloorDivC(MaxX, CHUNK_SIZE); + const int32 CY0 = FloorDivC(MinY, CHUNK_SIZE), CY1 = FloorDivC(MaxY, CHUNK_SIZE); + // IMPORTANT : les gardes restent complètes, mais seulement sur les chunks où le mesher + // échantillonne réellement CETTE strate cave. Inclure gap/surface/hors-layout ici ferait + // échouer la garde d'archétype avant de pouvoir plier leurs hypothèses indépendantes. + // The guards stay exhaustive over the cave samples. Non-cave chunks are intentionally not + // represented by this stack; their hypotheses were folded separately in the Z pass. + const int32 CZ0 = FloorDivC(CaveMinZ, CHUNK_SIZE), CZ1 = FloorDivC(CaveMaxZ, CHUNK_SIZE); + + // Keep the parameter identity check bounded, but do not use the old 27-key cutoff here: + // LOD4 legitimately covers 19^3 = 6,859 chunk keys. Returning Mixed at that point sent + // the tile straight to GenerateMesh, where GetDensityAt rebuilt one exact-chunk state for + // almost every coarse sample. The operator proof now shares its root morphology cache + // across refined children, so this scan is cheap compared with the fallback it prevents. + const int64 NumChunkCoords = (int64)(CX1 - CX0 + 1) * (int64)(CY1 - CY0 + 1) * (int64)(CZ1 - CZ0 + 1); + constexpr int64 MaxClassifiedChunkCoords = 262144; + if (NumChunkCoords > MaxClassifiedChunkCoords) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; + } + + FSlabGenerationParams TileSlab; + FMazeGenerationParams TileMaze; + FVerticalShaftParams TileVert; + FFloatingIslandParams TileFloat; + FStrateGenerationParams TileTunnel; + bool bFirst = true; + + for (int32 cz = CZ0; cz <= CZ1; ++cz) + for (int32 cy = CY0; cy <= CY1; ++cy) + for (int32 cx = CX0; cx <= CX1; ++cx) + { + const FIntVector CC(cx, cy, cz); + if (StrateManager->GetGeneratorTypeForChunk(CC) != CaveType) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; // la boîte déborde sur un autre archétype + } + + // Every chunk of the box must have a stack, not only the one that triggered the + // attempt (redundant with the Z pass today, kept hole-safe). + if (!StrateManager->UsesOperatorStackForChunk(CC)) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailNoStack); + return EVoxelTileClass::Mixed; + } + + switch (CaveType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + { + const FSlabGenerationParams Q = StrateManager->GetSlabParamsForChunk(CC); + if (bFirst) { TileSlab = Q; } + else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; + } + break; + } + case ECaveGeneratorType::Maze: + { + const FMazeGenerationParams Q = StrateManager->GetMazeParamsForChunk(CC); + if (bFirst) { TileMaze = Q; } + else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; + } + break; + } + case ECaveGeneratorType::VerticalShafts: + { + const FVerticalShaftParams Q = StrateManager->GetVerticalShaftParamsForChunk(CC); + if (bFirst) { TileVert = Q; } + else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; + } + break; + } + case ECaveGeneratorType::FloatingIslands: + { + const FFloatingIslandParams Q = StrateManager->GetFloatingIslandParamsForChunk(CC); + if (bFirst) { TileFloat = Q; } + else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; + } + break; + } + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + { + const FStrateGenerationParams Q = StrateManager->GetGenerationParams(CC); + if (bFirst) { TileTunnel = Q; } + else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; + } + break; + } + default: + INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); + return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas être le type du slot cave + } + + bFirst = false; + } + + //--------------------------------------------------------------------- + // La pile — construite par la MÊME fabrique que `GetDensityAt`. + //--------------------------------------------------------------------- + FVoxelStackParamRefs Refs; + Refs.Slab = &TileSlab; + Refs.Maze = &TileMaze; + Refs.Vert = &TileVert; + Refs.Float = &TileFloat; + Refs.Tunnel = &TileTunnel; + // `Refs.Surface` reste nul — la fabrique refuse alors SurfaceWorld, et c'est voulu : cette + // fonction le prouve elle-même sur le treillis EXACT du mesher, ce qu'aucune borne de boîte + // ne fera mieux. + + FVoxelOpContext OpCtx; + OpCtx.ChunkCoord = RepCC; + OpCtx.Seed = (uint32)Seed; + OpCtx.LayoutVersion = TC_LayoutVersion; + OpCtx.Step = Step; + OpCtx.WorldRadiusVoxels = WorldRadiusVoxels; + OpCtx.EdgeSealThickness = EdgeSealThickness; + OpCtx.bUseLatticeProof = true; + OpCtx.bTightenWarpProof = Step <= 2; + OpCtx.LatticeOriginVoxels = OriginVoxels; + + FVoxelOpStack TileStack; + if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius, + StrateManager, TileStack, OpCtx)) + { + // Strate dégénérée : `GetDensityAt` y rend de l'air sans pile, donc la pile ne décrit + // pas ce que le mesher verra. Aucun verdict. + INC_DWORD_STAT(STAT_VoxelForgeCaveBailNoStack); + return EVoxelTileClass::Mixed; + } + TileStack.PrepareChunk(OpCtx); + const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ), + FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ)); + const EVoxelTileClass StackVerdict = VF_ClassifyBoxWithWarpRetry( + TileStack, CaveBox, OpCtx, this, StrateManager, TileTunnel.BaseDensity, OutStats); + if (StackVerdict == EVoxelTileClass::Mixed) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict); + return EVoxelTileClass::Mixed; + } + + const bool bCaveOnlyTile = bAnyCave + && !bAnyGap && !bAnySurface && !bAnyOutOfLayout + && CaveMinZ == MinZ && CaveMaxZ == MaxZ; + if (bCaveOnlyTile && StackVerdict == EVoxelTileClass::AllSolid) + { + // The operator stack does not own the final passage carve. Keep + // its AllSolid shortcut only when the exact-lattice passage guard + // found no modifier or body-envelope write in this tile. + bCanSolid = !bHasPassageCarvingCandidate; + bCanAir = false; + } + else if (bCaveOnlyTile && StackVerdict == EVoxelTileClass::AllAir) + { + bCanSolid = false; + bCanAir = true; + } + else if (StackVerdict == EVoxelTileClass::AllSolid) + { + bCanAir = false; + } + else + { + bCanSolid = false; + } + + // The stack verdict does not own these final MC-space solid writers. In particular, an + // AllAir core result must not restore bCanAir after a native D-floor or landing-floor + // candidate has already made the full field potentially solid. + if (bHasSolidStructuralCandidate) + { + bCanAir = false; + } + + // Le verdict cave se plie avec gap=solide, hors-layout=air, seals surface=solide. Si les + // deux hypothèses sont mortes ici, les catégories se contredisent : ce n'est PAS un échec + // de borne de la pile ni une disturbance. + // Fold the cave verdict with gap=solid, out-of-layout=air, and solid surface seals. If both + // hypotheses die here, the categories conflict; this is not a stack-bound/disturbance bail. + if (!bCanSolid && !bCanAir) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailFoldConflict); + return EVoxelTileClass::Mixed; + } + + //--------------------------------------------------------------------- + // ⚠️ LES DISTURBANCES NE SONT PAS DANS LA PILE (`OPSTACK-DECOMPOSITION §10.2`) : + // `GetDensityAt` les applique APRÈS, sur la densité déjà négatée. Un verdict qui les + // ignorerait serait faux exactement là où elles agissent. Mêmes inégalités que la branche + // SurfaceWorld plus haut, pour la même raison. + //--------------------------------------------------------------------- + const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(RepCC); + // A non-zero strate density is only a possibility. Discharge it when no deterministic + // hash-placed primitive can touch this exact tile; a hit keeps the old polarity-specific + // conservative result (chasms kill AllSolid, bridges/ridges kill AllAir). + if (VF_AnyChasmCanTouchLattice( + OriginVoxels, Step, CPA, D, (uint32)Seed)) + { + bCanSolid = false; + } + if (VF_AnyBridgeCanTouchLattice( + OriginVoxels, Step, CPA, D, (uint32)Seed) + || VF_AnyRidgeCanTouchLattice( + OriginVoxels, Step, CPA, D, (uint32)Seed)) + { + bCanAir = false; + } + + if (!bCanSolid && !bCanAir) + { + INC_DWORD_STAT(STAT_VoxelForgeCaveBailDisturbance); + return EVoxelTileClass::Mixed; + } + } + + // ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne + // tranche chaque z intérieur : air côté MC ⇔ TerrainZ ≤ z ≤ CeilSurf (D = −interne ≥ 0, + // cf. FSurfaceColumnSource ; spine/passages ne font QUE de l'air → gardés plus haut). + // Pré-passe clairsemée ~5×5 pour tuer vite les tuiles traversées par la surface, puis + // passe complète — les colonnes recalculées par la pré-passe restent chaudes dans la boîte. + auto TestColumn = [&](int32 gx, int32 gy) -> bool // false ⇒ les deux hypothèses sont mortes + { + const int32 Xi = OriginVoxels.X + gx * Step; + const int32 Yi = OriginVoxels.Y + gy * Step; + for (int32 s = 0; s < NumSlots; ++s) + { + FSurfSlot& S = Slots[s]; + if (S.InteriorZ.Num() == 0) continue; + FSurfaceColumnBox& Box = GSurfColCache.Acquire( + Xi, Yi, S.StrateKey, Seed, TC_LayoutVersion, + TC_OwnerId, TC_ManagerLifetimeId); + const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX); + if (!Box.Computed[CI]) + { + ComputeSurfaceColumn((float)Xi, (float)Yi, S.RepChunkZ, S.Params, S.BiomeCtx, + S.BiomeParams, TC_BiomeCache, + Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf, + Box.Cols[CI].OverhangAmp, Box.Cols[CI].DirX, Box.Cols[CI].DirY); + Box.Computed[CI] = true; + } + const float T = Box.Cols[CI].TerrainZ; + const float C = Box.Cols[CI].CeilSurf; + const float M = S.OverhangMargin; + for (const float Z : S.InteriorZ) + { + // F20 : juste au-dessus du sol (jusqu'à +OverhangHeight) une corniche peut ajouter de la + // roche ⇒ ni air ni solide prouvable → tuile Mixed. (Vers le haut uniquement : l'union + // n'enlève rien sous le terrain ; le cap n'est pas affecté.) + if (M > 0.0f && Z > T && Z <= T + M) return false; + if (Z >= T && Z <= C) { bCanSolid = false; } // point côté air (surface incluse) + else { bCanAir = false; } // sous le terrain / dans le cap + if (!bCanSolid && !bCanAir) return false; + } + } + return true; + }; + + const bool bNeedColumns = (NumSlots > 0) + && (Slots[0].InteriorZ.Num() > 0 || (NumSlots > 1 && Slots[1].InteriorZ.Num() > 0)); + if (bNeedColumns) + { + const int32 PreStride = FMath::Max(1, GridDim / 4); + for (int32 gy = 0; gy <= CPA; gy += PreStride) + for (int32 gx = 0; gx <= CPA; gx += PreStride) + if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed; + for (int32 gy = 0; gy <= CPA; ++gy) + for (int32 gx = 0; gx <= CPA; ++gx) + if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed; + } + + // Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles + // sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed. + if (bCanSolid == bCanAir) + { + return EVoxelTileClass::Mixed; + } + if (bAnyCave) + { + // Compte seulement les verdicts FINAUX qui sautent réellement une tuile. Une pile peut avoir + // prouvé sa sous-boîte cave puis perdre l'hypothèse sur une colonne SurfaceWorld adjacente. + // Count only final verdicts that actually skip a tile; a later surface column may still + // invalidate the hypothesis proved for the cave sub-box. + if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); } + else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); } + } + return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; +} + +//============================================================================= +// CLIMATE & BIOME FIELDS +//============================================================================= +// Pure functions of world XY (+ seed). The relief field is shared with SurfaceWorld +// terrain so the biome map and the terrain it modulates stay in agreement. The biome +// field is a warped Voronoi whose cells are assigned a biome by climate — coherent +// geography (mountains cluster in high relief), window-invariant by construction. + +float UVoxelGenerator::SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const +{ + const uint32 SeedU = (uint32)Seed; + // Same offsets/octaves as the original SurfaceWorld relief so existing worlds are + // unchanged (this is the function that code path now calls). + float R = FractalNoise3D(FVector3f( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f), + VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1] + R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f); + return SmoothStep01(R); +} + +float UVoxelGenerator::SampleMoisture(float WorldX, float WorldY, float Frequency) const +{ + const uint32 SeedU = (uint32)Seed; + const float N = FractalNoise3D(FVector3f( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 4.7f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 8.9f), + VoxelHash::SeedOffset(SeedU, 1.3f)), 2) * 0.5f + 0.5f; // [0,1] + return FMath::Clamp(N, 0.0f, 1.0f); +} + +bool UVoxelGenerator::EvaluateTerrainConditions(const TArray& Conditions, + float WorldX, float WorldY, const FBiomeContext& BiomeCtx) const +{ + if (Conditions.Num() == 0) return true; // zero-cost default (the common case) + + // Freq/contrast come from the strate's biome map (defaults when the strate has no biomes) — the same + // params SampleRelief/SampleMoisture are given everywhere else, so a condition agrees with the terrain. + const FBiomeMapParams MP = BiomeCtx.IsValid() ? BiomeCtx.Map : FBiomeMapParams(); + + // Lazily sample each field only if a condition references it (BiomeBorder's Voronoi is the pricey one). + float Relief = 0.0f, Moisture = 0.0f, Border = 0.0f; + bool bHaveRelief = false, bHaveMoisture = false, bHaveBorder = false; + + for (const FTerrainCondition& C : Conditions) + { + float V = 0.0f; + switch (C.Type) + { + case ETerrainConditionType::Relief: + if (!bHaveRelief) { Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast); bHaveRelief = true; } + V = Relief; break; + case ETerrainConditionType::Moisture: + if (!bHaveMoisture) { Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency); bHaveMoisture = true; } + V = Moisture; break; + case ETerrainConditionType::BiomeBorder: + if (!bHaveBorder) { Border = SampleBiomeAt(WorldX, WorldY, BiomeCtx).NeighborWeight; bHaveBorder = true; } + V = Border; break; + default: break; + } + + bool bPass = (V >= C.Min && V <= C.Max); + if (C.bInvert) bPass = !bPass; + if (!bPass) return false; // AND semantics: any failing condition rejects the candidate + } + return true; +} + +int32 UVoxelGenerator::ClassifyBiomeAtSite(float SiteX, float SiteY, + const FBiomeContext& Ctx, uint32 SiteHash) const +{ + const float R = SampleRelief(SiteX, SiteY, Ctx.Map.ReliefFrequency, Ctx.Map.ReliefContrast); + const float Mo = SampleMoisture(SiteX, SiteY, Ctx.Map.MoistureFrequency); + + int32 Best = 0; + float BestScore = FLT_MAX; + for (int32 i = 0; i < Ctx.Biomes.Num(); ++i) + { + const FBiomeResolved& B = Ctx.Biomes[i]; + // Distance in climate space to the biome's box (0 when inside it). + const float dr = (R < B.ReliefMin) ? (B.ReliefMin - R) + : (R > B.ReliefMax) ? (R - B.ReliefMax) : 0.0f; + const float dm = (Mo < B.MoistureMin) ? (B.MoistureMin - Mo) + : (Mo > B.MoistureMax) ? (Mo - B.MoistureMax) : 0.0f; + float Score = dr * dr + dm * dm; + // Tiny deterministic jitter so overlapping boxes don't all collapse to biome 0. + Score += VoxelHash::ToFloat01(VoxelHash::Mix(SiteHash ^ (0x9e3779b9u * (uint32)(i + 1)))) * 1.0e-4f; + if (Score < BestScore) { BestScore = Score; Best = i; } + } + return Best; +} + +FBiomeSample UVoxelGenerator::SampleBiomeAt(float WorldX, float WorldY, const FBiomeContext& Ctx) const +{ + FBiomeSample Out; + if (Ctx.Biomes.Num() == 0) return Out; // biomes disabled + if (Ctx.Biomes.Num() == 1) { Out.DominantIndex = 0; return Out; } + + const FBiomeMapParams& MP = Ctx.Map; + const float Cell = FMath::Max(MP.CellSize, 1.0f); + const uint32 S = (uint32)Seed ^ 0x42494f4du; // 'BIOM' + + // Domain-warp the query so cell borders wind organically (not a hex grid). + float QX = WorldX, QY = WorldY; + if (MP.WarpStrength > 0.0f) + { + const uint32 SeedU = (uint32)Seed; + const float WF = MP.WarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); + QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; + QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; + } + + const int32 CX = FMath::FloorToInt(QX / Cell); + const int32 CY = FMath::FloorToInt(QY / Cell); + + // Worley F1/F2 over the 3x3 neighbourhood of the (warped) cell. With jitter confined + // to [0,1) of a cell, the nearest site is always within this neighbourhood. + float BestD2 = FLT_MAX, SecondD2 = FLT_MAX; + int32 BestBiome = 0, SecondBiome = 0; + for (int32 dy = -1; dy <= 1; ++dy) + for (int32 dx = -1; dx <= 1; ++dx) + { + const int32 nx = CX + dx, ny = CY + dy; + const uint32 h = VoxelHash::Cell(nx, ny, S); + const float jx = VoxelHash::ToFloat01(h); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu)); + const float sx = (nx + jx) * Cell; + const float sy = (ny + jy) * Cell; + const float ddx = sx - QX, ddy = sy - QY; + const float d2 = ddx * ddx + ddy * ddy; + + if (d2 < BestD2) + { + SecondD2 = BestD2; SecondBiome = BestBiome; + BestD2 = d2; BestBiome = ClassifyBiomeAtSite(sx, sy, Ctx, h); + } + else if (d2 < SecondD2) + { + SecondD2 = d2; SecondBiome = ClassifyBiomeAtSite(sx, sy, Ctx, h); + } + } + + Out.DominantIndex = BestBiome; + Out.NeighborIndex = SecondBiome; + + // Blend weight: 0.5 at the shared border (d1≈d2), fading to 0 a BorderBlend-wide + // band inside the dominant cell. Only blend when the neighbour is a DIFFERENT biome. + if (MP.BorderBlend > 0.0f && SecondD2 < FLT_MAX && BestBiome != SecondBiome) + { + const float d1 = FMath::Sqrt(BestD2); + const float d2 = FMath::Sqrt(SecondD2); + const float t = FMath::Clamp((d2 - d1) / FMath::Max(MP.BorderBlend, 1.0f), 0.0f, 1.0f); + Out.NeighborWeight = 0.5f * (1.0f - SmoothStep01(t)); + } + return Out; +} + +void UVoxelGenerator::RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache, + uint64 OwnerId, uint64 ManagerLifetimeId) const +{ + Cache.ChunkZ = ChunkZ; + Cache.Seed = Seed; + Cache.OwnerId = OwnerId; + Cache.ManagerLifetimeId = ManagerLifetimeId; + Cache.bActive = Ctx.IsValid(); + Cache.Ctx = Ctx; + Cache.CellBiome.Reset(); + + if (!Cache.bActive) + { + // Mark the whole chunk footprint valid so non-biome chunks don't rebuild per voxel. + Cache.ValidMinX = (float)ChunkX * CHUNK_SIZE - (float)CHUNK_SIZE; + Cache.ValidMaxX = (float)(ChunkX + 1) * CHUNK_SIZE + (float)CHUNK_SIZE; + Cache.ValidMinY = (float)ChunkY * CHUNK_SIZE - (float)CHUNK_SIZE; + Cache.ValidMaxY = (float)(ChunkY + 1) * CHUNK_SIZE + (float)CHUNK_SIZE; + return; + } + + const FBiomeMapParams& MP = Ctx.Map; + const float Cell = FMath::Max(MP.CellSize, 1.0f); + const uint32 S = (uint32)Seed ^ 0x42494f4du; // 'BIOM' (must match SampleBiomeAt) + + // Validity halo: one chunk beyond the footprint, so the +X/+Y boundary corners and + // ±1 gradient-normal samples stay inside the valid box (no rebuild thrash, §8.10). + const float Halo = (float)CHUNK_SIZE; + Cache.ValidMinX = (float)ChunkX * CHUNK_SIZE - Halo; + Cache.ValidMaxX = (float)(ChunkX + 1) * CHUNK_SIZE + Halo; + Cache.ValidMinY = (float)ChunkY * CHUNK_SIZE - Halo; + Cache.ValidMaxY = (float)(ChunkY + 1) * CHUNK_SIZE + Halo; + + // Cell-grid coverage: the valid box expanded by the max warp displacement + one cell, + // so the 3x3 search around any in-box query's warped point is fully present. + const float CellMargin = MP.WarpStrength * VOXEL_NOISE_SCALE + Cell + 1.0f; + Cache.BaseCellX = FMath::FloorToInt((Cache.ValidMinX - CellMargin) / Cell); + Cache.BaseCellY = FMath::FloorToInt((Cache.ValidMinY - CellMargin) / Cell); + const int32 MaxCellX = FMath::FloorToInt((Cache.ValidMaxX + CellMargin) / Cell); + const int32 MaxCellY = FMath::FloorToInt((Cache.ValidMaxY + CellMargin) / Cell); + Cache.CellsX = MaxCellX - Cache.BaseCellX + 1; + Cache.CellsY = MaxCellY - Cache.BaseCellY + 1; + Cache.CellBiome.SetNumUninitialized(Cache.CellsX * Cache.CellsY); + + for (int32 cy = 0; cy < Cache.CellsY; ++cy) + for (int32 cx = 0; cx < Cache.CellsX; ++cx) + { + const int32 nx = Cache.BaseCellX + cx; + const int32 ny = Cache.BaseCellY + cy; + const uint32 h = VoxelHash::Cell(nx, ny, S); + const float jx = VoxelHash::ToFloat01(h); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu)); + const float sx = (nx + jx) * Cell; + const float sy = (ny + jy) * Cell; + Cache.CellBiome[cy * Cache.CellsX + cx] = ClassifyBiomeAtSite(sx, sy, Ctx, h); + } +} + +FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const +{ + FBiomeSample Out; + const uint64 OwnerId = DensityCacheOwnerId; + const uint64 ManagerLifetimeId = StrateManager + ? StrateManager->GetCacheLifetimeId() : 0; + + // Box-validated rebuild (perf-only; result is the pure function of XY either way). + if (!Cache.Contains(WorldX, WorldY, ChunkZ, Seed, OwnerId, ManagerLifetimeId)) + { + RebuildBiomeGrid(FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ, Ctx, Cache, + OwnerId, ManagerLifetimeId); + } + + if (!Cache.bActive) return Out; + if (Cache.Ctx.Biomes.Num() == 1) { Out.DominantIndex = 0; return Out; } + + const FBiomeMapParams& MP = Cache.Ctx.Map; + const float Cell = FMath::Max(MP.CellSize, 1.0f); + const uint32 S = (uint32)Seed ^ 0x42494f4du; + + // Same warp + 3x3 Worley as SampleBiomeAt → identical assignment (matches the preview). + float QX = WorldX, QY = WorldY; + if (MP.WarpStrength > 0.0f) + { + const uint32 SeedU = (uint32)Seed; + const float WF = MP.WarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); + QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; + QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; + } + + const int32 CX = FMath::FloorToInt(QX / Cell); + const int32 CY = FMath::FloorToInt(QY / Cell); + + float BestD2 = FLT_MAX, SecondD2 = FLT_MAX; + int32 BestBiome = 0, SecondBiome = 0; + for (int32 dy = -1; dy <= 1; ++dy) + for (int32 dx = -1; dx <= 1; ++dx) + { + const int32 nx = CX + dx, ny = CY + dy; + const uint32 h = VoxelHash::Cell(nx, ny, S); + const float jx = VoxelHash::ToFloat01(h); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu)); + const float sx = (nx + jx) * Cell; + const float sy = (ny + jy) * Cell; + const float ddx = sx - QX, ddy = sy - QY; + const float d2 = ddx * ddx + ddy * ddy; + + // Cached biome index (fallback to classify on the rare margin miss → still correct). + const int32 gx = nx - Cache.BaseCellX; + const int32 gy = ny - Cache.BaseCellY; + const int32 BiomeIdx = (gx >= 0 && gx < Cache.CellsX && gy >= 0 && gy < Cache.CellsY) + ? Cache.CellBiome[gy * Cache.CellsX + gx] + : ClassifyBiomeAtSite(sx, sy, Cache.Ctx, h); + + if (d2 < BestD2) { SecondD2 = BestD2; SecondBiome = BestBiome; BestD2 = d2; BestBiome = BiomeIdx; } + else if (d2 < SecondD2) { SecondD2 = d2; SecondBiome = BiomeIdx; } + } + + Out.DominantIndex = BestBiome; + Out.NeighborIndex = SecondBiome; + if (MP.BorderBlend > 0.0f && SecondD2 < FLT_MAX && BestBiome != SecondBiome) + { + const float d1 = FMath::Sqrt(BestD2); + const float d2 = FMath::Sqrt(SecondD2); + const float t = FMath::Clamp((d2 - d1) / FMath::Max(MP.BorderBlend, 1.0f), 0.0f, 1.0f); + Out.NeighborWeight = 0.5f * (1.0f - SmoothStep01(t)); + } + return Out; +} + +const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const +{ + if (!StrateManager) return nullptr; + + const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ); + + const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord); + if (!Ctx.IsValid()) return nullptr; + + const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx); + if (!Ctx.Biomes.IsValidIndex(S.DominantIndex)) return nullptr; + + // Map the context position back to the strate's Biomes[] asset. + const int32 StrateBiomeIdx = Ctx.Biomes[S.DominantIndex].Index; + const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord); + return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr; +} + +void UVoxelGenerator::QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const +{ + Out = FVoxelBiomeQuery(); + if (!StrateManager) return; + + const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ); + const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord); + + // Climate fields are always meaningful (use the strate's map freqs, or defaults when no biomes). + const FBiomeMapParams MP = Ctx.IsValid() ? Ctx.Map : FBiomeMapParams(); + Out.Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast); + Out.Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency); + + if (!Ctx.IsValid()) return; // bHasBiomes stays false → BP knows this strate has no biome field + Out.bHasBiomes = true; + + const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx); + Out.NeighborWeight = S.NeighborWeight; + Out.DominantContextIndex = S.DominantIndex; + + const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord); + + if (Ctx.Biomes.IsValidIndex(S.DominantIndex)) + { + const int32 DomStrateIdx = Ctx.Biomes[S.DominantIndex].Index; + if (Def && Def->Biomes.IsValidIndex(DomStrateIdx)) + { + UVoxelBiomeDefinition* Bio = Def->Biomes[DomStrateIdx]; + Out.DominantBiome = Bio; + if (Bio) + { + Out.DebugColor = Bio->DebugColor; + Out.DominantName = Bio->BiomeName.IsEmpty() ? FText::FromName(Bio->GetFName()) : Bio->BiomeName; + // Effective list = the biome's decorations, or the strate's when the biome has none + // (this is exactly what the per-column scatter falls back to). 0 ⇒ empty band. + Out.DominantDecorationCount = + (Bio->Decorations.Num() > 0) ? Bio->Decorations.Num() : Def->Decorations.Num(); + } + } + } + if (Ctx.Biomes.IsValidIndex(S.NeighborIndex)) + { + const int32 NbStrateIdx = Ctx.Biomes[S.NeighborIndex].Index; + if (Def && Def->Biomes.IsValidIndex(NbStrateIdx)) { Out.NeighborBiome = Def->Biomes[NbStrateIdx]; } + } +} + +void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ, + int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const +{ + OutDominantPalette = 0; + OutNeighborPalette = 0; + OutBlendWeight = 0.0f; + if (!StrateManager) return; + + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), + FMath::FloorToInt(WorldZ / CHUNK_SIZE)); + + // Per-chunk biome context cache, mirroring GetDensityAt: mesher vertices cluster by chunk, + // so the (cheap) flatten + (noise-heavy, box-validated) grid stay warm across a tile. + thread_local FIntVector BM_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local FBiomeContext BM_Ctx; + thread_local FChunkBiomeCache BM_Cache; + thread_local uint32 BM_Version = 0xFFFFFFFFu; // AUDIT C2 + thread_local uint64 BM_OwnerId = 0; + thread_local uint64 BM_ManagerLifetimeId = 0; + + const uint32 BM_LayoutVersion = StrateManager->GetLayoutVersion(); + const uint64 BM_CurrentOwnerId = DensityCacheOwnerId; + const uint64 BM_CurrentManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + if (ChunkCoord != BM_Chunk || BM_LayoutVersion != BM_Version + || BM_CurrentOwnerId != BM_OwnerId + || BM_CurrentManagerLifetimeId != BM_ManagerLifetimeId) + { + if (BM_LayoutVersion != BM_Version || BM_CurrentOwnerId != BM_OwnerId + || BM_CurrentManagerLifetimeId != BM_ManagerLifetimeId) + { + BM_Cache.Invalidate(); + } + BM_Version = BM_LayoutVersion; + BM_OwnerId = BM_CurrentOwnerId; + BM_ManagerLifetimeId = BM_CurrentManagerLifetimeId; + BM_Chunk = ChunkCoord; + BM_Ctx = StrateManager->GetBiomeContextForChunk(ChunkCoord); + } + if (!BM_Ctx.IsValid()) return; // strate has no biomes → default palette + + const FBiomeSample S = ResolveBiomeSampleAt(WorldX, WorldY, ChunkCoord.Z, BM_Ctx, BM_Cache); + if (BM_Ctx.Biomes.IsValidIndex(S.DominantIndex)) + { + OutDominantPalette = BM_Ctx.Biomes[S.DominantIndex].MaterialPaletteIndex; + // Neighbour defaults to the dominant so an interior vertex blends to itself (no seam). + OutNeighborPalette = BM_Ctx.Biomes.IsValidIndex(S.NeighborIndex) + ? BM_Ctx.Biomes[S.NeighborIndex].MaterialPaletteIndex + : OutDominantPalette; + OutBlendWeight = S.NeighborWeight; // 0 inside a cell → ~0.5 at the border + } +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelHeightOpStack.cpp b/Standalone/Source/VoxelForge/Private/VoxelHeightOpStack.cpp new file mode 100644 index 0000000..2e4bd61 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelHeightOpStack.cpp @@ -0,0 +1,497 @@ +// VoxelHeightOpStack.cpp +// Les cinq opérateurs d'espace-hauteur de SurfaceWorld. +// The five height-space operators of SurfaceWorld. +// +// FIDÉLITÉ / FIDELITY +// Chaque corps est une transcription LITTÉRALE du bloc correspondant de +// `SampleSurfaceStructuralZ` / `ComputeSurfaceTerrainZ` — mêmes offsets, mêmes octaves, même ordre +// d'opérations flottantes. Depuis que `FPSemantics = Precise` est posé (AUDIT §C9), l'égalité +// BIT À BIT est atteignable et atteinte pour Maze et Slab : c'est donc la barre ici aussi, et +// `VoxelForge.OpStack.SurfaceHeightEquivalence` la vérifie. +// +// ⚠️ LE DÉTOUR PAR `FVector` EST DÉLIBÉRÉ, comme ailleurs dans ce refactor : `FractalNoise3D` prend +// un `FVector` (donc des DOUBLES en UE5) et re-descend en float. Passer directement des floats +// saute un arrondi. Reproduire le détour, c'est reproduire l'arrondi. +// The FVector round-trip is deliberate: FVector is double in UE5, so the original rounds through a +// double. Going straight through floats skips a rounding step. + +#include "VoxelHeightOp.h" + +#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset — AUDIT §C1 (bounded, site-salted) +#include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D +#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE + +namespace +{ + //========================================================================= + // HELPERS — les mêmes enveloppes que VoxelGenerator.cpp, transcrites + //========================================================================= + // `FractalNoise3D` et `RidgedNoise3D` sont `static` dans VoxelGenerator.cpp, donc invisibles + // ici. Elles sont recopiées à l'identique plutôt qu'exportées : les exporter changerait leur + // contexte d'inlining, et sous /fp:precise comme sous /fp:fast la règle est la même — on ne + // touche à rien de ce qui entoure une expression flottante qu'on veut reproduire. + FORCEINLINE float HFractalNoise3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); + } + + FORCEINLINE float HRidgedNoise3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); + } + + /** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de + * biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */ + FORCEINLINE float HSampleRelief(float WorldX, float WorldY, uint32 SeedU, + float Frequency, float Contrast) + { + float R = HFractalNoise3D(FVector( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f), + VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1] + R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f); + return SmoothStep01(R); + } + + //========================================================================= + // SOURCE — CHAMP STRUCTUREL / STRUCTURAL HEIGHT FIELD + //========================================================================= + // Continents + montagnes + détail, sous une frame de domain-warp. Produit les DEUX canaux. + class FStructuralHeightSource final : public IVoxelHeightOp + { + public: + FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed) + : P(InP), SeedU((uint32)InSeed) {} + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + float M = 1.0f; + InOut.Height = SampleZ(WorldX, WorldY, M); // Replace : racine de pile + InOut.Relief = M; + } + + /** + * Le champ nu, exposé parce que `FCliffHeightMod` doit le RÉ-ÉCHANTILLONNER en différences + * centrées. C'est une dépendance réelle du code d'origine (`ComputeSurfaceTerrainZ` appelle + * `SampleSurfaceStructuralZ` quatre fois de plus), pas un raccourci : la pente doit venir du + * champ STRUCTUREL, sans rétroaction des ops, sinon le cliff se nourrirait de lui-même. + */ + float SampleZ(float WorldX, float WorldY, float& OutM) const + { + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + const float BottomZ = P.StrateBottomWorldZ; + + const float GroundBase = BottomZ + H * P.BaseGroundRelative; + + // Domain-warp des coords STRUCTURELLES (continents + montagnes). Le bruit de détail + // reste sur le vrai XY pour que les bosses fines restent nettes et décorrélées. + float QX = WorldX, QY = WorldY; + if (P.HeightWarpStrength > 0.0f) + { + const float WF = P.HeightWarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f))); + QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength; + QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength; + } + + const float Relief = HSampleRelief(WorldX, WorldY, SeedU, P.ReliefFrequency, P.ReliefContrast); + const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength); + + float Cont = HFractalNoise3D(FVector( + QX * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f), + QY * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f), + VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1] + + float Detail = HFractalNoise3D(FVector( + WorldX * P.DetailFrequency + 11.0f, + WorldY * P.DetailFrequency + 22.0f, + VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1] + + float Mountain = 0.0f; + if (P.MountainStrength > 0.0f) + { + float Ridge = HRidgedNoise3D(FVector( + QX * P.MountainFrequency + 99.0f, + QY * P.MountainFrequency + 77.0f, + VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1] + Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets + Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief + } + + // Les plaines gardent une fraction du gonflement continental ; les hautes terres tout. + const float ContScale = FMath::Lerp(0.45f, 1.0f, M); + + float Terrain = GroundBase + + Cont * P.ElevationRange * 0.5f * ContScale + + Mountain * P.ElevationRange + + Detail * P.SurfaceRoughness; + + OutM = M; + return Terrain; + } + + // Une SOURCE pose l'altitude, elle ne la déplace pas : la notion de « déplacement max » ne + // s'applique pas. La borne d'une colonne se calcule à partir de la source elle-même + // (GroundBase ± ElevationRange ± SurfaceRoughness), pas ici — d'où FLT_MAX, honnête. + float MaxDisplacement() const override { return FLT_MAX; } + + private: + FSurfaceGenerationParams P; + uint32 SeedU; + }; + + //========================================================================= + // MOD — FALAISE / CLIFF (raidissement conditionné par la pente) + //========================================================================= + class FCliffHeightMod final : public IVoxelHeightOp + { + public: + FCliffHeightMod(const FSurfaceGenerationParams& InP, const FStructuralHeightSource* InSrc) + : P(InP), Src(InSrc) {} + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + if (P.CliffStrength <= 0.0f || Src == nullptr) { return; } + + const float D = FMath::Max(P.CliffSampleDist, 0.5f); + float Ms; // relief scratch — on ne veut que les hauteurs + const float Zxp = Src->SampleZ(WorldX + D, WorldY, Ms); + const float Zxm = Src->SampleZ(WorldX - D, WorldY, Ms); + const float Zyp = Src->SampleZ(WorldX, WorldY + D, Ms); + const float Zym = Src->SampleZ(WorldX, WorldY - D, Ms); + const float dZdX = (Zxp - Zxm) / (2.0f * D); + const float dZdY = (Zyp - Zym) / (2.0f * D); + const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY); + + const float Thr = FMath::Max(P.CliffSlopeThreshold, 0.05f); + const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + if (SlopeGate > 0.0f) + { + const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym); + const float Gain = P.CliffStrength * SlopeGate * P.CliffSharpness; + InOut.Height += (InOut.Height - Ref) * Gain; + } + } + + private: + FSurfaceGenerationParams P; + const FStructuralHeightSource* Src; + }; + + //========================================================================= + // MOD — TERRASSES / TERRACE (gaté par le relief : le canal Relief sert ICI) + //========================================================================= + class FTerraceHeightMod final : public IVoxelHeightOp + { + public: + explicit FTerraceHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {} + + void Eval(float, float, FVoxelHeightSample& InOut) const override + { + if (P.TerraceStrength <= 0.0f || P.TerraceHeight <= 0.0f) { return; } + + const float StepH = P.TerraceHeight; + const float T = InOut.Height / StepH; + const float K = FMath::FloorToFloat(T); + const float Frac = T - K; + const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(P.TerraceHardness, 0.0f, 1.0f)); + const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f)); + const float Stepped = (K + Fs) * StepH; + // `* InOut.Relief` : c'est le `* M` de l'original — la raison d'être du second canal. + InOut.Height = FMath::Lerp(InOut.Height, Stepped, P.TerraceStrength * InOut.Relief); + } + + // Le terrace interpole VERS une hauteur quantifiée : l'écart ne dépasse jamais un palier. + float MaxDisplacement() const override + { + return (P.TerraceStrength > 0.0f) ? FMath::Max(P.TerraceHeight, 0.0f) : 0.0f; + } + + private: + FSurfaceGenerationParams P; + }; + + //========================================================================= + // MOD — LIGNES DE STRATES / LAYER LINES + //========================================================================= + class FLayerLineHeightMod final : public IVoxelHeightOp + { + public: + explicit FLayerLineHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {} + + void Eval(float, float, FVoxelHeightSample& InOut) const override + { + if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; } + + const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing); + InOut.Height -= VoxelMath::DetSin(Phase) * P.LayerLineDepth; + } + + // `sin` ∈ [-1,1] ⇒ borne exacte. + float MaxDisplacement() const override + { + return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f; + } + + private: + FSurfaceGenerationParams P; + }; + + //========================================================================= + // MOD — PLAGE / BEACH (aplatissement vers la ligne d'eau) + //========================================================================= + class FBeachHeightMod final : public IVoxelHeightOp + { + public: + explicit FBeachHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {} + + void Eval(float, float, FVoxelHeightSample& InOut) const override + { + // Le niveau d'eau est GLOBAL à la strate (forcé depuis la strate) pour que le plan + // d'eau reste continu — d'où le calcul depuis les bornes de strate, pas depuis un param + // par biome. + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + const float WaterZ = P.StrateBottomWorldZ + H * P.WaterLevelRelative; + if (P.WaterLevelRelative <= 0.0f || P.BeachWidth <= 0.0f) { return; } + + const float DAbs = FMath::Abs(InOut.Height - WaterZ); + if (DAbs < P.BeachWidth) + { + float T = SmoothStep01(DAbs / P.BeachWidth); + InOut.Height = FMath::Lerp(WaterZ, InOut.Height, T); + } + } + + // N'agit que dans `BeachWidth` de l'eau, et ne fait qu'y RAPPROCHER. + float MaxDisplacement() const override + { + return (P.WaterLevelRelative > 0.0f) ? FMath::Max(P.BeachWidth, 0.0f) : 0.0f; + } + + private: + FSurfaceGenerationParams P; + }; + + //========================================================================= + // SOURCE — LE CIEL / SKY CAP (c'est une ALTITUDE, donc c'est un op de hauteur) + //========================================================================= + // `ComputeSurfaceCeiling` rend un Z, exactement comme le terrain. `OPSTACK-DECOMPOSITION §5` le + // range en `FSkyCapSource` côté DENSITÉ (« Subtract »), mais c'est le même glissement que pour + // les ops de terrain : ce que la fonction produit est une hauteur, et la soustraction n'arrive + // qu'après, dans le combine. Le mettre ici lui donne gratuitement l'invariance de fenêtre + // testée, la pureté XY garantie par le type, et le cache de colonne. + // The sky cap returns a Z, so it belongs in height space; the subtraction happens later, in the + // density-side combine. + class FSkyCapHeightSource final : public IVoxelHeightOp + { + public: + FSkyCapHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed) + : P(InP), SeedU((uint32)InSeed) {} + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + float CeilZ = P.StrateBottomWorldZ + H * P.CeilingRelative; + + // Domain-warp des coords larges/ridge (miroir du HeightWarp du sol). Les bosses fines + // restent sur le vrai XY pour rester nettes et décorrélées. 0 ⇒ pas de warp. + float QX = WorldX, QY = WorldY; + if (P.CeilingWarpStrength > 0.0f) + { + const float WF = P.CeilingWarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f))); + QX += wx * VOXEL_NOISE_SCALE * P.CeilingWarpStrength; + QY += wy * VOXEL_NOISE_SCALE * P.CeilingWarpStrength; + } + + // Gonflement large SIGNÉ : monte/descend toute la voûte. + if (P.CeilingUndulation > 0.0f) + { + const float Swell = HFractalNoise3D(FVector( + QX * P.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f), + QY * P.CeilingUndulationFrequency + 13.0f, + VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1] + CeilZ += Swell * VOXEL_NOISE_SCALE * P.CeilingUndulation; + } + + // Pendage vers le BAS uniquement : tout est >= 0, donc rien ne perce vers le haut dans + // le seal. Bosses fines + lames ridgées s'additionnent. + float Hang = 0.0f; + if (P.CeilingRoughness > 0.0f) + { + Hang += FMath::Abs(HFractalNoise3D(FVector( + WorldX * P.CeilingRoughnessFrequency + 5.0f, + WorldY * P.CeilingRoughnessFrequency + 6.0f, + VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * P.CeilingRoughness; + } + if (P.CeilingRidgeStrength > 0.0f) + { + float Ridge = HRidgedNoise3D(FVector( + QX * P.CeilingRidgeFrequency + 31.0f, + QY * P.CeilingRidgeFrequency + 47.0f, + VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1] + Ridge = Ridge * 0.5f + 0.5f; // [0,1] lignes de crête pendantes + Hang += Ridge * P.CeilingRidgeStrength; + } + + InOut.Height = CeilZ - Hang; // Replace : racine de sa propre pile + // Relief laissé intact : le ciel n'en produit pas et personne ne le lui demande. + } + + float MaxDisplacement() const override { return FLT_MAX; } // source, pas modificateur + + private: + FSurfaceGenerationParams P; + uint32 SeedU; + }; + + //========================================================================= + // COMBINER `Mask` — MÉLANGE DE BIOMES / BIOME BLEND + //========================================================================= + // Une pile complète par biome ; le champ dit lequel domine ; on interpole les HAUTEURS. + // + // ⚠️ Chaque pile calcule SON PROPRE relief `M` en interne et l'utilise pour son propre gate de + // terrace — exactement comme l'original, où `ComputeSurfaceTerrainZ(X, Y, *PD)` et + // `(…, *PN)` sont deux appels complets et indépendants dont seules les SORTIES sont mêlées. + // Le canal `Relief` qui ressort ici est celui du DOMINANT : il est informatif, personne en aval + // ne s'en sert pour re-gater quoi que ce soit. + // + // Each biome stack computes its own relief internally and gates its own terrace with it, exactly + // as the original makes two independent full calls and blends only the OUTPUTS. + class FBiomeBlendHeightSource final : public IVoxelHeightOp + { + public: + FBiomeBlendHeightSource(const TArray& PerBiome, int32 Seed, + const IVoxelBiomeField* InField, bool bCeilingOnly) + : Field(InField) + { + Stacks.Reserve(PerBiome.Num()); + for (const FSurfaceGenerationParams& BP : PerBiome) + { + FVoxelHeightStack S; + if (bCeilingOnly) { VoxelHeightOps::BuildSurfaceCeilingStack(S, BP, Seed); } + else { VoxelHeightOps::BuildSurfaceHeightStack(S, BP, Seed); } + Stacks.Add(MoveTemp(S)); + } + bBlend = !bCeilingOnly; // le plafond SÉLECTIONNE, il ne mélange pas + } + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + if (Stacks.Num() == 0) { return; } + + FVoxelBiomeWeights W; + if (Field) { W = Field->SampleAt(WorldX, WorldY); } + + const int32 D = Stacks.IsValidIndex(W.Dominant) ? W.Dominant : 0; + InOut = Stacks[D].EvalSample(WorldX, WorldY); + + // Le plafond ne se mélange pas (voir la fabrique) ; le sol si, et seulement dans la + // bande de frontière où le poids est non nul. + if (bBlend && W.NeighborWeight > 0.0f && Stacks.IsValidIndex(W.Neighbor)) + { + const float HN = Stacks[W.Neighbor].EvalHeight(WorldX, WorldY); + InOut.Height = FMath::Lerp(InOut.Height, HN, W.NeighborWeight); + } + } + + float MaxDisplacement() const override { return FLT_MAX; } // source composite + + private: + TArray Stacks; + const IVoxelBiomeField* Field; + bool bBlend = true; + }; + +} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. + // En dessous commence `namespace VoxelHeightOps` (les fabriques). Y insérer une classe la sort + // de la liaison interne, et l'accolade qu'on ajoute avec elle ne ferme rien → C2059. Erreur + // commise DEUX fois (7cd2bed, puis à nouveau ici) en s'ancrant sur la bannière « FABRIQUES », + // qui est de l'autre côté de cette accolade. + // END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line. Anchoring on the FACTORIES + // banner below puts them outside it, and the brace added with them closes nothing. + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelHeightOps +{ + TUniquePtr MakeBiomeBlendHeightSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field) + { + return MakeUnique(PerBiomeParams, Seed, Field, /*bCeilingOnly*/false); + } + + TUniquePtr MakeBiomeSelectCeilingSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field) + { + return MakeUnique(PerBiomeParams, Seed, Field, /*bCeilingOnly*/true); + } + + TUniquePtr MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, int32 Seed) + { + return MakeUnique(P, Seed); + } + + void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed) + { + // Un seul op aujourd'hui — et c'est une information, pas un manque : le plafond n'a pas + // d'équivalent des quatre modificateurs du sol. Le jour où on veut des terrasses au + // plafond, on ajoute la ligne ; c'est exactement le genre de composition que le refactor + // existe pour rendre possible. + OutStack.Add(MakeSkyCapHeightSource(P, Seed)); + } + TUniquePtr MakeStructuralHeightSource(const FSurfaceGenerationParams& P, int32 Seed, + const IVoxelHeightOp** OutSource) + { + TUniquePtr Src = MakeUnique(P, Seed); + if (OutSource) { *OutSource = Src.Get(); } + return Src; + } + + TUniquePtr MakeCliffHeightMod(const FSurfaceGenerationParams& P, + const IVoxelHeightOp* StructuralSource) + { + // `static_cast` plutôt que `Cast<>` : ce ne sont pas des UObject, et le contrat de la + // fabrique est qu'on lui rend exactement le pointeur sorti de MakeStructuralHeightSource. + return MakeUnique( + P, static_cast(StructuralSource)); + } + + TUniquePtr MakeTerraceHeightMod(const FSurfaceGenerationParams& P) + { + return MakeUnique(P); + } + + TUniquePtr MakeLayerLineHeightMod(const FSurfaceGenerationParams& P) + { + return MakeUnique(P); + } + + TUniquePtr MakeBeachHeightMod(const FSurfaceGenerationParams& P) + { + return MakeUnique(P); + } + + void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed) + { + // L'ORDRE EST CELUI DE `ComputeSurfaceTerrainZ`, et il porte du sens : + // le cliff raidit le champ brut, le terrace quantifie le résultat raidi, les lignes de + // strates se posent dessus, et la plage écrase tout près de l'eau. + const IVoxelHeightOp* Structural = nullptr; + OutStack.Add(MakeStructuralHeightSource(P, Seed, &Structural)); + OutStack.Add(MakeCliffHeightMod(P, Structural)); + OutStack.Add(MakeTerraceHeightMod(P)); + OutStack.Add(MakeLayerLineHeightMod(P)); + OutStack.Add(MakeBeachHeightMod(P)); + } +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp new file mode 100644 index 0000000..1f978f5 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -0,0 +1,1315 @@ +// VoxelMarchingCubesMesher.cpp +// Implémentation du marching cubes density-only. + +#include "VoxelMarchingCubesMesher.h" +#include "MarchingCubesTables.h" +#include "VoxelDensityProfile.h" +#if WITH_EDITOR +#include "VoxelRedefinedDensityField.h" +#endif +#include "VoxelStrateManager.h" +#include "VoxelTilePostReach.h" +#include "VoxelPassageGeometry.h" +#include "ProfilingDebugging/CpuProfilerTrace.h" +#include "HAL/IConsoleManager.h" +#include "Misc/CommandLine.h" +#include + +namespace +{ + int32 GVoxelForgeUseOperatorBlock = 1; + FAutoConsoleVariableRef CVarVoxelForgeUseOperatorBlock( + TEXT("voxel.UseOperatorBlock"), + GVoxelForgeUseOperatorBlock, + TEXT("Use the operator-major density block path for non-shared mesher grids.")); + bool GVoxelForgeBlockSwitchParsed = false; + + int32 GVoxelForgeTilePostReachDebug = 0; + FAutoConsoleVariableRef CVarVoxelForgeTilePostReachDebug( + TEXT("voxel.TilePostReachDebug"), + GVoxelForgeTilePostReachDebug, + TEXT("Run skipped landing/structural posts in a bypass and compare their exact result; " + "a nonzero difference fails the debug run.")); + + float GVoxelForgeTilePostReachScale = 1.0f; + FAutoConsoleVariableRef CVarVoxelForgeTilePostReachScale( + TEXT("voxel.TilePostReachScale"), + GVoxelForgeTilePostReachScale, + TEXT("Scale the per-tile post reach for deliberate soundness tests. Default 1.0 is exact; " + "values below 1 intentionally shrink the guard.")); + + int32 GVoxelForgeTileReachCostDiagnostics = 0; + FAutoConsoleVariableRef CVarVoxelForgeTileReachCostDiagnostics( + TEXT("voxel.TileReachCostDiagnostics"), + GVoxelForgeTileReachCostDiagnostics, + TEXT("Measure core/passage density cost split by tile reach; no field gate.")); + + bool GVoxelForgeTilePostReachSwitchesParsed = false; + + void VF_ParseTilePostReachSwitches() + { + if (GVoxelForgeTilePostReachSwitchesParsed) + { + return; + } + GVoxelForgeTilePostReachSwitchesParsed = true; + int32 DebugValue = GVoxelForgeTilePostReachDebug; + float ScaleValue = GVoxelForgeTilePostReachScale; + int32 CostDiagnosticsValue = GVoxelForgeTileReachCostDiagnostics; + FParse::Value( + FCommandLine::Get(), TEXT("voxel.TilePostReachDebug="), DebugValue); + FParse::Value( + FCommandLine::Get(), TEXT("voxel.TilePostReachScale="), ScaleValue); + FParse::Value( + FCommandLine::Get(), TEXT("voxel.TileReachCostDiagnostics="), CostDiagnosticsValue); + GVoxelForgeTilePostReachDebug = DebugValue != 0 ? 1 : 0; + if (!VoxelMath::IsFinite(ScaleValue) || ScaleValue <= 0.0f) + { + ScaleValue = 1.0f; + } + GVoxelForgeTilePostReachScale = FMath::Min(ScaleValue, 1.0f); + GVoxelForgeTileReachCostDiagnostics = CostDiagnosticsValue != 0 ? 1 : 0; + VoxelGenLOD::GTilePostReachDebugEnabled.store( + GVoxelForgeTilePostReachDebug != 0, std::memory_order_relaxed); + VoxelGenLOD::GTileReachCostDiagnosticsEnabled.store( + GVoxelForgeTileReachCostDiagnostics != 0, std::memory_order_relaxed); + } + + void VF_ParseOperatorBlockSwitch() + { + if (GVoxelForgeBlockSwitchParsed) { return; } + GVoxelForgeBlockSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeUseOperatorBlock; + if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseOperatorBlock="), CommandLineValue)) + { + GVoxelForgeUseOperatorBlock = CommandLineValue; + } + } +} + +void UVoxelMarchingCubesMesher::InitializeConsoleSwitches() +{ + VF_ParseOperatorBlockSwitch(); + VF_ParseTilePostReachSwitches(); +} + +//============================================================================= +// MAIN ALGORITHM +//============================================================================= +// La grille pré-échantillonnée fournit positions ET gradients. + +FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis, + TArray* OutCaptureGrid, + int32 BandZMinVox, int32 BandZMaxVox, + int64* OutDensitySampleCount, + FVoxelSharedDensityGrid* OutPreEditDensityGrid, + const FVoxelSharedDensityGrid* InSharedDensityGrid) +{ + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_MesherGenerateMesh); + FVoxelMeshData MeshData; + if (OutCaptureGrid) { OutCaptureGrid->Reset(); } + if (OutPreEditDensityGrid) { OutPreEditDensityGrid->Reset(); } + if (OutDensitySampleCount) { *OutDensitySampleCount = 0; } + if (!Generator) return MeshData; + VoxelDensityProfile::FScopedTimer MesherProfileTimer( + VoxelDensityProfile::EBucket::MesherGenerateMesh); + VoxelDensityProfile::FScopedTimer MesherSetupTimer( + VoxelDensityProfile::EBucket::MesherOther); + + // Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT + // grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen. + Step = FMath::Max(1, Step); + + // T2.b — octave bias for THIS tile: drop LODOctaveDrop octaves per Step doubling from + // the generator's per-voxel volumetric noise (sub-cell octaves can't shape a coarse + // isosurface). TGuardValue restores 0 on every exit path, so nothing outside this tile + // (deco snapping, density-volume fill, the next task on this pooled thread) sees a bias. + const int32 OctaveBias = (LODOctaveDrop > 0 && Step > 1) + ? LODOctaveDrop * (int32)FMath::FloorLog2((uint32)Step) : 0; + TGuardValue OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias); + TGuardValue SampleStepGuard(VoxelGenLOD::SampleStep, Step); + + // World-cm origin of the tile's min corner (positions are built relative to this). + const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE; + + //========================================================================= + // VERTEX DEDUPLICATION MAP + //========================================================================= + // Clé = position quantifiée au 0.01 unité (FIntVector). + // Valeur = index dans MeshData.Vertices. + // Les vertices partagés permettent des normales lisses et réduisent le count ~3x. + // Réutilisé d'une tuile à l'autre (thread_local) : Reset garde les buckets alloués → + // plus de (ré)allocation de hash-map par tuile (chaque worker a son propre exemplaire). + static thread_local TMap VertexMap; + VertexMap.Reset(); + + //========================================================================= + // F17 — CLASSE DE SURFACE (sol vs plafond sky-cap), par vertex → par triangle + //========================================================================= + // Le discriminant est SÉMANTIQUE, pas géométrique (fable-idea F17) : un vertex orienté vers + // le bas est un sky-cap seulement s'il est proche de CeilSurf ; proche de TerrainZ c'est un + // surplomb de terrain (reste "sol" — un vote par tuile mettrait le matériau ciel sous les + // surplombs des tuiles majoritairement plafond). Un futur toit de grotte (aussi down- + // facing, mais SOUS TerrainZ) tombera correctement côté "sol/roche" par la même règle. + // Coût : GetSurfaceHeightAt (pile XY complète) UNIQUEMENT pour les vertex down-facing, + // mémoïsé par colonne quantifiée au pas de la grille → ≤ (colonnes touchées) appels ; + // ~zéro sur une tuile de sol pur, borné par lattice² sur une tuile de cap. + static thread_local TArray VertexClasses; // 0 = sol, 1 = sky-cap + static thread_local TMap SurfColMemo; // (Xq,Yq,chunkZ) → (TerrainZ, CeilSurf) ; X=FLT_MAX ⇒ pas SurfaceWorld + VertexClasses.Reset(); + SurfColMemo.Reset(); + + // Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b) — + // plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la + // normalise ici (fallback up si dégénérée). + auto GetOrCreateVertex = [&](const FVector& WorldPos, const FVector& RawNormal) -> int32 + { + const FIntVector Key( + FMath::RoundToInt(WorldPos.X * 100.0f), + FMath::RoundToInt(WorldPos.Y * 100.0f), + FMath::RoundToInt(WorldPos.Z * 100.0f) + ); + + if (int32* Existing = VertexMap.Find(Key)) + { + return *Existing; + } + + const int32 NewIndex = MeshData.Vertices.Num(); + VertexMap.Add(Key, NewIndex); + + MeshData.Vertices.Add(WorldPos); + + FVector Normal = RawNormal; + if (!Normal.Normalize()) + { + Normal = FVector(0.0f, 0.0f, 1.0f); // dégénéré (zone plate) + } + MeshData.Normals.Add(Normal); + + // UVs planaires — le triplanar mapping se fait dans le matériau. + MeshData.UVs.Add(FVector2D(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE)); + + //--------------------------------------------------------------------- + // VERTEX COLOUR — masques pour le matériau triplanar maître (F6). + // R = index de palette du biome DOMINANT (0-255) — re-skin par biome. + // G = pente (0 = sol/plafond plat, 1 = paroi verticale) — roche sur falaises. + // B = poids de fondu de bordure (0 au cœur d'un biome → ~0.5 à la frontière). + // A = index de palette du biome VOISIN — le matériau lerp(R,A) par B → bords sans couture. + // La hauteur/snow-line se déduit de WorldPosition.Z dans le matériau (pas besoin de canal). + // Coût: une résolution biome par vertex UNIQUE (déduplication) ; nul si la strate n'a pas + // de biomes (GetBiomeMaterialAt sort en O(1) → palette 0 partout). + int32 PalD = 0, PalN = 0; float BlendW = 0.0f; + Generator->GetBiomeMaterialAt(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE, + WorldPos.Z / VOXEL_SIZE, PalD, PalN, BlendW); + const uint8 R = (uint8)FMath::Clamp(PalD, 0, 255); + const uint8 A = (uint8)FMath::Clamp(PalN, 0, 255); + const uint8 G = (uint8)FMath::Clamp(FMath::RoundToInt((1.0f - FMath::Abs((float)Normal.Z)) * 255.0f), 0, 255); + const uint8 Bc = (uint8)FMath::Clamp(FMath::RoundToInt(BlendW * 255.0f), 0, 255); + MeshData.Colors.Add(FColor(R, G, Bc, A)); + + // F17 — classe de surface (voir le bloc en tête de fonction). Down-facing seulement : + // les vertex de sol/falaise (N.Z ≥ -0.1) sont "sol" sans payer la requête colonne. + uint8 SurfClass = 0; + if (Normal.Z < -0.1f) + { + const float Zv = WorldPos.Z / VOXEL_SIZE; + const float StepF = (float)Step; + const FIntVector MemoKey( + FMath::RoundToInt(WorldPos.X / (VOXEL_SIZE * StepF)), + FMath::RoundToInt(WorldPos.Y / (VOXEL_SIZE * StepF)), + FMath::FloorToInt(Zv / (float)CHUNK_SIZE)); + FVector2f* Col = SurfColMemo.Find(MemoKey); + if (!Col) + { + float TerrainZ = 0.0f, CeilSurf = 0.0f; + // Un vertex du cap vit à la FRONTIÈRE HAUTE de la strate : arrondi/interpolation + // peuvent le faire flotter dans le chunk gap/seal juste AU-DESSUS (hors + // SurfaceWorld ⇒ sonde ratée ⇒ faux "sol"). On retente un chunk plus bas. + const bool bSurf = Generator->GetSurfaceHeightAt( + (float)(MemoKey.X * Step), (float)(MemoKey.Y * Step), MemoKey.Z, + TerrainZ, CeilSurf) + || Generator->GetSurfaceHeightAt( + (float)(MemoKey.X * Step), (float)(MemoKey.Y * Step), MemoKey.Z - 1, + TerrainZ, CeilSurf); + Col = &SurfColMemo.Add(MemoKey, bSurf ? FVector2f(TerrainZ, CeilSurf) + : FVector2f(FLT_MAX, -FLT_MAX)); + } + // Plus proche du plafond que du terrain ⇒ sky-cap. (Hors SurfaceWorld : sol.) + // + garde : un vrai vertex de cap est AU niveau du cap (± slop d'interpolation) ; + // loin AU-DESSUS = plancher/toit de la strate d'à côté atteint via le retry chunkZ-1 + // (strates contiguës) → reste sol. + if (Col->X != FLT_MAX && FMath::Abs(Zv - Col->Y) < FMath::Abs(Zv - Col->X) + && Zv <= Col->Y + 2.0f * StepF) + { + SurfClass = 1; + } + } + VertexClasses.Add(SurfClass); + + return NewIndex; + }; + + //========================================================================= + // CORNER + EDGE TABLES + //========================================================================= + // 4-------5 + // /| /| + // / | / | + // 7-------6 | + // | 0----|--1 + // | / | / + // |/ |/ + // 3-------2 + static const FIntVector CornerOffsets[8] = { + {0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, + {0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}, + }; + + static const int32 EdgeCorners[12][2] = { + {0, 1}, {1, 2}, {2, 3}, {3, 0}, // Bas (0-3) + {4, 5}, {5, 6}, {6, 7}, {7, 4}, // Haut (4-7) + {0, 4}, {1, 5}, {2, 6}, {3, 7}, // Verticales (8-11) + }; + + //========================================================================= + // PRÉ-CALCUL DE LA GRILLE DE DENSITÉ + //========================================================================= + // Les cellules adjacentes partagent leurs coins : en échantillonnant par + // cellule (8 coins) on appelle GetDensityAt ~8× de trop pour chaque point. + // On échantillonne donc chaque point de grille UNE SEULE FOIS dans un tableau + // plat, puis le balayage des cellules y lit ses coins. GetDensityAt est une + // fonction pure de la coordonnée monde, donc le maillage est identique au bit + // près — c'est juste ~7× moins d'appels au LOD0 (33³ au lieu de 32³×8). + // + // CellsPerAxis = nombre de cellules par axe ; GridDim = points de grille (+1). + // Le point de grille (gx,gy,gz) correspond au voxel monde (gx,gy,gz)*Step. + // Ordre de remplissage z→y→x : garde le cache SDF (search-box) bien chaud. + // On échantillonne avec un anneau de marge de 1 point de chaque côté (indices -1..GridDim) + // pour pouvoir calculer les normales par GRADIENT DE GRILLE (T1.b) — différences centrales + // sur la grille au lieu de 6 appels densité frais par vertex. La marge utilise les mêmes + // échantillons monde purs qu'un chunk voisin, donc les normales restent continues aux bords + // de chunk (pas de couture de shading). La géométrie est inchangée au bit près (mêmes + // positions d'arête) ; seules les normales changent. + const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer + const int32 GridDim = CellsPerAxis + 1; + const int32 MDim = GridDim + 2; // +1 marge de chaque côté + TGuardValue TileOriginGuard( + VoxelGenLOD::TileOriginVoxels, OriginVoxels); + TGuardValue TileCellsGuard( + VoxelGenLOD::TileCellsPerAxis, CellsPerAxis); + + // Every density sample in this tile sees the same post-reach decision. The one-point halo + // matches the actual grid fill below (gx/gy/gz = -1..GridDim), so a false bit proves that no + // landing or passage structural writer can reach any sample used by meshing or gradients. + TGuardValue TilePostReachDebugGuard( + VoxelGenLOD::bTilePostReachDebug, + GVoxelForgeTilePostReachDebug != 0); + TGuardValue TilePostReachBypassGuard( + VoxelGenLOD::bTilePostReachBypass, false); + TGuardValue TileReachDiagnosticGuard( + VoxelGenLOD::bTileReachDiagnosticTile, true); + TGuardValue TileCoreReachProofGuard( + VoxelGenLOD::bTileCoreReachProofEnabled, false); + TGuardValue TileCoreReachDecisionGuard( + VoxelGenLOD::bTileCoreReachDecisionRecorded, false); + TGuardValue TileReachScaleGuard( + VoxelGenLOD::TileReachScale, GVoxelForgeTilePostReachScale); + const float ReachScale = GVoxelForgeTilePostReachScale; + const int32 ReachBlocksPerAxis = FMath::DivideAndRoundUp( + CellsPerAxis, VoxelGenLOD::TileReachBlockCells); + TGuardValue TileReachBlocksGuard( + VoxelGenLOD::TileReachBlocksPerAxis, ReachBlocksPerAxis); + TGuardValue TileReachBlockIndexGuard( + VoxelGenLOD::TileReachBlockIndex, INDEX_NONE); + TGuardValue TileBlockReachValidGuard( + VoxelGenLOD::bTileBlockReachValid, false); + uint8 TilePostReachFlags = VoxelGenLOD::AllTilePostReach; + if (Generator->StrateManager != nullptr) + { + const int64 Extent = static_cast(CellsPerAxis + 1) * Step; + const FBox LatticeWithHalo( + FVector( + static_cast(static_cast(OriginVoxels.X) - Step), + static_cast(static_cast(OriginVoxels.Y) - Step), + static_cast(static_cast(OriginVoxels.Z) - Step)), + FVector( + static_cast(static_cast(OriginVoxels.X) + Extent), + static_cast(static_cast(OriginVoxels.Y) + Extent), + static_cast(static_cast(OriginVoxels.Z) + Extent))); + // Core stays conservative until the tile-window tunnel cache is built. Passage carving + // is manager-owned and can be decided immediately from the same lattice+halo box. + TilePostReachFlags = VoxelGenLOD::TunnelCoreReachable; + if (Generator->StrateManager->AnyOriginLandingNearLattice( + LatticeWithHalo, OriginVoxels, Step, ReachScale)) + { + TilePostReachFlags |= VoxelGenLOD::OriginLandingReachable; + } + if (Generator->StrateManager->AnyPassageLandingNearLattice( + LatticeWithHalo, OriginVoxels, Step, ReachScale)) + { + TilePostReachFlags |= VoxelGenLOD::PassageLandingReachable; + } + if (Generator->StrateManager->AnyPassageCarvingNearLattice( + LatticeWithHalo, OriginVoxels, Step, ReachScale)) + { + TilePostReachFlags |= VoxelGenLOD::PassageCarvingReachable; + } + if (Generator->StrateManager->AnyPassageStructuralPostNearLattice( + LatticeWithHalo, OriginVoxels, Step, ReachScale)) + { + TilePostReachFlags |= VoxelGenLOD::PassageStructuralReachable; + } + } + TGuardValue TilePostReachFlagsGuard( + VoxelGenLOD::TilePostReachFlags, TilePostReachFlags); + for (int32 BlockZ = 0; BlockZ < ReachBlocksPerAxis; ++BlockZ) + { + for (int32 BlockY = 0; BlockY < ReachBlocksPerAxis; ++BlockY) + { + for (int32 BlockX = 0; BlockX < ReachBlocksPerAxis; ++BlockX) + { + const int32 BlockIndex = + ((BlockZ * ReachBlocksPerAxis) + BlockY) * ReachBlocksPerAxis + BlockX; + uint8 BlockFlags = TilePostReachFlags; + if (Generator->StrateManager != nullptr + && (TilePostReachFlags & VoxelGenLOD::PassageCarvingReachable) != 0 + && !Generator->StrateManager->AnyPassageCarvingNearLattice( + VoxelGenLOD::MakeTileReachBlockBox( + OriginVoxels, Step, CellsPerAxis, + BlockX, BlockY, BlockZ), + OriginVoxels, Step, ReachScale)) + { + BlockFlags &= ~VoxelGenLOD::PassageCarvingReachable; + } + VoxelGenLOD::TileBlockReachFlags[BlockIndex] = BlockFlags; + } + } + } + VoxelGenLOD::bTileBlockReachValid = true; + VoxelGenLOD::RecordTileReachMask(TilePostReachFlags); + // COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules + // dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs). + // Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous). + if (OutCaptureGrid || OutPreEditDensityGrid) + { + BandZMinVox = INT32_MIN; + BandZMaxVox = INT32_MAX; + } + int32 CzLo = 0, CzHi = CellsPerAxis - 1; + if (BandZMinVox > INT32_MIN || BandZMaxVox < INT32_MAX) + { + auto FloorDivI = [](int64 A, int64 B) -> int32 + { + const int64 Q = A / B; + return (int32)(Q - (((A % B) != 0 && ((A < 0) != (B < 0))) ? 1 : 0)); + }; + // Cellule c couvre [O.Z + c*Step, O.Z + (c+1)*Step] : première/dernière cellule contenant + // la borne (un contact purement tangent est exclu — sa traversée serait hors bande). + CzLo = FMath::Max(CzLo, FloorDivI((int64)BandZMinVox - OriginVoxels.Z, Step)); + CzHi = FMath::Min(CzHi, FloorDivI((int64)BandZMaxVox - OriginVoxels.Z, Step)); + if (CzHi < CzLo) { return MeshData; } // tuile entièrement hors bande → vide + } + const int32 GzLo = CzLo - 1; // coins CzLo..CzHi+1, gradients ±1 + const int32 GzHi = CzHi + 2; // (== -1..GridDim sans bande) + + MesherSetupTimer.End(); + + // Optional 8x8x8-cell block proof. The tile classifier has already returned Mixed, but + // that is a conservative answer over the whole tile. A tile can still be uniformly solid + // (or uniformly air) when its smaller blocks are each provable. Do this before allocating + // or filling the density grid: the grid is only needed when at least one block can contain + // a surface. A whole-tile early return is allowed only when every relevant block has the + // SAME verdict; adjacent uniform blocks with opposite signs can contain a surface between + // them. This is a proof-only optimization: ClassifyTile is conservative and no samples or + // resolution are removed from a tile that reaches the mesher. + TArray BlockSkip; + int32 BlocksPerAxis = 0; + // LOD2's 16-cell tile is the remaining false-Mixed empty case in the streamed game path. + // Its step-8 lattice can use the same proof-only 8-cell block partition as LOD0. Keep the + // gate off for steps 2/16/32: their meshing work is already smaller than repeating a full + // tile classifier per block, and this preserves the established coarse-LOD latency profile. + const bool bUseProofBlocks = bUseBlockEarlyOut && !OutCaptureGrid + && (Step == 1 || Step == 8); + if (bUseProofBlocks) + { + constexpr int32 BlockCells = 8; + BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells; + BlockSkip.Init(0, BlocksPerAxis * BlocksPerAxis * BlocksPerAxis); + bool bHaveUniformBlock = false; + EVoxelTileClass FirstUniformVerdict = EVoxelTileClass::Mixed; + bool bAllRelevantBlocksUniform = true; + bool bAllRelevantBlocksSame = true; + TArray MixedBlockIndices; + + for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ) + { + const int32 BlockMinZ = BlockZ * BlockCells; + const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1, + BlockMinZ + BlockCells - 1); + if (BlockMaxZ < CzLo || BlockMinZ > CzHi) + { + continue; + } + for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY) + { + for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX) + { + const FIntVector BlockOrigin = OriginVoxels + FIntVector( + BlockX * BlockCells * Step, + BlockY * BlockCells * Step, + BlockMinZ * Step); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherBlockTests); + } + const bool bAttributeClassifier = + VoxelDensityProfile::GetMode() + == VoxelDensityProfile::EMode::Attribution; + const bool bSampleClassifier = bAttributeClassifier + && VoxelDensityProfile::ShouldSample( + VoxelDensityProfile::EBucket::ClassifierTotal); + const uint64 ClassifierStartCycles = bSampleClassifier + ? FPlatformTime::Cycles64() : 0; + FVoxelTileClassificationStats ClassifierStats; + if (bAttributeClassifier) + { + VoxelDensityProfile::RecordClassifierCall(bSampleClassifier); + } + const EVoxelTileClass Verdict = Generator->ClassifyTile( + BlockOrigin, Step, BlockCells, + bSampleClassifier ? &ClassifierStats : nullptr); + if (bSampleClassifier) + { + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierTotal, + FPlatformTime::Cycles64() - ClassifierStartCycles); + VoxelDensityProfile::FSnapshot::FClassifierStats ProfileStats; + ProfileStats.RefineNodes = ClassifierStats.RefineNodes; + ProfileStats.StackBoxCalls = ClassifierStats.StackBoxCalls; + ProfileStats.WholeMixedNodes = ClassifierStats.WholeMixedNodes; + ProfileStats.WholeSolidNodes = ClassifierStats.WholeSolidNodes; + ProfileStats.WholeAirNodes = ClassifierStats.WholeAirNodes; + ProfileStats.NeedsFinalFieldNodes = ClassifierStats.NeedsFinalFieldNodes; + ProfileStats.SplitNodes = ClassifierStats.SplitNodes; + ProfileStats.MaxRefinementDepth = ClassifierStats.MaxRefinementDepth; + ProfileStats.ExactCoreSamples = ClassifierStats.ExactCoreSamples; + ProfileStats.ExactFinalSamples = ClassifierStats.ExactFinalSamples; + ProfileStats.ExactCoreCacheHits = ClassifierStats.ExactCoreCacheHits; + ProfileStats.ExactFinalCacheHits = ClassifierStats.ExactFinalCacheHits; + ProfileStats.ExactCoreLeaves = ClassifierStats.ExactCoreLeaves; + ProfileStats.ExactFinalLeaves = ClassifierStats.ExactFinalLeaves; + ProfileStats.StackBoxCycles = ClassifierStats.StackBoxCycles; + ProfileStats.ExactCoreCycles = ClassifierStats.ExactCoreCycles; + ProfileStats.ExactFinalCycles = ClassifierStats.ExactFinalCycles; + ProfileStats.RoomTailQueries = ClassifierStats.RoomTailQueries; + ProfileStats.RoomTailEvaluated = ClassifierStats.RoomTailEvaluated; + ProfileStats.RoomTailCycles = ClassifierStats.RoomTailCycles; + ProfileStats.RoomPropagateCycles = ClassifierStats.RoomPropagateCycles; + ProfileStats.RoomExactPrimitiveCycles = ClassifierStats.RoomExactPrimitiveCycles; + ProfileStats.RoomCacheWindowCycles = ClassifierStats.RoomCacheWindowCycles; + ProfileStats.RoomNumRooms = FMath::Max(ClassifierStats.RoomNumRooms, 0); + ProfileStats.RoomNumTunnels = FMath::Max(ClassifierStats.RoomNumTunnels, 0); + ProfileStats.RoomNumRoomFloorJoins = FMath::Max( + ClassifierStats.RoomNumRoomFloorJoins, 0); + ProfileStats.RoomNumPits = FMath::Max(ClassifierStats.RoomNumPits, 0); + ProfileStats.RoomNumChimneys = FMath::Max(ClassifierStats.RoomNumChimneys, 0); + VoxelDensityProfile::AddClassifierStats(ProfileStats); + + if (ClassifierStats.StackBoxCalls > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierIntervalProof, + 0, ClassifierStats.StackBoxCalls); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierIntervalProof, + ClassifierStats.StackBoxCycles); + } + if (ClassifierStats.ExactCoreLeaves > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactCore, + 0, ClassifierStats.ExactCoreLeaves); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactCore, + ClassifierStats.ExactCoreCycles); + } + if (ClassifierStats.ExactFinalLeaves > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactFinal, + 0, ClassifierStats.ExactFinalLeaves); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierExactFinal, + ClassifierStats.ExactFinalCycles); + } + if (ClassifierStats.RoomTailQueries > 0) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::ClassifierRoomTail, + 0, ClassifierStats.RoomTailQueries); + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::ClassifierRoomTail, + ClassifierStats.RoomTailCycles); + } + } + if (Verdict == EVoxelTileClass::Mixed) + { + MixedBlockIndices.Add( + ((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX); + } + if (Verdict == EVoxelTileClass::AllSolid + || Verdict == EVoxelTileClass::AllAir) + { + // Keep the classifier result as a candidate until the exact density grid + // used by this mesher has been filled. ClassifyTile is conservative by + // contract, but its proof and the mesher share worker-local caches; a + // cache replacement must never turn that contract into a cell skip by + // itself. The post-grid sign check below is the final proof consumed by + // the cell loop and clears any candidate whose actual MC vertices disagree. + BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] = + Verdict == EVoxelTileClass::AllSolid ? 1 : 2; + if (!bHaveUniformBlock) + { + FirstUniformVerdict = Verdict; + bHaveUniformBlock = true; + } + else if (FirstUniformVerdict != Verdict) + { + bAllRelevantBlocksSame = false; + } + } + else + { + bAllRelevantBlocksUniform = false; + } + } + } + } + + // A conservative whole-tile proof can leave a small block unresolved even when every + // other block has the same verdict. Resolve that tail on the exact MC lattice. This uses + // the final GetDensityAt field, checks every cell corner, treats zero/non-finite values + // as unknown, and never changes the grid resolution or skips an uncertified block. + if (bHaveUniformBlock && bAllRelevantBlocksSame && !bAllRelevantBlocksUniform + && MixedBlockIndices.Num() <= 4) + { + int32 RemainingMixedBlocks = MixedBlockIndices.Num(); + for (const int32 MixedBlockIndex : MixedBlockIndices) + { + const int32 BlockX = MixedBlockIndex % BlocksPerAxis; + const int32 BlockY = (MixedBlockIndex / BlocksPerAxis) % BlocksPerAxis; + const int32 BlockZ = MixedBlockIndex / (BlocksPerAxis * BlocksPerAxis); + const FIntVector BlockOrigin = OriginVoxels + FIntVector( + BlockX * BlockCells * Step, + BlockY * BlockCells * Step, + BlockZ * BlockCells * Step); + + bool bAllSolid = true; + bool bAllAir = true; + for (int32 Z = 0; Z <= BlockCells && (bAllSolid || bAllAir); ++Z) + { + for (int32 Y = 0; Y <= BlockCells && (bAllSolid || bAllAir); ++Y) + { + for (int32 X = 0; X <= BlockCells; ++X) + { + const float Density = Generator->GetDensityAt( + BlockOrigin.X + X * Step, + BlockOrigin.Y + Y * Step, + BlockOrigin.Z + Z * Step); + if (!VoxelMath::IsFinite(Density) || Density == 0.0f) + { + bAllSolid = false; + bAllAir = false; + break; + } + bAllSolid &= Density < 0.0f; + bAllAir &= Density > 0.0f; + if (!bAllSolid && !bAllAir) + { + break; + } + } + } + } + + const EVoxelTileClass ExactVerdict = bAllSolid + ? EVoxelTileClass::AllSolid + : (bAllAir ? EVoxelTileClass::AllAir : EVoxelTileClass::Mixed); + if (ExactVerdict == EVoxelTileClass::AllSolid + || ExactVerdict == EVoxelTileClass::AllAir) + { + BlockSkip[MixedBlockIndex] = + ExactVerdict == EVoxelTileClass::AllSolid ? 1 : 2; + --RemainingMixedBlocks; + if (ExactVerdict != FirstUniformVerdict) + { + bAllRelevantBlocksSame = false; + } + } + } + bAllRelevantBlocksUniform = RemainingMixedBlocks == 0; + } + + // Every relevant block has independently proved the same sign. This is a complete + // certificate for the tile's MC lattice: the block boxes share their boundary vertices, + // so equal signs on both sides cannot hide a surface between blocks. The whole-tile + // ClassifyTile path already relies on this same non-Mixed proof contract. Return before + // allocating the density grid; no samples are skipped for a tile that can contain + // geometry, and the final geometry remains byte-identical because this path emits none. + if (bHaveUniformBlock && bAllRelevantBlocksSame && bAllRelevantBlocksUniform) + { + return MeshData; + } + + // A mixed or differently signed block set still needs the exact mesher grid. Classifier + // results remain candidates only in that case; the grid validation below keeps the cell + // skip independent of worker-local cache state and preserves geometry byte-for-byte. + } + + // Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la + // capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile. + static thread_local TArray DensityGrid; + DensityGrid.SetNumUninitialized(MDim * MDim * MDim); + const FVoxelSharedDensityGrid* ReuseGrid = InSharedDensityGrid != nullptr + ? InSharedDensityGrid : SharedDensityGrid; + const int32 SharedDeltaX = ReuseGrid + ? (OriginVoxels.X - ReuseGrid->OriginVoxels.X) / Step : 0; + const int32 SharedDeltaY = ReuseGrid + ? (OriginVoxels.Y - ReuseGrid->OriginVoxels.Y) / Step : 0; + const int32 SharedDeltaZ = ReuseGrid + ? (OriginVoxels.Z - ReuseGrid->OriginVoxels.Z) / Step : 0; + const bool bUseSharedDensityGrid = ReuseGrid + && ReuseGrid->Step == Step + && ReuseGrid->Dim > 0 + && ReuseGrid->Samples.Num() == ReuseGrid->Dim * ReuseGrid->Dim * ReuseGrid->Dim + && (OriginVoxels.X - ReuseGrid->OriginVoxels.X) % Step == 0 + && (OriginVoxels.Y - ReuseGrid->OriginVoxels.Y) % Step == 0 + && (OriginVoxels.Z - ReuseGrid->OriginVoxels.Z) % Step == 0 + && SharedDeltaX - 1 >= 0 + && SharedDeltaY - 1 >= 0 + && SharedDeltaZ - 1 >= 0 + && SharedDeltaX + GridDim < ReuseGrid->Dim + && SharedDeltaY + GridDim < ReuseGrid->Dim + && SharedDeltaZ + GridDim < ReuseGrid->Dim; + const bool bCapturePreEditGrid = OutPreEditDensityGrid != nullptr + && !bUseSharedDensityGrid + && Step == 1 && CellsPerAxis == CHUNK_SIZE + && CzLo == 0 && CzHi == CellsPerAxis - 1; + if (bCapturePreEditGrid) + { + OutPreEditDensityGrid->OriginVoxels = OriginVoxels + FIntVector(-Step, -Step, -Step); + OutPreEditDensityGrid->Step = Step; + OutPreEditDensityGrid->Dim = MDim; + OutPreEditDensityGrid->Samples.SetNumUninitialized(MDim * MDim * MDim); + } + // Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de + // gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu. + VoxelDensityProfile::FScopedTimer MesherDensityGridTimer( + VoxelDensityProfile::EBucket::MesherDensityGrid); +#if WITH_EDITOR + const bool bUseParameterizedBrickEvaluator = + VoxelForgeRedefinedDensityField::IsParameterizedEnabled() + && !bUseSharedDensityGrid && !bCapturePreEditGrid + && OutCaptureGrid == nullptr; +#else + constexpr bool bUseParameterizedBrickEvaluator = false; +#endif + const bool bUseOperatorBlock = !bUseSharedDensityGrid + && !bUseParameterizedBrickEvaluator + && GVoxelForgeUseOperatorBlock != 0; + if (bUseOperatorBlock) + { + // The grid includes one sample of halo on every side. The generator groups these exact + // lattice points by chunk key and lets the prepared op stack fill each group once. + Generator->BeginDensityBlock( + OriginVoxels + FIntVector(-Step, -Step, GzLo * Step), + Step, MDim, MDim, GzHi - GzLo + 1); + } + if (bUseParameterizedBrickEvaluator) + { +#if WITH_EDITOR + static std::atomic bLoggedParameterizedBrickRoute{false}; + if (!bLoggedParameterizedBrickRoute.exchange(true, std::memory_order_relaxed)) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeResearchBrickEvaluator] mesher_grid=1 brick_samples=%d detail_scale=%.6f"), + VoxelForgeRedefinedDensityField::ParameterizedBrickSamples, + VoxelForgeRedefinedDensityField::GetDetailScale()); + } + constexpr int32 ResearchBrickSamples = + VoxelForgeRedefinedDensityField::ParameterizedBrickSamples; + const float DetailScale = VoxelForgeRedefinedDensityField::GetDetailScale(); + TArray DetailBrick; + TArray BrickValues; + TArray ColumnValues; + TArray ProofKinds; + VoxelForgeRedefinedDensityField::FParameterizedBrickContext BrickContext; + const int32 GridZSamples = GzHi - GzLo + 1; + const int32 ProofZCount = FMath::DivideAndRoundUp(GridZSamples, ResearchBrickSamples); + ProofKinds.SetNumUninitialized(ProofZCount); + for (int32 BrickY = 0; BrickY < MDim; BrickY += ResearchBrickSamples) + for (int32 BrickX = 0; BrickX < MDim; BrickX += ResearchBrickSamples) + { + if (ShouldAbortWork()) + { + return FVoxelMeshData(); + } + const int32 BrickSizeX = FMath::Min(ResearchBrickSamples, MDim - BrickX); + const int32 BrickSizeY = FMath::Min(ResearchBrickSamples, MDim - BrickY); + for (int32 ProofZ = 0; ProofZ < ProofZCount; ++ProofZ) + { + if (ShouldAbortWork()) + { + return FVoxelMeshData(); + } + const int32 BrickZ = ProofZ * ResearchBrickSamples; + const FIntVector ProofSize( + BrickSizeX, BrickSizeY, + FMath::Min(ResearchBrickSamples, GridZSamples - BrickZ)); + const FIntVector ProofOrigin = OriginVoxels + FIntVector( + (BrickX - 1) * Step, + (BrickY - 1) * Step, + (GzLo + BrickZ) * Step); + + bool bUniformAir = false; + if (VoxelForgeRedefinedDensityField::TryClassifyParameterizedBrick( + *Generator, ProofOrigin, ProofSize, bUniformAir, DetailScale, + nullptr, nullptr, Step)) + { + ProofKinds[ProofZ] = bUniformAir ? 1 : 2; + const float UniformDensity = bUniformAir + ? VoxelForgeRedefinedDensityField::ParameterizedUniformDensity + : -VoxelForgeRedefinedDensityField::ParameterizedUniformDensity; + for (int32 Z = 0; Z < ProofSize.Z; ++Z) + for (int32 Y = 0; Y < ProofSize.Y; ++Y) + for (int32 X = 0; X < ProofSize.X; ++X) + { + const int32 GX = BrickX + X - 1; + const int32 GY = BrickY + Y - 1; + const int32 GZ = GzLo + BrickZ + Z; + const int32 LocalIndex = + ((GZ + 1) * MDim + (GY + 1)) * MDim + (GX + 1); + DensityGrid[LocalIndex] = UniformDensity; + } + } + else + { + ProofKinds[ProofZ] = 0; + } + } + + for (int32 ProofZ = 0; ProofZ < ProofZCount;) + { + if (ProofKinds[ProofZ] != 0) + { + ++ProofZ; + continue; + } + if (ShouldAbortWork()) + { + return FVoxelMeshData(); + } + + const int32 FirstMixedProofZ = ProofZ; + const int32 BrickZ = FirstMixedProofZ * ResearchBrickSamples; + const int32 FirstWorldZ = OriginVoxels.Z + (GzLo + BrickZ) * Step; + const int32 FirstChunkZ = FMath::FloorToInt( + static_cast(FirstWorldZ) / CHUNK_SIZE); + int32 RunProofCount = 1; + int32 RunSampleCount = FMath::Min( + ResearchBrickSamples, GridZSamples - BrickZ); + while (ProofZ + RunProofCount < ProofZCount + && ProofKinds[ProofZ + RunProofCount] == 0) + { + const int32 CandidateSampleCount = FMath::Min( + (RunProofCount + 1) * ResearchBrickSamples, + GridZSamples - BrickZ); + const int32 CandidateEndZ = FirstWorldZ + + (CandidateSampleCount - 1) * Step; + const int32 CandidateChunkZ = FMath::FloorToInt( + static_cast(CandidateEndZ) / CHUNK_SIZE); + if (CandidateSampleCount > FMath::Max(1, CHUNK_SIZE / Step) + || CandidateChunkZ != FirstChunkZ) + { + break; + } + ++RunProofCount; + RunSampleCount = CandidateSampleCount; + } + + const FIntVector BrickSize(BrickSizeX, BrickSizeY, RunSampleCount); + const FIntVector BrickOrigin = OriginVoxels + FIntVector( + (BrickX - 1) * Step, + (BrickY - 1) * Step, + (GzLo + BrickZ) * Step); + VoxelForgeRedefinedDensityField::EvaluateParameterizedDensityBrick( + *Generator, BrickOrigin, BrickSize, DetailScale, BrickContext, + DetailBrick, ColumnValues, BrickValues, Step); + for (int32 Z = 0; Z < BrickSize.Z; ++Z) + for (int32 Y = 0; Y < BrickSize.Y; ++Y) + for (int32 X = 0; X < BrickSize.X; ++X) + { + const int32 GX = BrickX + X - 1; + const int32 GY = BrickY + Y - 1; + const int32 GZ = GzLo + BrickZ + Z; + const int32 LocalIndex = + ((GZ + 1) * MDim + (GY + 1)) * MDim + (GX + 1); + DensityGrid[LocalIndex] = + BrickValues[(Z * BrickSize.Y + Y) * BrickSize.X + X]; + } + if (OutDensitySampleCount != nullptr) + { + *OutDensitySampleCount += static_cast( + BrickSize.X * BrickSize.Y * BrickSize.Z); + } + ProofZ += RunProofCount; + } + } +#endif + } + else for (int32 gz = GzLo; gz <= GzHi; gz++) + { + if (ShouldAbortWork()) + { + if (bUseOperatorBlock) { Generator->EndDensityBlock(); } + return FVoxelMeshData(); + } + for (int32 gy = -1; gy <= GridDim; gy++) + { + if (ShouldAbortWork()) + { + if (bUseOperatorBlock) { Generator->EndDensityBlock(); } + return FVoxelMeshData(); + } + for (int32 gx = -1; gx <= GridDim; gx++) + { + const int32 LocalIndex = ((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1); + if (bUseSharedDensityGrid) + { + const int32 SharedX = SharedDeltaX + gx; + const int32 SharedY = SharedDeltaY + gy; + const int32 SharedZ = SharedDeltaZ + gz; + DensityGrid[LocalIndex] = ReuseGrid->Samples[ + (SharedZ * ReuseGrid->Dim + SharedY) * ReuseGrid->Dim + SharedX]; + } + else + { + // World voxel = tile origin + grid offset scaled by the cell size (Step). + const int32 ReachBlockX = FMath::Clamp(gx, 0, CellsPerAxis - 1) + / VoxelGenLOD::TileReachBlockCells; + const int32 ReachBlockY = FMath::Clamp(gy, 0, CellsPerAxis - 1) + / VoxelGenLOD::TileReachBlockCells; + const int32 ReachBlockZ = FMath::Clamp(gz, 0, CellsPerAxis - 1) + / VoxelGenLOD::TileReachBlockCells; + VoxelGenLOD::TileReachBlockIndex = + ((ReachBlockZ * ReachBlocksPerAxis) + ReachBlockY) + * ReachBlocksPerAxis + ReachBlockX; + float PreEditDensity = 0.0f; + DensityGrid[LocalIndex] = Generator->GetDensityAt( + OriginVoxels.X + gx * Step, + OriginVoxels.Y + gy * Step, + OriginVoxels.Z + gz * Step, + bCapturePreEditGrid ? &PreEditDensity : nullptr); + if (bCapturePreEditGrid) + { + OutPreEditDensityGrid->Samples[LocalIndex] = PreEditDensity; + } + if (OutDensitySampleCount) + { + ++(*OutDensitySampleCount); + } + } + } + } + } + if (VoxelDensityProfile::AreCountersEnabled()) + { + const uint64 GridSampleCount = static_cast(GzHi - GzLo + 1) + * static_cast(MDim) + * static_cast(MDim); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherDensityGridBytes, + GridSampleCount * sizeof(float)); + if (bUseSharedDensityGrid) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherSharedGridReadBytes, + GridSampleCount * sizeof(float)); + } + } + if (bUseOperatorBlock) + { + Generator->EndDensityBlock(); + } + MesherDensityGridTimer.End(); + + if (bUseProofBlocks) + { + // Finalize every block candidate against the exact density samples that the marching- + // cubes loop will consume. This is intentionally redundant with ClassifyTile: it is a + // cheap soundness fence around the proof/cache hand-off. Unknown, zero, or opposite-sign + // vertices clear the candidate, so an unproven block pays the ordinary cell path. + constexpr int32 BlockCells = 8; + for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ) + { + const int32 BlockMinZ = BlockZ * BlockCells; + const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1, + BlockMinZ + BlockCells - 1); + if (BlockMaxZ < CzLo || BlockMinZ > CzHi) + { + continue; + } + for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY) + for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX) + { + const int32 BlockIndex = + ((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX; + const uint8 Candidate = BlockSkip[BlockIndex]; + if (Candidate == 0) + { + continue; + } + + const int32 BlockMaxX = FMath::Min(CellsPerAxis - 1, + BlockX * BlockCells + BlockCells - 1); + const int32 BlockMaxY = FMath::Min(CellsPerAxis - 1, + BlockY * BlockCells + BlockCells - 1); + bool bUniform = true; + for (int32 Z = BlockMinZ; Z <= BlockMaxZ + 1 && bUniform; ++Z) + for (int32 Y = BlockY * BlockCells; Y <= BlockMaxY + 1 && bUniform; ++Y) + for (int32 X = BlockX * BlockCells; X <= BlockMaxX + 1; ++X) + { + const float Density = DensityGrid[ + ((Z + 1) * MDim + (Y + 1)) * MDim + (X + 1)]; + if (!VoxelMath::IsFinite(Density) + || Density == 0.0f + || (Candidate == 1 ? Density >= 0.0f : Density <= 0.0f)) + { + bUniform = false; + break; + } + } + if (!bUniform) + { + BlockSkip[BlockIndex] = 0; + continue; + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + Candidate == 1 + ? VoxelDensityProfile::ECounter::MesherBlockAllSolid + : VoxelDensityProfile::ECounter::MesherBlockAllAir); + } + } + } + } + + // ── CAPTURE-DURING-MESHING ── + // Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1<SetNumUninitialized(CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE); + uint8* Cap = OutCaptureGrid->GetData(); + int32 ci = 0; + for (int32 gz = 0; gz < CHUNK_SIZE; ++gz) + for (int32 gy = 0; gy < CHUNK_SIZE; ++gy) + for (int32 gx = 0; gx < CHUNK_SIZE; ++gx) + { + Cap[ci++] = VF_QuantizeDensity(DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)]); + } + } + + if (OutPreEditDensityGrid && !bCapturePreEditGrid) + { + OutPreEditDensityGrid->Reset(); + } + + // Lecture grille (avec offset de marge) + gradient central depuis la grille. + auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float + { + return DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)]; + }; + auto GradAt = [&](int32 gx, int32 gy, int32 gz) -> FVector + { + // Densité négative=solide, positive=air → le gradient pointe vers l'air (sortant). + return FVector( + SampleG(gx + 1, gy, gz) - SampleG(gx - 1, gy, gz), + SampleG(gx, gy + 1, gz) - SampleG(gx, gy - 1, gz), + SampleG(gx, gy, gz + 1) - SampleG(gx, gy, gz - 1)); + }; + + //========================================================================= + // ITÉRATION SUR LES CELLULES + //========================================================================= + // On itère sur les CELLULES (indices de grille), pas sur les voxels monde. + // Coin i de la cellule = point de grille (cx+ox, cy+oy, cz+oz) ; coord voxel + // monde = ce point × Step. LOD0 Step=1 → full res ; LOD1 Step=2 → ~4× moins. + // F17 — les triangles sont émis dans DEUX seaux (sol / sky-cap, vote majoritaire des + // classes de vertex) puis concaténés sol-puis-cap : RMC exige un run d'indices contigu + // par polygroup. Géométrie inchangée au bit près — seul l'ORDRE des triangles bouge. + static thread_local TArray GroundTris; + static thread_local TArray CapTris; + GroundTris.Reset(); + CapTris.Reset(); + const bool bProfileMesher = VoxelDensityProfile::IsCycleTimingEnabled(); + uint64 CellClassificationCalls = 0; + uint64 SurfaceCellCalls = 0; + for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut + { + if (ShouldAbortWork()) return FVoxelMeshData(); + for (int32 cy = 0; cy < CellsPerAxis; cy++) + { + if (ShouldAbortWork()) return FVoxelMeshData(); + for (int32 cx = 0; cx < CellsPerAxis; cx++) + { + if (bUseProofBlocks + && BlockSkip[((cz / 8) * BlocksPerAxis + (cy / 8)) * BlocksPerAxis + (cx / 8)] != 0) + { + continue; + } + const bool bSampleCell = bProfileMesher + && VoxelDensityProfile::ShouldSample( + VoxelDensityProfile::EBucket::MesherCellClassification); + const uint64 ClassificationStart = bSampleCell + ? FPlatformTime::Cycles64() : 0; + // PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT. + // ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ; + // on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures + // grille + maths vectorielles). Sortie bit-identique : positions et gradients ne + // servent qu'aux cellules réellement traversées par l'isosurface. + float Densities[8]; + int32 CaseIndex = 0; + for (int32 i = 0; i < 8; i++) + { + const float D = SampleG(cx + CornerOffsets[i].X, + cy + CornerOffsets[i].Y, + cz + CornerOffsets[i].Z); + Densities[i] = D; + if (D >= IsoLevel) CaseIndex |= (1 << i); + } + + const bool bHasSurface = MCEdgeTable[CaseIndex] != 0; + if (bSampleCell) + { + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::MesherCellClassification, + FPlatformTime::Cycles64() - ClassificationStart); + } + ++CellClassificationCalls; + if (!bHasSurface) continue; // Pas de surface ici → skip + ++SurfaceCellCalls; + + // PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente). + const uint64 GradientStart = bSampleCell + ? FPlatformTime::Cycles64() : 0; + FVector Positions[8]; + FVector Gradients[8]; + for (int32 i = 0; i < 8; i++) + { + const int32 GX = cx + CornerOffsets[i].X; + const int32 GY = cy + CornerOffsets[i].Y; + const int32 GZ = cz + CornerOffsets[i].Z; + Positions[i] = ChunkWorldPos + + FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE; + Gradients[i] = GradAt(GX, GY, GZ); + } + if (bSampleCell) + { + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::MesherGradientNormals, + FPlatformTime::Cycles64() - GradientStart); + } + + // Interpolation des positions + normales sur les arêtes traversées. t = point de + // traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ; + // la normale interpole les gradients de coin par le même t. + const uint64 VertexInterpolationStart = bSampleCell + ? FPlatformTime::Cycles64() : 0; + FVector EdgeVertices[12]; + FVector EdgeNormals[12]; + for (int32 i = 0; i < 12; i++) + { + if (MCEdgeTable[CaseIndex] & (1 << i)) + { + const int32 A = EdgeCorners[i][0]; + const int32 B = EdgeCorners[i][1]; + const float D1 = Densities[A], D2 = Densities[B]; + const float T = (FMath::Abs(D2 - D1) < KINDA_SMALL_NUMBER) + ? 0.5f + : FMath::Clamp((IsoLevel - D1) / (D2 - D1), 0.0f, 1.0f); + EdgeVertices[i] = Positions[A] + T * (Positions[B] - Positions[A]); + EdgeNormals[i] = Gradients[A] + T * (Gradients[B] - Gradients[A]); + } + } + if (bSampleCell) + { + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::MesherVertexInterpolation, + FPlatformTime::Cycles64() - VertexInterpolationStart); + } + + // Génère les triangles avec vertices dédupliqués. + // Ordre 0, 2, 1 (pas 0, 1, 2) pour le winding attendu par RealtimeMesh. + const uint64 StreamBuildingStart = bSampleCell + ? FPlatformTime::Cycles64() : 0; + for (int32 i = 0; MCTriTable[CaseIndex][i] != -1; i += 3) + { + const int32 E0 = MCTriTable[CaseIndex][i]; + const int32 E1 = MCTriTable[CaseIndex][i + 1]; + const int32 E2 = MCTriTable[CaseIndex][i + 2]; + const int32 Idx0 = GetOrCreateVertex(EdgeVertices[E0], EdgeNormals[E0]); + const int32 Idx1 = GetOrCreateVertex(EdgeVertices[E1], EdgeNormals[E1]); + const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]); + + // F17 — vote majoritaire (≥ 2 vertex sky-cap ⇒ triangle sky-cap). + const int32 CapVotes = (int32)VertexClasses[Idx0] + + (int32)VertexClasses[Idx1] + + (int32)VertexClasses[Idx2]; + TArray& Dst = (CapVotes >= 2) ? CapTris : GroundTris; + Dst.Add(Idx0); + Dst.Add(Idx2); + Dst.Add(Idx1); + } + if (bSampleCell) + { + VoxelDensityProfile::AddSampledMeasurement( + VoxelDensityProfile::EBucket::MesherStreamBuilding, + FPlatformTime::Cycles64() - StreamBuildingStart); + } + } + } + } + + if (bProfileMesher) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherCellClassification, + 0, CellClassificationCalls); + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherGradientNormals, + 0, SurfaceCellCalls); + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherVertexInterpolation, + 0, SurfaceCellCalls); + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherStreamBuilding, + 0, SurfaceCellCalls); + } + + VoxelDensityProfile::FScopedTimer MesherFinalisationTimer( + VoxelDensityProfile::EBucket::MesherOther); + + //========================================================================= + // SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap) + //========================================================================= + // Deux tuiles de niveaux voisins maillent à des résolutions différentes : leurs iso-surfaces + // ne se rejoignent pas parfaitement le long de la face partagée → une fine fissure traversante. + // On la scelle en suspendant une courte « jupe » (mur) sous chaque arête de surface posée sur + // l'une des 6 faces externes de la tuile, extrudée VERS LE SOLIDE le long de la normale inversée + // sur ~une taille de cellule. Là où la surface du voisin est décalée, les jupes des deux tuiles + // se recouvrent dans la roche et ferment le trou ; ailleurs la jupe est enterrée et invisible. + // Émis en DOUBLE FACE (deux orientations) pour s'afficher quel que soit le côté caméra / le + // matériau. Une arête de surface posée sur une face de frontière a sa coordonnée d'axe EXACTE + // (l'interpolation MC garde fixe l'axe de la face) → comparaison flottante exacte fiable. + MeshData.NumSurfaceVertices = MeshData.Vertices.Num(); + if (bGenerateSkirts && (GroundTris.Num() + CapTris.Num()) > 0) + { + const float ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE; + const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z; + const float MaxX = MinX + ExtentCm, MaxY = MinY + ExtentCm, MaxZ = MinZ + ExtentCm; + const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * VOXEL_SIZE; + + auto OnBoundaryPlane = [&](const FVector& A, const FVector& B) -> bool + { + return (A.X == MinX && B.X == MinX) || (A.X == MaxX && B.X == MaxX) + || (A.Y == MinY && B.Y == MinY) || (A.Y == MaxY && B.Y == MaxY) + || (A.Z == MinZ && B.Z == MinZ) || (A.Z == MaxZ && B.Z == MaxZ); + }; + + auto AddSkirtVert = [&](const FVector& Pos, const FVector& Nrm, const FColor& Col) -> int32 + { + const int32 Idx = MeshData.Vertices.Num(); + MeshData.Vertices.Add(Pos); + MeshData.Normals.Add(Nrm); + MeshData.UVs.Add(FVector2D(Pos.X / VOXEL_SIZE, Pos.Y / VOXEL_SIZE)); + MeshData.Colors.Add(Col); // hérite la couleur du vertex source (parallèle aux autres tableaux) + return Idx; + }; + + // F17 — jupes émises PAR SEAU : chaque jupe hérite la classe (donc le polygroup / + // matériau) de son triangle source ; ajouter les jupes après coup casserait les runs + // d'indices contigus par groupe qu'exige RMC. + auto EmitSkirts = [&](TArray& Tris) + { + // On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà. + const int32 BaseTriNum = Tris.Num(); + for (int32 t = 0; t + 2 < BaseTriNum; t += 3) + { + const int32 Tri[3] = { Tris[t], Tris[t + 1], Tris[t + 2] }; + for (int32 e = 0; e < 3; ++e) + { + const int32 iA = Tri[e], iB = Tri[(e + 1) % 3]; + // COPIES par valeur — AddSkirtVert réalloue Vertices/Normals (invaliderait des refs). + const FVector PA = MeshData.Vertices[iA]; + const FVector PB = MeshData.Vertices[iB]; + if (!OnBoundaryPlane(PA, PB)) continue; + + const FVector NA = MeshData.Normals[iA]; + const FVector NB = MeshData.Normals[iB]; + const FColor CA = MeshData.Colors[iA]; + const FColor CB = MeshData.Colors[iB]; + const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA); + const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB); + + // Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations. + Tris.Add(iA); Tris.Add(iB); Tris.Add(iB2); + Tris.Add(iA); Tris.Add(iB2); Tris.Add(iA2); + Tris.Add(iA); Tris.Add(iB2); Tris.Add(iB); + Tris.Add(iA); Tris.Add(iA2); Tris.Add(iB2); + } + } + MeshData.NumSkirtTriangles += (Tris.Num() - BaseTriNum) / 3; + }; + EmitSkirts(GroundTris); + EmitSkirts(CapTris); + } + + // F17 — concatène sol PUIS sky-cap : un run d'indices contigu par polygroup (voir + // FVoxelMeshData::NumCeilingTriangles). Mêmes triangles qu'avant, seul l'ordre change. + MeshData.Triangles.Reserve(GroundTris.Num() + CapTris.Num()); + MeshData.Triangles.Append(GroundTris); + MeshData.Triangles.Append(CapTris); + MeshData.NumCeilingTriangles = CapTris.Num() / 3; + + if (VoxelDensityProfile::AreCountersEnabled()) + { + const uint64 OutputArrayBytes = static_cast(MeshData.Vertices.Num()) * sizeof(FVector) + + static_cast(MeshData.Normals.Num()) * sizeof(FVector) + + static_cast(MeshData.UVs.Num()) * sizeof(FVector2D) + + static_cast(MeshData.Colors.Num()) * sizeof(FColor) + + static_cast(MeshData.Triangles.Num()) * sizeof(int32); + const uint64 OutputAllocatedBytes = static_cast(MeshData.Vertices.GetAllocatedSize()) + + static_cast(MeshData.Normals.GetAllocatedSize()) + + static_cast(MeshData.UVs.GetAllocatedSize()) + + static_cast(MeshData.Colors.GetAllocatedSize()) + + static_cast(MeshData.Triangles.GetAllocatedSize()); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherOutputArrayBytes, + OutputArrayBytes); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherOutputAllocatedBytes, + OutputAllocatedBytes); + } + + return MeshData; +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelSeasonAsset.cpp b/Standalone/Source/VoxelForge/Private/VoxelSeasonAsset.cpp new file mode 100644 index 0000000..aa198a4 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelSeasonAsset.cpp @@ -0,0 +1,135 @@ +#include "VoxelSeasonAsset.h" + +#include "VoxelSeasonManifest.h" +#include "VoxelDensityOpStack.h" + +#if WITH_EDITOR +#include "Misc/FileHelper.h" +#endif + +namespace +{ + bool ValidateRuntimeRecipes(const FVoxelSeasonManifest& Manifest, FString& OutReport) + { + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + if (!Strate.bUsesRecipe) continue; + FVoxelOpStack ValidationStack; + FVoxelOpContext ValidationContext; + FString BuildError; + if (!VF_BuildSeasonStrateStack( + Strate, Manifest.OriginSpineRadius, nullptr, + ValidationStack, ValidationContext, &BuildError)) + { + OutReport = FString::Printf( + TEXT("season slot %d recipe is not materialisable: %s"), + Strate.DepthIndex, *BuildError); + return false; + } + } + return true; + } +} + +FPrimaryAssetId UVoxelSeasonAsset::GetPrimaryAssetId() const +{ + return FPrimaryAssetId(TEXT("VoxelSeason"), GetFName()); +} + +bool UVoxelSeasonAsset::LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const +{ + if (ManifestJson.IsEmpty()) + { + OutReport = TEXT("season asset contains no imported manifest JSON"); + OutManifest = FVoxelSeasonManifest(); + return false; + } + + if (!VF_DeserializeVoxelSeasonManifest(ManifestJson, OutManifest, OutReport)) + { + return false; + } + if (!OutManifest.ContentHash.Equals(ManifestContentHash, ESearchCase::IgnoreCase) + || OutManifest.SchemaVersion != SchemaVersion + || OutManifest.Season != SeasonNumber + || OutManifest.Seed != SeasonSeed + || FMemory::Memcmp(&OutManifest.OriginSpineRadius, + &OriginSpineRadius, sizeof(float)) != 0 + || FMemory::Memcmp(&OutManifest.WorldRadiusVoxels, + &WorldRadiusVoxels, sizeof(float)) != 0) + { + OutReport = TEXT("season asset metadata does not match its embedded manifest; re-import the JSON"); + OutManifest.bValid = false; + OutManifest.Error = OutReport; + return false; + } + if (!ValidateRuntimeRecipes(OutManifest, OutReport)) + { + OutManifest.bValid = false; + OutManifest.Error = OutReport; + return false; + } + return true; +} + +bool UVoxelSeasonAsset::SetManifestJson(const FString& InManifestJson, FString& OutReport) +{ + FVoxelSeasonManifest Parsed; + if (!VF_DeserializeVoxelSeasonManifest(InManifestJson, Parsed, OutReport)) + { + return false; + } + if (!ValidateRuntimeRecipes(Parsed, OutReport)) + { + return false; + } + + Modify(); + ManifestJson = InManifestJson; + SchemaVersion = Parsed.SchemaVersion; + SeasonNumber = Parsed.Season; + SeasonSeed = Parsed.Seed; + OriginSpineRadius = Parsed.OriginSpineRadius; + WorldRadiusVoxels = Parsed.WorldRadiusVoxels; + ManifestContentHash = Parsed.ContentHash; + ReferencedDefinitions.Reset(); + TSet SeenDefinitionPaths; + for (const FVoxelSeasonStrate& Strate : Parsed.Strates) + { + if (!Strate.SourceDefinitionPath.IsEmpty() + && !SeenDefinitionPaths.Contains(Strate.SourceDefinitionPath)) + { + SeenDefinitionPaths.Add(Strate.SourceDefinitionPath); + ReferencedDefinitions.Add(TSoftObjectPtr( + FSoftObjectPath(Strate.SourceDefinitionPath))); + } + } + MarkPackageDirty(); + OutReport = FString::Printf( + TEXT("imported season %d seed %d (%d strates, hash %s)"), + SeasonNumber, SeasonSeed, Parsed.Strates.Num(), *ManifestContentHash); + return true; +} + +void UVoxelSeasonAsset::ImportSeasonManifestJson() +{ +#if WITH_EDITOR + FString JsonText; + FString Report; + if (SourceManifestJson.FilePath.IsEmpty() + || !FFileHelper::LoadFileToString(JsonText, *SourceManifestJson.FilePath)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Could not read manifest JSON '%s'."), + *SourceManifestJson.FilePath); + return; + } + if (!SetManifestJson(JsonText, Report)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] Import failed: %s"), *Report); + return; + } + UE_LOG(LogTemp, Log, TEXT("[VoxelSeasonAsset] %s"), *Report); +#else + UE_LOG(LogTemp, Error, TEXT("[VoxelSeasonAsset] JSON import is editor-only.")); +#endif +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelSeasonManifest.cpp b/Standalone/Source/VoxelForge/Private/VoxelSeasonManifest.cpp new file mode 100644 index 0000000..9b015d2 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelSeasonManifest.cpp @@ -0,0 +1,3177 @@ +// Offline Tier 4c season composition, manifest persistence, and review artifact. + +#include "VoxelSeasonManifest.h" + +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "HAL/FileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Misc/SecureHash.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "UObject/EnumProperty.h" +#include "UObject/FieldIterator.h" +#include "UObject/UnrealType.h" + +#include "VoxelDensityOpStack.h" +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.h" +#include "VoxelStratePreview.h" +#include "VoxelCaveMorphology.h" + +#include + +namespace VoxelSeasonManifestPrivate +{ + enum class EScalarKind : uint8 + { + Float, + Integer, + Boolean, + }; + + struct FScalarValue + { + EScalarKind Kind = EScalarKind::Float; + double Number = 0.0; + bool Boolean = false; + }; + + bool IsTunnelArchetype(ECaveGeneratorType Archetype) + { + return Archetype == ECaveGeneratorType::TunnelNetwork + || Archetype == ECaveGeneratorType::Underwater; + } + + bool IsSupportedArchetype(ECaveGeneratorType Archetype) + { + 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: + return true; + default: + return false; + } + } + + UStruct* ParamStruct(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return FStrateGenerationParams::StaticStruct(); + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return FSlabGenerationParams::StaticStruct(); + case ECaveGeneratorType::Maze: + return FMazeGenerationParams::StaticStruct(); + case ECaveGeneratorType::SurfaceWorld: + return FSurfaceGenerationParams::StaticStruct(); + case ECaveGeneratorType::VerticalShafts: + return FVerticalShaftParams::StaticStruct(); + case ECaveGeneratorType::FloatingIslands: + return FFloatingIslandParams::StaticStruct(); + default: + return nullptr; + } + } + + const void* ParamMemory(const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + void* ParamMemory(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + template + double NativeNumber(T Value) + { + if constexpr (std::is_enum::value) + { + using Underlying = typename std::underlying_type::type; + return static_cast(static_cast(Value)); + } + else + { + return static_cast(Value); + } + } + + template + EScalarKind NativeKind() + { + using ValueType = typename std::remove_cv< + typename std::remove_reference::type>::type; + if (std::is_same::value) + { + return EScalarKind::Boolean; + } + if (std::is_integral::value || std::is_enum::value) + { + return EScalarKind::Integer; + } + return EScalarKind::Float; + } + + template + typename std::enable_if::value, void>::type + AssignNative(T& Target, double Value) + { + Target = static_cast(Value); + } + + template + typename std::enable_if::value, void>::type + AssignNative(T& Target, double Value) + { + using Underlying = typename std::underlying_type::type; + Target = static_cast(static_cast(FMath::RoundToInt(Value))); + } + + template + void AssignNativeScalar(T& Target, const FScalarValue& Value) + { + using ValueType = typename std::remove_cv::type; + if constexpr (std::is_same::value) + { + Target = Value.Boolean; + } + else + { + AssignNative(Target, Value.Number); + } + } + + template + bool NativeBoolValue(T Value) + { + using ValueType = typename std::remove_cv::type; + if constexpr (std::is_same::value) + { + return Value; + } + else + { + return NativeNumber(Value) != 0.0; + } + } + + bool IsScalarProperty(const FProperty* Property) + { + return CastField(Property) != nullptr + || CastField(Property) != nullptr + || CastField(Property) != nullptr; + } + + bool ReadProperty(const FProperty* Property, const void* Memory, FScalarValue& OutValue) + { + if (Property == nullptr || Memory == nullptr || !IsScalarProperty(Property)) + { + return false; + } + const void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* Bool = CastField(Property)) + { + OutValue.Kind = EScalarKind::Boolean; + OutValue.Boolean = Bool->GetPropertyValue(ValuePtr); + OutValue.Number = OutValue.Boolean ? 1.0 : 0.0; + return true; + } + + const FNumericProperty* Numeric = CastField(Property); + const FNumericProperty* Underlying = Numeric; + if (const FEnumProperty* Enum = CastField(Property)) + { + Underlying = Enum->GetUnderlyingProperty(); + } + if (Underlying == nullptr) + { + return false; + } + OutValue.Kind = Underlying->IsFloatingPoint() + ? EScalarKind::Float : EScalarKind::Integer; + OutValue.Number = Underlying->IsFloatingPoint() + ? Underlying->GetFloatingPointPropertyValue(ValuePtr) + : static_cast(Underlying->GetSignedIntPropertyValue(ValuePtr)); + return FMath::IsFinite(OutValue.Number); + } + + bool WriteProperty(const FProperty* Property, void* Memory, const FScalarValue& Value) + { + if (Property == nullptr || Memory == nullptr || !IsScalarProperty(Property)) + { + return false; + } + void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* Bool = CastField(Property)) + { + if (Value.Kind != EScalarKind::Boolean) + { + return false; + } + Bool->SetPropertyValue(ValuePtr, Value.Boolean); + return true; + } + + const FNumericProperty* Numeric = CastField(Property); + const FNumericProperty* Underlying = Numeric; + if (const FEnumProperty* Enum = CastField(Property)) + { + Underlying = Enum->GetUnderlyingProperty(); + } + if (Underlying == nullptr || !FMath::IsFinite(Value.Number)) + { + return false; + } + if (Underlying->IsFloatingPoint()) + { + if (Value.Kind != EScalarKind::Float) + { + return false; + } + Underlying->SetFloatingPointPropertyValue(ValuePtr, Value.Number); + } + else + { + if (Value.Kind != EScalarKind::Integer + || FMath::FloorToDouble(Value.Number) != Value.Number) + { + return false; + } + Underlying->SetIntPropertyValue(ValuePtr, static_cast(Value.Number)); + } + return true; + } + + bool ReadTunnelField(const FStrateGenerationParams& Params, + const FString& FieldName, FScalarValue& OutValue) + { +#define VF_SEASON_READ_TUNNEL(Name) \ + if (FieldName == TEXT(#Name)) \ + { \ + OutValue.Kind = NativeKind(); \ + OutValue.Number = NativeNumber(Params.Name); \ + OutValue.Boolean = NativeBoolValue(Params.Name); \ + return FMath::IsFinite(OutValue.Number); \ + } + VF_STRATE_PARAM_FIELDS(VF_SEASON_READ_TUNNEL, VF_SEASON_READ_TUNNEL) +#undef VF_SEASON_READ_TUNNEL + return false; + } + + bool WriteTunnelField(FStrateGenerationParams& Params, + const FString& FieldName, const FScalarValue& Value) + { +#define VF_SEASON_WRITE_TUNNEL(Name) \ + if (FieldName == TEXT(#Name)) \ + { \ + if (Value.Kind != NativeKind()) return false; \ + AssignNativeScalar(Params.Name, Value); \ + return true; \ + } + VF_STRATE_PARAM_FIELDS(VF_SEASON_WRITE_TUNNEL, VF_SEASON_WRITE_TUNNEL) +#undef VF_SEASON_WRITE_TUNNEL + return false; + } + + bool ReadRuntimeField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, FScalarValue& OutValue) + { + if (Memory == nullptr || (FieldName != TEXT("StrateTopWorldZ") + && FieldName != TEXT("StrateBottomWorldZ"))) + { + return false; + } + const bool bTop = FieldName == TEXT("StrateTopWorldZ"); + float Number = 0.0f; + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::Maze: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::SurfaceWorld: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::VerticalShafts: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + case ECaveGeneratorType::FloatingIslands: + Number = bTop ? static_cast(Memory)->StrateTopWorldZ + : static_cast(Memory)->StrateBottomWorldZ; + break; + default: + return false; + } + OutValue.Kind = EScalarKind::Float; + OutValue.Number = Number; + return FMath::IsFinite(Number); + } + + bool WriteRuntimeField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, const FScalarValue& Value) + { + if (Memory == nullptr || Value.Kind != EScalarKind::Float + || !FMath::IsFinite(Value.Number) + || (FieldName != TEXT("StrateTopWorldZ") + && FieldName != TEXT("StrateBottomWorldZ"))) + { + return false; + } + const bool bTop = FieldName == TEXT("StrateTopWorldZ"); + const float Number = static_cast(Value.Number); + if (!FMath::IsFinite(Number)) + { + return false; + } + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::Maze: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::SurfaceWorld: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::VerticalShafts: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + case ECaveGeneratorType::FloatingIslands: + if (bTop) static_cast(Memory)->StrateTopWorldZ = Number; + else static_cast(Memory)->StrateBottomWorldZ = Number; + return true; + default: + return false; + } + } + + bool ReadNamedField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, FScalarValue& OutValue) + { + if (FieldName == TEXT("StrateTopWorldZ") || FieldName == TEXT("StrateBottomWorldZ")) + { + return ReadRuntimeField(Memory, Archetype, FieldName, OutValue); + } + if (Memory == nullptr || !IsSupportedArchetype(Archetype)) + { + return false; + } + if (IsTunnelArchetype(Archetype) + && ReadTunnelField(*static_cast(Memory), + FieldName, OutValue)) + { + return true; + } + UStruct* Struct = ParamStruct(Archetype); + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + if ((*It)->GetName() == FieldName) + { + return ReadProperty(*It, Memory, OutValue); + } + } + return false; + } + + bool WriteNamedField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, const FScalarValue& Value) + { + if (FieldName == TEXT("StrateTopWorldZ") || FieldName == TEXT("StrateBottomWorldZ")) + { + return WriteRuntimeField(Memory, Archetype, FieldName, Value); + } + if (Memory == nullptr || !IsSupportedArchetype(Archetype)) + { + return false; + } + if (IsTunnelArchetype(Archetype) + && WriteTunnelField(*static_cast(Memory), + FieldName, Value)) + { + return true; + } + UStruct* Struct = ParamStruct(Archetype); + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + if ((*It)->GetName() == FieldName) + { + return WriteProperty(*It, Memory, Value); + } + } + return false; + } + + TArray FieldNames(ECaveGeneratorType Archetype) + { + TArray Result; + if (IsTunnelArchetype(Archetype)) + { +#define VF_SEASON_ADD_TUNNEL(Name) Result.AddUnique(TEXT(#Name)); + VF_STRATE_PARAM_FIELDS(VF_SEASON_ADD_TUNNEL, VF_SEASON_ADD_TUNNEL) +#undef VF_SEASON_ADD_TUNNEL + } + else if (UStruct* Struct = ParamStruct(Archetype)) + { + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + if (IsScalarProperty(*It)) + { + Result.AddUnique((*It)->GetName()); + } + } + } + Result.Add(TEXT("StrateTopWorldZ")); + Result.Add(TEXT("StrateBottomWorldZ")); + Result.Sort([](const FString& A, const FString& B) { return A < B; }); + return Result; + } + + FString FloatBits(float Value) + { + uint32 Bits = 0; + FMemory::Memcpy(&Bits, &Value, sizeof(Bits)); + return FString::Printf(TEXT("0x%08X"), Bits); + } + + bool ParseFloatBits(const FString& Text, float& OutValue) + { + FString Digits = Text; + if (Digits.StartsWith(TEXT("0x"), ESearchCase::IgnoreCase)) + { + Digits.RightChopInline(2); + } + if (Digits.IsEmpty() || Digits.Len() > 8) + { + return false; + } + uint32 Bits = 0; + for (const TCHAR Ch : Digits) + { + uint32 Nibble = 0; + if (Ch >= TEXT('0') && Ch <= TEXT('9')) Nibble = static_cast(Ch - TEXT('0')); + else if (Ch >= TEXT('a') && Ch <= TEXT('f')) Nibble = static_cast(Ch - TEXT('a') + 10); + else if (Ch >= TEXT('A') && Ch <= TEXT('F')) Nibble = static_cast(Ch - TEXT('A') + 10); + else return false; + Bits = (Bits << 4) | Nibble; + } + FMemory::Memcpy(&OutValue, &Bits, sizeof(OutValue)); + return FMath::IsFinite(OutValue); + } + + TSharedPtr SerializeScalar(const FScalarValue& Value) + { + TSharedPtr Object = MakeShared(); + if (Value.Kind == EScalarKind::Boolean) + { + Object->SetStringField(TEXT("kind"), TEXT("bool")); + Object->SetBoolField(TEXT("value"), Value.Boolean); + return Object; + } + if (Value.Kind == EScalarKind::Integer) + { + Object->SetStringField(TEXT("kind"), TEXT("int")); + Object->SetNumberField(TEXT("value"), Value.Number); + return Object; + } + const float FloatValue = static_cast(Value.Number); + Object->SetStringField(TEXT("kind"), TEXT("float")); + Object->SetNumberField(TEXT("value"), static_cast(FloatValue)); + Object->SetStringField(TEXT("bits"), FloatBits(FloatValue)); + return Object; + } + + bool ReadJsonNumber(const TSharedPtr& Object, + const TCHAR* Name, double& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Number) + { + return false; + } + OutValue = (*Found)->AsNumber(); + return FMath::IsFinite(OutValue); + } + + bool ReadJsonInt32(const TSharedPtr& Object, + const TCHAR* Name, int32& OutValue) + { + double Number = 0.0; + if (!ReadJsonNumber(Object, Name, Number) + || FMath::FloorToDouble(Number) != Number + || Number < static_cast(TNumericLimits::Lowest()) + || Number > static_cast(TNumericLimits::Max())) + { + return false; + } + OutValue = static_cast(Number); + return true; + } + + bool ReadJsonInt64(const TSharedPtr& Object, + const TCHAR* Name, int64& OutValue) + { + double Number = 0.0; + if (!ReadJsonNumber(Object, Name, Number) + || FMath::FloorToDouble(Number) != Number + || Number < -9223372036854775807.0 + || Number > 9223372036854775807.0) + { + return false; + } + OutValue = static_cast(Number); + return static_cast(OutValue) == Number; + } + + bool ReadJsonString(const TSharedPtr& Object, + const TCHAR* Name, FString& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::String) + { + return false; + } + OutValue = (*Found)->AsString(); + return true; + } + + bool ReadJsonBool(const TSharedPtr& Object, + const TCHAR* Name, bool& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Boolean) + { + return false; + } + OutValue = (*Found)->AsBool(); + return true; + } + + bool ReadJsonObject(const TSharedPtr& Object, + const TCHAR* Name, TSharedPtr& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Object) + { + return false; + } + OutValue = (*Found)->AsObject(); + return OutValue.IsValid(); + } + + bool ReadJsonArray(const TSharedPtr& Object, + const TCHAR* Name, + const TArray>*& OutValue) + { + if (!Object.IsValid()) return false; + const TSharedPtr* Found = Object->Values.Find(Name); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != EJson::Array) + { + return false; + } + OutValue = &(*Found)->AsArray(); + return true; + } + + bool ReadScalar(const TSharedPtr& Object, + const FString& Name, FScalarValue& OutValue) + { + TSharedPtr Scalar; + if (!ReadJsonObject(Object, *Name, Scalar)) return false; + FString Kind; + if (!ReadJsonString(Scalar, TEXT("kind"), Kind)) return false; + if (Kind == TEXT("bool")) + { + OutValue.Kind = EScalarKind::Boolean; + return ReadJsonBool(Scalar, TEXT("value"), OutValue.Boolean); + } + if (Kind == TEXT("int")) + { + OutValue.Kind = EScalarKind::Integer; + return ReadJsonNumber(Scalar, TEXT("value"), OutValue.Number) + && FMath::FloorToDouble(OutValue.Number) == OutValue.Number; + } + if (Kind != TEXT("float")) return false; + FString Bits; + if (!ReadJsonString(Scalar, TEXT("bits"), Bits)) return false; + float FloatValue = 0.0f; + if (!ParseFloatBits(Bits, FloatValue)) return false; + OutValue.Kind = EScalarKind::Float; + OutValue.Number = FloatValue; + return true; + } + + TSharedPtr SerializeParamsForArchetype( + ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params) + { + TSharedPtr Object = MakeShared(); + const void* Memory = ParamMemory(Params, Archetype); + for (const FString& FieldName : FieldNames(Archetype)) + { + FScalarValue Value; + if (ReadNamedField(Memory, Archetype, FieldName, Value)) + { + Object->SetObjectField(FieldName, SerializeScalar(Value)); + } + } + return Object; + } + + bool DeserializeParamsForArchetype( + ECaveGeneratorType Archetype, const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid()) return false; + void* Memory = ParamMemory(OutParams, Archetype); + if (Memory == nullptr) return false; + for (const FString& FieldName : FieldNames(Archetype)) + { + FScalarValue Value; + if (!ReadScalar(Object, FieldName, Value) + || !WriteNamedField(Memory, Archetype, FieldName, Value)) + { + return false; + } + } + return true; + } + + TSharedPtr SerializeAllParams(const FVoxelStrateArchetypeParams& Params) + { + TSharedPtr Object = MakeShared(); + Object->SetObjectField(TEXT("tunnel_network"), + SerializeParamsForArchetype(ECaveGeneratorType::TunnelNetwork, Params)); + Object->SetObjectField(TEXT("slab"), + SerializeParamsForArchetype(ECaveGeneratorType::FlatPlain, Params)); + Object->SetObjectField(TEXT("maze"), + SerializeParamsForArchetype(ECaveGeneratorType::Maze, Params)); + Object->SetObjectField(TEXT("surface"), + SerializeParamsForArchetype(ECaveGeneratorType::SurfaceWorld, Params)); + Object->SetObjectField(TEXT("vertical_shaft"), + SerializeParamsForArchetype(ECaveGeneratorType::VerticalShafts, Params)); + Object->SetObjectField(TEXT("floating_island"), + SerializeParamsForArchetype(ECaveGeneratorType::FloatingIslands, Params)); + return Object; + } + + bool DeserializeAllParams(const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid()) return false; + TSharedPtr Tunnel, Slab, Maze, Surface, Vertical, Floating; + if (!ReadJsonObject(Object, TEXT("tunnel_network"), Tunnel) + || !ReadJsonObject(Object, TEXT("slab"), Slab) + || !ReadJsonObject(Object, TEXT("maze"), Maze) + || !ReadJsonObject(Object, TEXT("surface"), Surface) + || !ReadJsonObject(Object, TEXT("vertical_shaft"), Vertical) + || !ReadJsonObject(Object, TEXT("floating_island"), Floating)) + { + return false; + } + OutParams = FVoxelStrateArchetypeParams(); + return DeserializeParamsForArchetype(ECaveGeneratorType::TunnelNetwork, Tunnel, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::FlatPlain, Slab, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::Maze, Maze, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::SurfaceWorld, Surface, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::VerticalShafts, Vertical, OutParams) + && DeserializeParamsForArchetype(ECaveGeneratorType::FloatingIslands, Floating, OutParams); + } + + TSharedPtr SerializeRecipeEntry(const FVoxelOpRecipeEntry& Entry) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("op_class"), static_cast(Entry.OpClass)); + Object->SetNumberField(TEXT("param_block"), static_cast(Entry.ParamBlock)); + return Object; + } + + bool DeserializeRecipeEntry(const TSharedPtr& Object, + FVoxelOpRecipeEntry& OutEntry) + { + int32 OpClass = 0; + int32 ParamBlock = 0; + if (!ReadJsonInt32(Object, TEXT("op_class"), OpClass) + || !ReadJsonInt32(Object, TEXT("param_block"), ParamBlock) + || OpClass < static_cast(EVoxelStrateOpClass::ConstantRockSource) + || OpClass > static_cast(EVoxelStrateOpClass::DensityNoiseFillMod) + || ParamBlock < static_cast(EVoxelStrateParamBlock::None) + || ParamBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland)) + { + return false; + } + OutEntry.OpClass = static_cast(OpClass); + OutEntry.ParamBlock = static_cast(ParamBlock); + return true; + } + + TSharedPtr SerializeRecipe(const FVoxelOpStackRecipe& Recipe) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("root_polarity"), static_cast(Recipe.RootPolarity)); + Object->SetObjectField(TEXT("root"), SerializeRecipeEntry(Recipe.Root)); + Object->SetObjectField(TEXT("shape_source"), SerializeRecipeEntry(Recipe.ShapeSource)); + Object->SetObjectField(TEXT("conversion"), SerializeRecipeEntry(Recipe.Conversion)); + Object->SetNumberField(TEXT("structural_param_block"), + static_cast(Recipe.StructuralParamBlock)); + TArray> Modifiers; + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) + { + Modifiers.Add(MakeShared(SerializeRecipeEntry(Entry))); + } + Object->SetArrayField(TEXT("modifiers"), MoveTemp(Modifiers)); + return Object; + } + + bool DeserializeRecipe(const TSharedPtr& Object, + FVoxelOpStackRecipe& OutRecipe) + { + if (!Object.IsValid()) return false; + int32 RootPolarity = 0; + int32 StructuralBlock = 0; + TSharedPtr Root, Shape, Conversion; + const TArray>* Modifiers = nullptr; + if (!ReadJsonInt32(Object, TEXT("root_polarity"), RootPolarity) + || !ReadJsonObject(Object, TEXT("root"), Root) + || !ReadJsonObject(Object, TEXT("shape_source"), Shape) + || !ReadJsonObject(Object, TEXT("conversion"), Conversion) + || !ReadJsonInt32(Object, TEXT("structural_param_block"), StructuralBlock) + || !ReadJsonArray(Object, TEXT("modifiers"), Modifiers) + || RootPolarity < static_cast(EVoxelStrateRootPolarity::RockCarve) + || RootPolarity > static_cast(EVoxelStrateRootPolarity::VoidFill) + || StructuralBlock < static_cast(EVoxelStrateParamBlock::None) + || StructuralBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland) + || Modifiers->Num() < 4 || Modifiers->Num() > 8 + || !DeserializeRecipeEntry(Root, OutRecipe.Root) + || !DeserializeRecipeEntry(Shape, OutRecipe.ShapeSource) + || !DeserializeRecipeEntry(Conversion, OutRecipe.Conversion)) + { + return false; + } + OutRecipe.RootPolarity = static_cast(RootPolarity); + OutRecipe.StructuralParamBlock = static_cast(StructuralBlock); + OutRecipe.Modifiers.Reset(); + for (const TSharedPtr& Value : *Modifiers) + { + if (!Value.IsValid() || Value->Type != EJson::Object) return false; + FVoxelOpRecipeEntry& Entry = OutRecipe.Modifiers.AddDefaulted_GetRef(); + if (!DeserializeRecipeEntry(Value->AsObject(), Entry)) return false; + } + return true; + } + + void SetMetricFloat(const TSharedPtr& Object, + const TCHAR* Name, float Value) + { + FScalarValue Scalar; + Scalar.Kind = EScalarKind::Float; + Scalar.Number = Value; + Object->SetObjectField(Name, SerializeScalar(Scalar)); + } + + bool ReadMetricFloat(const TSharedPtr& Object, + const TCHAR* Name, float& OutValue) + { + FScalarValue Scalar; + if (!ReadScalar(Object, Name, Scalar) || Scalar.Kind != EScalarKind::Float) + { + return false; + } + OutValue = static_cast(Scalar.Number); + return FMath::IsFinite(OutValue); + } + + TSharedPtr SerializeMetrics(const FVoxelStrateMeasuredMetrics& Metrics) + { + TSharedPtr Object = MakeShared(); + Object->SetBoolField(TEXT("valid"), Metrics.bValid); + Object->SetNumberField(TEXT("num_sampled"), static_cast(Metrics.NumSampled)); + Object->SetNumberField(TEXT("num_air"), static_cast(Metrics.NumAir)); + Object->SetNumberField(TEXT("num_solid"), static_cast(Metrics.NumSolid)); + SetMetricFloat(Object, TEXT("air_fraction"), Metrics.AirFraction); + Object->SetNumberField(TEXT("num_air_components"), Metrics.NumAirComponents); + SetMetricFloat(Object, TEXT("largest_component_share"), Metrics.LargestComponentShare); + Object->SetNumberField(TEXT("largest_component_cells"), + static_cast(Metrics.LargestComponentCells)); + Object->SetNumberField(TEXT("num_components_at_least_1_pct"), + Metrics.NumComponentsAtLeast1Pct); + SetMetricFloat(Object, TEXT("walkable_fraction"), Metrics.WalkableFraction); + Object->SetNumberField(TEXT("walkable_floor_columns"), + static_cast(Metrics.WalkableFloorColumns)); + SetMetricFloat(Object, TEXT("walkable_floor_area_fraction"), + Metrics.WalkableFloorAreaFraction); + Object->SetNumberField(TEXT("num_walkable_surface_components"), + Metrics.NumWalkableSurfaceComponents); + Object->SetNumberField(TEXT("largest_walkable_surface_columns"), + static_cast(Metrics.LargestWalkableSurfaceColumns)); + SetMetricFloat(Object, TEXT("largest_walkable_surface_share"), + Metrics.LargestWalkableSurfaceShare); + SetMetricFloat(Object, TEXT("median_feature_scale"), Metrics.MedianFeatureScale); + Object->SetNumberField(TEXT("median_vertical_clearance"), Metrics.MedianVerticalClearance); + Object->SetBoolField(TEXT("player_fit_resolved"), Metrics.bPlayerFitResolved); + Object->SetStringField(TEXT("player_fit_refusal_reason"), Metrics.PlayerFitRefusalReason); + Object->SetNumberField(TEXT("num_player_fit_cells"), + static_cast(Metrics.NumPlayerFitCells)); + SetMetricFloat(Object, TEXT("player_fit_fraction"), Metrics.PlayerFitFraction); + Object->SetNumberField(TEXT("num_traversable_components"), + Metrics.NumTraversableComponents); + Object->SetNumberField(TEXT("largest_traversable_component_cells"), + static_cast(Metrics.LargestTraversableComponentCells)); + SetMetricFloat(Object, TEXT("traversable_component_share"), + Metrics.TraversableComponentShare); + SetMetricFloat(Object, TEXT("minimum_player_clearance_voxels"), + Metrics.MinimumPlayerClearanceVoxels); + Object->SetNumberField(TEXT("resolved_margin_voxels"), Metrics.ResolvedMarginVoxels); + Object->SetNumberField(TEXT("sampled_min_z"), Metrics.SampledMinZ); + Object->SetNumberField(TEXT("sampled_max_z"), Metrics.SampledMaxZ); + Object->SetNumberField(TEXT("sampled_num_x"), Metrics.SampledNumX); + Object->SetNumberField(TEXT("sampled_num_y"), Metrics.SampledNumY); + Object->SetNumberField(TEXT("sampled_num_z"), Metrics.SampledNumZ); + SetMetricFloat(Object, TEXT("sampled_min_x"), Metrics.SampledMinX); + SetMetricFloat(Object, TEXT("sampled_max_x"), Metrics.SampledMaxX); + SetMetricFloat(Object, TEXT("sampled_min_y"), Metrics.SampledMinY); + SetMetricFloat(Object, TEXT("sampled_max_y"), Metrics.SampledMaxY); + TSharedPtr Point = MakeShared(); + SetMetricFloat(Point, TEXT("x"), static_cast(Metrics.LargestComponentPoint.X)); + SetMetricFloat(Point, TEXT("y"), static_cast(Metrics.LargestComponentPoint.Y)); + SetMetricFloat(Point, TEXT("z"), static_cast(Metrics.LargestComponentPoint.Z)); + Object->SetObjectField(TEXT("largest_component_point"), Point); + TArray> Components; + for (const int64 Cells : Metrics.AirComponentCells) + { + Components.Add(MakeShared(static_cast(Cells))); + } + Object->SetArrayField(TEXT("air_component_cells"), MoveTemp(Components)); + return Object; + } + + bool DeserializeMetrics(const TSharedPtr& Object, + FVoxelStrateMeasuredMetrics& OutMetrics) + { + if (!Object.IsValid()) return false; + bool bValid = true; + bValid = ReadJsonBool(Object, TEXT("valid"), OutMetrics.bValid) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_sampled"), OutMetrics.NumSampled) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_air"), OutMetrics.NumAir) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_solid"), OutMetrics.NumSolid) && bValid; + bValid = ReadMetricFloat(Object, TEXT("air_fraction"), OutMetrics.AirFraction) && bValid; + bValid = ReadJsonInt32(Object, TEXT("num_air_components"), OutMetrics.NumAirComponents) && bValid; + bValid = ReadMetricFloat(Object, TEXT("largest_component_share"), OutMetrics.LargestComponentShare) && bValid; + bValid = ReadJsonInt64(Object, TEXT("largest_component_cells"), OutMetrics.LargestComponentCells) && bValid; + bValid = ReadJsonInt32(Object, TEXT("num_components_at_least_1_pct"), OutMetrics.NumComponentsAtLeast1Pct) && bValid; + bValid = ReadMetricFloat(Object, TEXT("walkable_fraction"), OutMetrics.WalkableFraction) && bValid; + if (Object->HasField(TEXT("walkable_floor_columns"))) + { + bValid = ReadJsonInt64(Object, TEXT("walkable_floor_columns"), + OutMetrics.WalkableFloorColumns) && bValid; + bValid = ReadMetricFloat(Object, TEXT("walkable_floor_area_fraction"), + OutMetrics.WalkableFloorAreaFraction) && bValid; + bValid = ReadJsonInt32(Object, TEXT("num_walkable_surface_components"), + OutMetrics.NumWalkableSurfaceComponents) && bValid; + bValid = ReadJsonInt64(Object, TEXT("largest_walkable_surface_columns"), + OutMetrics.LargestWalkableSurfaceColumns) && bValid; + bValid = ReadMetricFloat(Object, TEXT("largest_walkable_surface_share"), + OutMetrics.LargestWalkableSurfaceShare) && bValid; + } + bValid = ReadMetricFloat(Object, TEXT("median_feature_scale"), OutMetrics.MedianFeatureScale) && bValid; + bValid = ReadJsonInt32(Object, TEXT("median_vertical_clearance"), OutMetrics.MedianVerticalClearance) && bValid; + if (Object->HasField(TEXT("player_fit_resolved"))) + { + bValid = ReadJsonBool(Object, TEXT("player_fit_resolved"), + OutMetrics.bPlayerFitResolved) && bValid; + bValid = ReadJsonString(Object, TEXT("player_fit_refusal_reason"), + OutMetrics.PlayerFitRefusalReason) && bValid; + bValid = ReadJsonInt64(Object, TEXT("num_player_fit_cells"), + OutMetrics.NumPlayerFitCells) && bValid; + bValid = ReadMetricFloat(Object, TEXT("player_fit_fraction"), + OutMetrics.PlayerFitFraction) && bValid; + bValid = ReadJsonInt32(Object, TEXT("num_traversable_components"), + OutMetrics.NumTraversableComponents) && bValid; + bValid = ReadJsonInt64(Object, TEXT("largest_traversable_component_cells"), + OutMetrics.LargestTraversableComponentCells) && bValid; + bValid = ReadMetricFloat(Object, TEXT("traversable_component_share"), + OutMetrics.TraversableComponentShare) && bValid; + bValid = ReadMetricFloat(Object, TEXT("minimum_player_clearance_voxels"), + OutMetrics.MinimumPlayerClearanceVoxels) && bValid; + } + bValid = ReadJsonInt32(Object, TEXT("resolved_margin_voxels"), OutMetrics.ResolvedMarginVoxels) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_min_z"), OutMetrics.SampledMinZ) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_max_z"), OutMetrics.SampledMaxZ) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_num_x"), OutMetrics.SampledNumX) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_num_y"), OutMetrics.SampledNumY) && bValid; + bValid = ReadJsonInt32(Object, TEXT("sampled_num_z"), OutMetrics.SampledNumZ) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_min_x"), OutMetrics.SampledMinX) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_max_x"), OutMetrics.SampledMaxX) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_min_y"), OutMetrics.SampledMinY) && bValid; + bValid = ReadMetricFloat(Object, TEXT("sampled_max_y"), OutMetrics.SampledMaxY) && bValid; + TSharedPtr Point; + bValid = ReadJsonObject(Object, TEXT("largest_component_point"), Point) && bValid; + if (Point.IsValid()) + { + float X = 0.0f; + float Y = 0.0f; + float Z = 0.0f; + bValid = ReadMetricFloat(Point, TEXT("x"), X) && bValid; + bValid = ReadMetricFloat(Point, TEXT("y"), Y) && bValid; + bValid = ReadMetricFloat(Point, TEXT("z"), Z) && bValid; + OutMetrics.LargestComponentPoint = FVector(X, Y, Z); + } + const TArray>* Components = nullptr; + bValid = ReadJsonArray(Object, TEXT("air_component_cells"), Components) && bValid; + OutMetrics.AirComponentCells.Reset(); + if (Components != nullptr) + { + for (const TSharedPtr& Value : *Components) + { + if (!Value.IsValid() || Value->Type != EJson::Number) + { + bValid = false; + continue; + } + const double Number = Value->AsNumber(); + const int64 Cells = static_cast(Number); + if (!FMath::IsFinite(Number) || Number < 0.0 + || FMath::FloorToDouble(Number) != Number + || static_cast(Cells) != Number) + { + bValid = false; + continue; + } + OutMetrics.AirComponentCells.Add(Cells); + } + } + return bValid; + } + + const TCHAR* ConnectivityName(EVoxelConnectivityResult Result) + { + switch (Result) + { + case EVoxelConnectivityResult::Connected: return TEXT("Connected"); + case EVoxelConnectivityResult::NotConnectedAtThisResolution: return TEXT("NotConnectedAtThisResolution"); + case EVoxelConnectivityResult::StartCellSolid: return TEXT("StartCellSolid"); + case EVoxelConnectivityResult::GoalCellSolid: return TEXT("GoalCellSolid"); + case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit"); + case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit"); + case EVoxelConnectivityResult::OutOfWindow: return TEXT("OutOfWindow"); + case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudgetExhausted"); + } + return TEXT("Unknown"); + } + + bool ParseConnectivityName(const FString& Name, EVoxelConnectivityResult& OutResult) + { + const EVoxelConnectivityResult Values[] = { + EVoxelConnectivityResult::Connected, + EVoxelConnectivityResult::NotConnectedAtThisResolution, + EVoxelConnectivityResult::StartCellSolid, + EVoxelConnectivityResult::GoalCellSolid, + EVoxelConnectivityResult::StartCellNotPlayerFit, + EVoxelConnectivityResult::GoalCellNotPlayerFit, + EVoxelConnectivityResult::OutOfWindow, + EVoxelConnectivityResult::CoarseLiedBudgetExhausted, + }; + for (const EVoxelConnectivityResult Value : Values) + { + if (Name == ConnectivityName(Value)) + { + OutResult = Value; + return true; + } + } + return false; + } + + const TCHAR* ArchetypeName(ECaveGeneratorType Archetype) + { + return VF_GetStrateArchetypeName(Archetype); + } + + bool ParseArchetype(const FString& Name, ECaveGeneratorType& OutArchetype) + { + const ECaveGeneratorType Values[] = { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater, + }; + for (const ECaveGeneratorType Value : Values) + { + if (Name == ArchetypeName(Value)) + { + OutArchetype = Value; + return true; + } + } + return false; + } + + TSharedPtr SerializePolicy(const FVoxelSeasonSelectionPolicy& Policy) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("grounded_fraction"), Policy.GroundedFraction); + Object->SetNumberField(TEXT("outlier_fraction"), Policy.OutlierFraction); + Object->SetNumberField(TEXT("adjacent_similarity_distance_threshold"), + Policy.AdjacentSimilarityDistanceThreshold); + Object->SetNumberField(TEXT("adjacent_similarity_penalty_weight"), + Policy.AdjacentSimilarityPenaltyWeight); + Object->SetNumberField(TEXT("new_archetype_bonus"), Policy.NewArchetypeBonus); + Object->SetNumberField(TEXT("new_recipe_bonus"), Policy.NewRecipeBonus); + Object->SetNumberField(TEXT("boss_slot_interval"), Policy.BossSlotInterval); + Object->SetNumberField(TEXT("minimum_largest_component_share"), + Policy.MinimumLargestComponentShare); + Object->SetNumberField(TEXT("feature_scale_normalization_voxels"), + Policy.FeatureScaleNormalizationVoxels); + Object->SetNumberField(TEXT("clearance_normalization_voxels"), + Policy.ClearanceNormalizationVoxels); + return Object; + } + + bool ReadPolicy(const TSharedPtr& Object, + FVoxelSeasonSelectionPolicy& OutPolicy) + { + if (!Object.IsValid()) return false; + double Number = 0.0; + bool bValid = true; + if (ReadJsonNumber(Object, TEXT("grounded_fraction"), Number)) OutPolicy.GroundedFraction = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("outlier_fraction"), Number)) OutPolicy.OutlierFraction = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("adjacent_similarity_distance_threshold"), Number)) OutPolicy.AdjacentSimilarityDistanceThreshold = Number; else bValid = false; + if (ReadJsonNumber(Object, TEXT("adjacent_similarity_penalty_weight"), Number)) OutPolicy.AdjacentSimilarityPenaltyWeight = Number; else bValid = false; + if (ReadJsonNumber(Object, TEXT("new_archetype_bonus"), Number)) OutPolicy.NewArchetypeBonus = Number; else bValid = false; + if (ReadJsonNumber(Object, TEXT("new_recipe_bonus"), Number)) OutPolicy.NewRecipeBonus = Number; else bValid = false; + if (ReadJsonInt32(Object, TEXT("boss_slot_interval"), OutPolicy.BossSlotInterval) == false) bValid = false; + if (ReadJsonNumber(Object, TEXT("minimum_largest_component_share"), Number)) OutPolicy.MinimumLargestComponentShare = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("feature_scale_normalization_voxels"), Number)) OutPolicy.FeatureScaleNormalizationVoxels = static_cast(Number); else bValid = false; + if (ReadJsonNumber(Object, TEXT("clearance_normalization_voxels"), Number)) OutPolicy.ClearanceNormalizationVoxels = static_cast(Number); else bValid = false; + return bValid; + } + + void SetCommonManifestFields(const TSharedPtr& Root, + const FVoxelSeasonManifest& Manifest) + { + Root->SetStringField(TEXT("format"), TEXT("VoxelForgeSeasonManifest")); + Root->SetBoolField(TEXT("valid"), Manifest.bValid); + Root->SetNumberField(TEXT("schema_version"), Manifest.SchemaVersion); + Root->SetNumberField(TEXT("season"), Manifest.Season); + Root->SetNumberField(TEXT("seed"), Manifest.Seed); + Root->SetNumberField(TEXT("input_corpus_hash"), static_cast(Manifest.InputCorpusHash)); + Root->SetNumberField(TEXT("candidate_count"), Manifest.CandidateCount); + Root->SetNumberField(TEXT("survivor_count"), Manifest.SurvivorCount); + Root->SetNumberField(TEXT("selected_count"), Manifest.SelectedCount); + Root->SetNumberField(TEXT("rejected_count"), Manifest.RejectedCount); + Root->SetNumberField(TEXT("grounded_selected_count"), Manifest.GroundedSelectedCount); + Root->SetNumberField(TEXT("outlier_selected_count"), Manifest.OutlierSelectedCount); + Root->SetNumberField(TEXT("fixed_selected_count"), Manifest.FixedSelectedCount); + Root->SetNumberField(TEXT("total_strates"), Manifest.TotalStrates); + Root->SetNumberField(TEXT("inter_strate_gap_chunks"), Manifest.InterStrateGapChunks); + SetMetricFloat(Root, TEXT("origin_spine_radius"), Manifest.OriginSpineRadius); + SetMetricFloat(Root, TEXT("world_radius_voxels"), Manifest.WorldRadiusVoxels); + Root->SetObjectField(TEXT("selection_policy"), SerializePolicy(Manifest.SelectionPolicy)); + Root->SetStringField(TEXT("policy_note"), Manifest.PolicyNote); + } + + TSharedPtr SerializeRegionManifest( + const FVoxelStrateRegionManifest& Regions) + { + TSharedPtr Object = MakeShared(); + Object->SetBoolField(TEXT("valid"), Regions.bValid); + Object->SetStringField(TEXT("failure_reason"), Regions.FailureReason); + Object->SetNumberField(TEXT("seed"), Regions.Seed); + Object->SetNumberField(TEXT("strate_index"), Regions.StrateIndex); + Object->SetNumberField(TEXT("region_count"), Regions.RegionCount); + Object->SetNumberField(TEXT("partition_seed"), + static_cast(Regions.PartitionSeed)); + SetMetricFloat(Object, TEXT("lattice_cell_size"), Regions.LatticeCellSize); + SetMetricFloat(Object, TEXT("blend_width"), Regions.BlendWidth); + Object->SetBoolField(TEXT("has_global_structural_params"), + Regions.bHasGlobalStructuralParams); + Object->SetNumberField(TEXT("structural_param_block"), + static_cast(Regions.StructuralParamBlock)); + SetMetricFloat(Object, TEXT("strate_top_world_z"), Regions.StrateTopWorldZ); + SetMetricFloat(Object, TEXT("strate_bottom_world_z"), Regions.StrateBottomWorldZ); + SetMetricFloat(Object, TEXT("boundary_seal_thickness"), + Regions.BoundarySealThickness); + SetMetricFloat(Object, TEXT("base_density"), Regions.BaseDensity); + + TArray> RegionValues; + for (const FVoxelStrateRegion& Region : Regions.Regions) + { + TSharedPtr RegionObject = MakeShared(); + RegionObject->SetNumberField(TEXT("region_index"), Region.RegionIndex); + RegionObject->SetNumberField(TEXT("seed"), Region.Seed); + RegionObject->SetStringField(TEXT("archetype"), ArchetypeName(Region.Archetype)); + RegionObject->SetNumberField(TEXT("archetype_id"), + static_cast(Region.Archetype)); + RegionObject->SetBoolField(TEXT("uses_recipe"), Region.bUsesRecipe); + RegionObject->SetObjectField(TEXT("recipe"), SerializeRecipe(Region.Recipe)); + RegionObject->SetObjectField(TEXT("parameters"), + SerializeAllParams(Region.ArchetypeParams)); + RegionValues.Add(MakeShared(MoveTemp(RegionObject))); + } + Object->SetArrayField(TEXT("regions"), MoveTemp(RegionValues)); + return Object; + } + + bool ReadJsonUInt32(const TSharedPtr& Object, + const TCHAR* Name, uint32& OutValue) + { + double Number = 0.0; + if (!ReadJsonNumber(Object, Name, Number) + || !FMath::IsFinite(Number) + || Number < 0.0 || Number > 4294967295.0 + || FMath::FloorToDouble(Number) != Number) + { + return false; + } + OutValue = static_cast(Number); + return true; + } + + bool DeserializeRegionManifest(const TSharedPtr& Object, + FVoxelStrateRegionManifest& OutRegions) + { + if (!Object.IsValid()) return false; + OutRegions = FVoxelStrateRegionManifest(); + bool JsonValid = false; + int32 StructuralBlock = 0; + bool bFieldsValid = ReadJsonBool(Object, TEXT("valid"), JsonValid) + && ReadJsonString(Object, TEXT("failure_reason"), OutRegions.FailureReason) + && ReadJsonInt32(Object, TEXT("seed"), OutRegions.Seed) + && ReadJsonInt32(Object, TEXT("strate_index"), OutRegions.StrateIndex) + && ReadJsonInt32(Object, TEXT("region_count"), OutRegions.RegionCount) + && ReadJsonUInt32(Object, TEXT("partition_seed"), OutRegions.PartitionSeed) + && ReadMetricFloat(Object, TEXT("lattice_cell_size"), OutRegions.LatticeCellSize) + && ReadMetricFloat(Object, TEXT("blend_width"), OutRegions.BlendWidth) + && ReadJsonBool(Object, TEXT("has_global_structural_params"), + OutRegions.bHasGlobalStructuralParams) + && ReadJsonInt32(Object, TEXT("structural_param_block"), StructuralBlock) + && ReadMetricFloat(Object, TEXT("strate_top_world_z"), OutRegions.StrateTopWorldZ) + && ReadMetricFloat(Object, TEXT("strate_bottom_world_z"), + OutRegions.StrateBottomWorldZ) + && ReadMetricFloat(Object, TEXT("boundary_seal_thickness"), + OutRegions.BoundarySealThickness) + && ReadMetricFloat(Object, TEXT("base_density"), OutRegions.BaseDensity); + if (StructuralBlock < static_cast(EVoxelStrateParamBlock::None) + || StructuralBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland)) + { + bFieldsValid = false; + } + OutRegions.StructuralParamBlock = static_cast(StructuralBlock); + + const TArray>* RegionValues = nullptr; + bFieldsValid = ReadJsonArray(Object, TEXT("regions"), RegionValues) && bFieldsValid; + if (RegionValues == nullptr + || OutRegions.RegionCount < 1 || OutRegions.RegionCount > 3 + || RegionValues->Num() != OutRegions.RegionCount) + { + bFieldsValid = false; + } + + OutRegions.Regions.Reset(); + if (RegionValues != nullptr) + { + OutRegions.Regions.Reserve(RegionValues->Num()); + for (int32 Index = 0; Index < RegionValues->Num(); ++Index) + { + const TSharedPtr& Value = (*RegionValues)[Index]; + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + const TSharedPtr RegionObject = Value->AsObject(); + FVoxelStrateRegion& Region = OutRegions.Regions.AddDefaulted_GetRef(); + FString ArchetypeText; + int32 ArchetypeId = -1; + TSharedPtr Recipe, Params; + bFieldsValid = ReadJsonInt32(RegionObject, TEXT("region_index"), + Region.RegionIndex) && bFieldsValid; + bFieldsValid = ReadJsonInt32(RegionObject, TEXT("seed"), Region.Seed) + && bFieldsValid; + bFieldsValid = ReadJsonString(RegionObject, TEXT("archetype"), ArchetypeText) + && bFieldsValid; + bFieldsValid = ReadJsonInt32(RegionObject, TEXT("archetype_id"), ArchetypeId) + && bFieldsValid; + bFieldsValid = ReadJsonBool(RegionObject, TEXT("uses_recipe"), + Region.bUsesRecipe) && bFieldsValid; + bFieldsValid = ReadJsonObject(RegionObject, TEXT("recipe"), Recipe) + && bFieldsValid; + bFieldsValid = ReadJsonObject(RegionObject, TEXT("parameters"), Params) + && bFieldsValid; + bFieldsValid = ParseArchetype(ArchetypeText, Region.Archetype) + && bFieldsValid; + bFieldsValid = Region.RegionIndex == Index && bFieldsValid; + bFieldsValid = ArchetypeId == static_cast(Region.Archetype) + && bFieldsValid; + if (Region.bUsesRecipe) + { + bFieldsValid = DeserializeRecipe(Recipe, Region.Recipe) && bFieldsValid; + } + bFieldsValid = DeserializeAllParams(Params, Region.ArchetypeParams) + && bFieldsValid; + } + } + + OutRegions.bValid = JsonValid; + return bFieldsValid && OutRegions.IsValid(); + } + + TSharedPtr SerializeStrate(const FVoxelSeasonStrate& Strate) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("depth_index"), Strate.DepthIndex); + Object->SetNumberField(TEXT("candidate_index"), Strate.CandidateIndex); + Object->SetNumberField(TEXT("seed"), Strate.Seed); + Object->SetNumberField(TEXT("height_in_chunks"), Strate.HeightInChunks); + Object->SetNumberField(TEXT("top_world_z"), Strate.TopWorldZ); + Object->SetNumberField(TEXT("bottom_world_z"), Strate.BottomWorldZ); + Object->SetStringField(TEXT("archetype"), ArchetypeName(Strate.Archetype)); + Object->SetNumberField(TEXT("archetype_id"), static_cast(Strate.Archetype)); + Object->SetBoolField(TEXT("uses_recipe"), Strate.bUsesRecipe); + Object->SetBoolField(TEXT("uses_regions"), Strate.bUsesRegions); + Object->SetObjectField(TEXT("recipe"), SerializeRecipe(Strate.Recipe)); + Object->SetObjectField(TEXT("parameters"), SerializeAllParams(Strate.Params)); + // Keep old/single-region artifacts compact and on the exact legacy rebuild path. A + // multi-region strate carries the complete parent manifest because that is its runtime + // identity; an invalid default Regions object must not make fixed legacy entries unloadable. + if (Strate.bUsesRegions) + { + Object->SetObjectField(TEXT("regions"), SerializeRegionManifest(Strate.Regions)); + } + Object->SetObjectField(TEXT("measured_metrics"), SerializeMetrics(Strate.Metrics)); + Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Strate.DistanceFromCorpusCentroid); + Object->SetStringField(TEXT("selection_reason"), Strate.SelectionReason); + Object->SetStringField(TEXT("source_definition_path"), Strate.SourceDefinitionPath); + Object->SetBoolField(TEXT("grounded"), Strate.bGrounded); + Object->SetBoolField(TEXT("outlier"), Strate.bOutlier); + Object->SetBoolField(TEXT("boss_slot"), Strate.bBossSlot); + Object->SetBoolField(TEXT("fixed"), Strate.bFixed); + Object->SetBoolField(TEXT("passed_non_vacuous"), Strate.bPassedNonVacuous); + Object->SetBoolField(TEXT("passed_largest_component"), Strate.bPassedLargestComponent); + Object->SetBoolField(TEXT("passed_primordial_law"), Strate.bPassedPrimordialLaw); + Object->SetStringField(TEXT("primordial_law_result"), ConnectivityName(Strate.PrimordialLawResult)); + return Object; + } + + bool DeserializeStrate(const TSharedPtr& Object, + FVoxelSeasonStrate& OutStrate) + { + if (!Object.IsValid()) return false; + FString ArchetypeText, Reason, SourcePath, LawText; + int32 ArchetypeId = -1; + bool bValid = ReadJsonInt32(Object, TEXT("depth_index"), OutStrate.DepthIndex) + && ReadJsonInt32(Object, TEXT("candidate_index"), OutStrate.CandidateIndex) + && ReadJsonInt32(Object, TEXT("seed"), OutStrate.Seed) + && ReadJsonInt32(Object, TEXT("height_in_chunks"), OutStrate.HeightInChunks) + && ReadJsonInt32(Object, TEXT("top_world_z"), OutStrate.TopWorldZ) + && ReadJsonInt32(Object, TEXT("bottom_world_z"), OutStrate.BottomWorldZ) + && ReadJsonString(Object, TEXT("archetype"), ArchetypeText) + && ReadJsonInt32(Object, TEXT("archetype_id"), ArchetypeId) + && ReadJsonBool(Object, TEXT("uses_recipe"), OutStrate.bUsesRecipe) + && ReadJsonString(Object, TEXT("selection_reason"), Reason) + && ReadJsonString(Object, TEXT("source_definition_path"), SourcePath) + && ReadJsonBool(Object, TEXT("grounded"), OutStrate.bGrounded) + && ReadJsonBool(Object, TEXT("outlier"), OutStrate.bOutlier) + && ReadJsonBool(Object, TEXT("boss_slot"), OutStrate.bBossSlot) + && ReadJsonBool(Object, TEXT("fixed"), OutStrate.bFixed) + && ReadJsonBool(Object, TEXT("passed_non_vacuous"), OutStrate.bPassedNonVacuous) + && ReadJsonBool(Object, TEXT("passed_largest_component"), OutStrate.bPassedLargestComponent) + && ReadJsonBool(Object, TEXT("passed_primordial_law"), OutStrate.bPassedPrimordialLaw) + && ReadJsonString(Object, TEXT("primordial_law_result"), LawText); + double Distance = 0.0; + bValid = ReadJsonNumber(Object, TEXT("distance_from_corpus_centroid"), Distance) && bValid; + OutStrate.DistanceFromCorpusCentroid = Distance; + OutStrate.SelectionReason = Reason; + OutStrate.SourceDefinitionPath = SourcePath; + TSharedPtr Recipe, Params, Metrics, RegionsObject; + bValid = ReadJsonObject(Object, TEXT("recipe"), Recipe) && bValid; + bValid = ReadJsonObject(Object, TEXT("parameters"), Params) && bValid; + bValid = ReadJsonObject(Object, TEXT("measured_metrics"), Metrics) && bValid; + const TSharedPtr* RegionsValue = Object->Values.Find(TEXT("regions")); + if (RegionsValue != nullptr) + { + bValid = ReadJsonObject(Object, TEXT("regions"), RegionsObject) && bValid; + if (RegionsObject.IsValid()) + { + bValid = DeserializeRegionManifest(RegionsObject, OutStrate.Regions) + && bValid; + } + } + const TSharedPtr* UsesRegionsValue = + Object->Values.Find(TEXT("uses_regions")); + if (UsesRegionsValue != nullptr) + { + bValid = ReadJsonBool(Object, TEXT("uses_regions"), OutStrate.bUsesRegions) + && bValid; + } + else + { + // Schema-1 manifests predating lateral regions simply take the old path. A region + // object is optional for backward compatibility, and never implicitly changes that + // path without the explicit opt-in bit. + OutStrate.bUsesRegions = false; + } + bValid = ParseArchetype(ArchetypeText, OutStrate.Archetype) && bValid; + bValid = ArchetypeId == static_cast(OutStrate.Archetype) && bValid; + bValid = ParseConnectivityName(LawText, OutStrate.PrimordialLawResult) && bValid; + if (OutStrate.bUsesRecipe) + { + bValid = DeserializeRecipe(Recipe, OutStrate.Recipe) && bValid; + } + bValid = DeserializeAllParams(Params, OutStrate.Params) && bValid; + bValid = DeserializeMetrics(Metrics, OutStrate.Metrics) && bValid; + if (OutStrate.bUsesRegions + && (!OutStrate.Regions.IsValid() || OutStrate.Regions.RegionCount <= 1)) + { + bValid = false; + } + return bValid; + } + +} + +namespace +{ + FString SerializeVoxelSeasonManifestInternal( + const FVoxelSeasonManifest& Manifest, bool bIncludeContentHash) + { + using namespace VoxelSeasonManifestPrivate; + TSharedPtr Root = MakeShared(); + SetCommonManifestFields(Root, Manifest); + + TArray> Strates; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + Strates.Add(MakeShared(SerializeStrate(Strate))); + } + Root->SetArrayField(TEXT("strates"), MoveTemp(Strates)); + + TArray> Rejections; + for (const FVoxelSeasonRejectionCount& Rejection : Manifest.RejectionCounts) + { + TSharedPtr Object = MakeShared(); + Object->SetStringField(TEXT("reason"), Rejection.Reason); + Object->SetNumberField(TEXT("count"), Rejection.Count); + Rejections.Add(MakeShared(Object)); + } + Root->SetArrayField(TEXT("rejection_counts"), MoveTemp(Rejections)); + + if (!Manifest.CandidateAudit.IsEmpty()) + { + TArray> Audit; + for (const FVoxelSeasonCandidateAudit& Row : Manifest.CandidateAudit) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("candidate_index"), Row.CandidateIndex); + Object->SetNumberField(TEXT("seed"), Row.Seed); + Object->SetStringField(TEXT("archetype"), ArchetypeName(Row.Archetype)); + Object->SetNumberField(TEXT("recipe_hash"), static_cast(Row.RecipeHash)); + Object->SetNumberField(TEXT("distance_from_corpus_centroid"), Row.DistanceFromCorpusCentroid); + Object->SetBoolField(TEXT("passed_hard_gates"), Row.bPassedHardGates); + Object->SetBoolField(TEXT("selected"), Row.bSelected); + Object->SetStringField(TEXT("rejection_reason"), Row.RejectionReason); + Audit.Add(MakeShared(Object)); + } + Root->SetArrayField(TEXT("candidate_audit"), MoveTemp(Audit)); + } + + if (bIncludeContentHash) + { + const FString Payload = SerializeVoxelSeasonManifestInternal(Manifest, false); + if (Payload.IsEmpty()) return FString(); + FTCHARToUTF8 Utf8(*Payload); + uint8 Digest[FSHA1::DigestSize]; + FSHA1::HashBuffer(Utf8.Get(), Utf8.Length(), Digest); + Root->SetStringField(TEXT("content_hash"), BytesToHex(Digest, FSHA1::DigestSize)); + } + + FString JsonText; + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&JsonText); + if (!FJsonSerializer::Serialize(Root.ToSharedRef(), Writer) || !Writer->Close()) + { + return FString(); + } + return JsonText; + } +} + +FString VF_ComputeVoxelSeasonManifestContentHash(const FVoxelSeasonManifest& Manifest) +{ + const FString Payload = SerializeVoxelSeasonManifestInternal(Manifest, false); + if (Payload.IsEmpty()) return FString(); + FTCHARToUTF8 Utf8(*Payload); + uint8 Digest[FSHA1::DigestSize]; + FSHA1::HashBuffer(Utf8.Get(), Utf8.Length(), Digest); + return BytesToHex(Digest, FSHA1::DigestSize); +} + +FString VF_SerializeVoxelSeasonManifest(const FVoxelSeasonManifest& Manifest) +{ + return SerializeVoxelSeasonManifestInternal(Manifest, true); +} + +bool VF_SaveVoxelSeasonManifest(const FString& ManifestPath, + const FVoxelSeasonManifest& Manifest, + FString& OutReport) +{ + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + return false; + } + const FString JsonText = VF_SerializeVoxelSeasonManifest(Manifest); + if (JsonText.IsEmpty()) + { + OutReport = TEXT("could not serialize season manifest"); + return false; + } + const FString Directory = FPaths::GetPath(ManifestPath); + if (!Directory.IsEmpty() && !IFileManager::Get().MakeDirectory(*Directory, true)) + { + OutReport = FString::Printf(TEXT("could not create manifest directory %s"), *Directory); + return false; + } + if (!FFileHelper::SaveStringToFile(JsonText, *ManifestPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutReport = FString::Printf(TEXT("could not write season manifest %s"), *ManifestPath); + return false; + } + OutReport = FString::Printf(TEXT("saved season manifest to %s"), *ManifestPath); + return true; +} + +bool VF_DeserializeVoxelSeasonManifest(const FString& ManifestJson, + FVoxelSeasonManifest& OutManifest, + FString& OutReport) +{ + using namespace VoxelSeasonManifestPrivate; + OutManifest = FVoxelSeasonManifest(); + OutReport.Reset(); + if (ManifestJson.IsEmpty()) + { + OutReport = TEXT("season manifest JSON is empty"); + OutManifest.Error = OutReport; + return false; + } + + TSharedPtr Root; + const TSharedRef> Reader = TJsonReaderFactory<>::Create(ManifestJson); + if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid()) + { + OutReport = TEXT("season manifest JSON is malformed"); + OutManifest.Error = OutReport; + return false; + } + + FString Format; + int32 SchemaVersion = 0; + bool bValid = false; + if (!ReadJsonString(Root, TEXT("format"), Format) + || Format != TEXT("VoxelForgeSeasonManifest") + || !ReadJsonBool(Root, TEXT("valid"), bValid) + || !ReadJsonInt32(Root, TEXT("schema_version"), SchemaVersion)) + { + OutReport = TEXT("season manifest header is missing or malformed"); + OutManifest.Error = OutReport; + return false; + } + if (SchemaVersion != FVoxelSeasonManifest::CurrentSchemaVersion) + { + OutReport = FString::Printf(TEXT("unsupported season manifest schema %d (expected %d)"), + SchemaVersion, FVoxelSeasonManifest::CurrentSchemaVersion); + OutManifest.Error = OutReport; + return false; + } + + bool bFieldsValid = true; + OutManifest.bValid = bValid; + OutManifest.SchemaVersion = SchemaVersion; + bFieldsValid = ReadJsonString(Root, TEXT("content_hash"), OutManifest.ContentHash) + && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("season"), OutManifest.Season) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("seed"), OutManifest.Seed) && bFieldsValid; + double CorpusHash = 0.0; + bFieldsValid = ReadJsonNumber(Root, TEXT("input_corpus_hash"), CorpusHash) && bFieldsValid; + if (CorpusHash < 0.0 || CorpusHash > 4294967295.0 + || FMath::FloorToDouble(CorpusHash) != CorpusHash) + { + bFieldsValid = false; + } + OutManifest.InputCorpusHash = static_cast(CorpusHash); + bFieldsValid = ReadJsonInt32(Root, TEXT("candidate_count"), OutManifest.CandidateCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("survivor_count"), OutManifest.SurvivorCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("selected_count"), OutManifest.SelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("rejected_count"), OutManifest.RejectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("grounded_selected_count"), OutManifest.GroundedSelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("outlier_selected_count"), OutManifest.OutlierSelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("fixed_selected_count"), OutManifest.FixedSelectedCount) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("total_strates"), OutManifest.TotalStrates) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Root, TEXT("inter_strate_gap_chunks"), OutManifest.InterStrateGapChunks) && bFieldsValid; + bFieldsValid = ReadMetricFloat(Root, TEXT("origin_spine_radius"), OutManifest.OriginSpineRadius) && bFieldsValid; + bFieldsValid = ReadMetricFloat(Root, TEXT("world_radius_voxels"), OutManifest.WorldRadiusVoxels) && bFieldsValid; + TSharedPtr Policy; + bFieldsValid = ReadJsonObject(Root, TEXT("selection_policy"), Policy) && bFieldsValid; + if (Policy.IsValid()) + { + bFieldsValid = ReadPolicy(Policy, OutManifest.SelectionPolicy) && bFieldsValid; + } + bFieldsValid = ReadJsonString(Root, TEXT("policy_note"), OutManifest.PolicyNote) && bFieldsValid; + + const TArray>* StrateValues = nullptr; + const TArray>* RejectionValues = nullptr; + bFieldsValid = ReadJsonArray(Root, TEXT("strates"), StrateValues) && bFieldsValid; + bFieldsValid = ReadJsonArray(Root, TEXT("rejection_counts"), RejectionValues) && bFieldsValid; + if (StrateValues != nullptr) + { + OutManifest.Strates.Reset(); + OutManifest.Strates.Reserve(StrateValues->Num()); + for (const TSharedPtr& Value : *StrateValues) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + FVoxelSeasonStrate& Strate = OutManifest.Strates.AddDefaulted_GetRef(); + if (!DeserializeStrate(Value->AsObject(), Strate)) + { + bFieldsValid = false; + } + } + } + if (RejectionValues != nullptr) + { + OutManifest.RejectionCounts.Reset(); + OutManifest.RejectionCounts.Reserve(RejectionValues->Num()); + for (const TSharedPtr& Value : *RejectionValues) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + TSharedPtr Object = Value->AsObject(); + FVoxelSeasonRejectionCount& Row = OutManifest.RejectionCounts.AddDefaulted_GetRef(); + bFieldsValid = ReadJsonString(Object, TEXT("reason"), Row.Reason) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Object, TEXT("count"), Row.Count) && bFieldsValid; + } + } + + const TArray>* AuditValues = nullptr; + if (ReadJsonArray(Root, TEXT("candidate_audit"), AuditValues)) + { + OutManifest.CandidateAudit.Reset(); + OutManifest.CandidateAudit.Reserve(AuditValues->Num()); + for (const TSharedPtr& Value : *AuditValues) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + bFieldsValid = false; + continue; + } + TSharedPtr Object = Value->AsObject(); + FVoxelSeasonCandidateAudit& Row = OutManifest.CandidateAudit.AddDefaulted_GetRef(); + FString ArchetypeText; + double Distance = 0.0; + bFieldsValid = ReadJsonInt32(Object, TEXT("candidate_index"), Row.CandidateIndex) && bFieldsValid; + bFieldsValid = ReadJsonInt32(Object, TEXT("seed"), Row.Seed) && bFieldsValid; + bFieldsValid = ReadJsonString(Object, TEXT("archetype"), ArchetypeText) && bFieldsValid; + double RecipeHash = 0.0; + bFieldsValid = ReadJsonNumber(Object, TEXT("recipe_hash"), RecipeHash) && bFieldsValid; + bFieldsValid = RecipeHash >= 0.0 && RecipeHash <= 4294967295.0 + && FMath::FloorToDouble(RecipeHash) == RecipeHash && bFieldsValid; + Row.RecipeHash = static_cast(RecipeHash); + bFieldsValid = ReadJsonNumber(Object, TEXT("distance_from_corpus_centroid"), Distance) && bFieldsValid; + Row.DistanceFromCorpusCentroid = Distance; + bFieldsValid = ReadJsonBool(Object, TEXT("passed_hard_gates"), Row.bPassedHardGates) && bFieldsValid; + bFieldsValid = ReadJsonBool(Object, TEXT("selected"), Row.bSelected) && bFieldsValid; + bFieldsValid = ReadJsonString(Object, TEXT("rejection_reason"), Row.RejectionReason) && bFieldsValid; + bFieldsValid = ParseArchetype(ArchetypeText, Row.Archetype) && bFieldsValid; + } + } + + if (OutManifest.Strates.Num() != OutManifest.SelectedCount + || OutManifest.SelectedCount <= 0 + || OutManifest.WorldRadiusVoxels != 0.0f) + { + bFieldsValid = false; + } + for (int32 Index = 0; Index < OutManifest.Strates.Num(); ++Index) + { + const FVoxelSeasonStrate& Strate = OutManifest.Strates[Index]; + const float StoredTop = static_cast(Strate.TopWorldZ); + const float StoredBottom = static_cast(Strate.BottomWorldZ); + if (Strate.DepthIndex != Index || Strate.bUsesRegions || !Strate.bPassedPrimordialLaw + || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected) + { + bFieldsValid = false; + break; + } + if (Strate.bUsesRegions + && (!Strate.Regions.IsValid() + || Strate.Regions.RegionCount <= 1 + || Strate.Regions.Seed != Strate.Seed + || Strate.Regions.StrateIndex != Strate.DepthIndex + || Strate.Regions.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Strate.Seed, Strate.DepthIndex) + || !Strate.Regions.bHasGlobalStructuralParams + || FMemory::Memcmp(&Strate.Regions.StrateTopWorldZ, + &StoredTop, sizeof(float)) != 0 + || FMemory::Memcmp(&Strate.Regions.StrateBottomWorldZ, + &StoredBottom, sizeof(float)) != 0) + ) + { + bFieldsValid = false; + break; + } + } + if (!bFieldsValid) + { + OutReport = TEXT("season manifest contains invalid fields"); + OutManifest.Error = OutReport; + OutManifest.bValid = false; + return false; + } + const FString ComputedHash = VF_ComputeVoxelSeasonManifestContentHash(OutManifest); + if (ComputedHash.IsEmpty() + || !ComputedHash.Equals(OutManifest.ContentHash, ESearchCase::IgnoreCase)) + { + OutReport = FString::Printf( + TEXT("season manifest content hash mismatch (stored %s, computed %s)"), + *OutManifest.ContentHash, *ComputedHash); + OutManifest.Error = OutReport; + OutManifest.bValid = false; + return false; + } + OutManifest.SerializedJson = ManifestJson; + OutReport = FString::Printf(TEXT("loaded season manifest hash %s"), *OutManifest.ContentHash); + return OutManifest.IsUsable(); +} + +bool VF_LoadVoxelSeasonManifest(const FString& ManifestPath, + FVoxelSeasonManifest& OutManifest, + FString& OutReport) +{ + FString JsonText; + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + OutManifest = FVoxelSeasonManifest(); + OutManifest.Error = OutReport; + return false; + } + if (!FFileHelper::LoadFileToString(JsonText, *ManifestPath)) + { + OutReport = FString::Printf(TEXT("could not read season manifest %s"), *ManifestPath); + OutManifest = FVoxelSeasonManifest(); + OutManifest.Error = OutReport; + return false; + } + if (!VF_DeserializeVoxelSeasonManifest(JsonText, OutManifest, OutReport)) + { + OutReport = FString::Printf(TEXT("season manifest %s: %s"), *ManifestPath, *OutReport); + OutManifest.Error = OutReport; + return false; + } + OutManifest.ManifestPath = ManifestPath; + OutReport = FString::Printf(TEXT("loaded season manifest from %s (hash %s)"), + *ManifestPath, *OutManifest.ContentHash); + return true; +} + +namespace VoxelSeasonRuntimePrivate +{ + float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: return Params.TunnelNetworkParams.StrateTopWorldZ; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: return Params.SlabParams.StrateTopWorldZ; + case ECaveGeneratorType::Maze: return Params.MazeParams.StrateTopWorldZ; + case ECaveGeneratorType::SurfaceWorld: return Params.SurfaceParams.StrateTopWorldZ; + case ECaveGeneratorType::VerticalShafts: return Params.VerticalShaftParams.StrateTopWorldZ; + case ECaveGeneratorType::FloatingIslands: return Params.FloatingIslandParams.StrateTopWorldZ; + } + return 0.0f; + } + + float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: return Params.TunnelNetworkParams.StrateBottomWorldZ; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: return Params.SlabParams.StrateBottomWorldZ; + case ECaveGeneratorType::Maze: return Params.MazeParams.StrateBottomWorldZ; + case ECaveGeneratorType::SurfaceWorld: return Params.SurfaceParams.StrateBottomWorldZ; + case ECaveGeneratorType::VerticalShafts: return Params.VerticalShaftParams.StrateBottomWorldZ; + case ECaveGeneratorType::FloatingIslands: return Params.FloatingIslandParams.StrateBottomWorldZ; + } + return 0.0f; + } + + bool SameFloatBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } +} + +#if WITH_EDITOR +namespace VoxelSeasonCompositionPrivate +{ + float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); + float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); + float ActiveSeal(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype); + void SetRuntimeBounds(FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); + void SetRegionRuntimeBounds(FVoxelStrateRegionManifest& Regions, + int32 Seed, int32 StrateIndex, + float TopWorldZ, float BottomWorldZ); + const TCHAR* RejectionReasonForLaw(EVoxelConnectivityResult Result); + + // These caps are policy/allocator guards, not gameplay limits. They keep a malformed build-box + // request from turning the candidate audit or Z-layout arithmetic into an unbounded allocation. + constexpr int32 MaxCandidateCount = 4096; + constexpr int32 MaxSelectedStrateCount = 256; + constexpr int32 MaxStrateHeightInChunks = 4096; + +#if WITH_EDITOR + struct FSeasonCandidateState + { + FVoxelStrateComposerCandidate Candidate; + FVoxelStrateMeasuredMetrics Metrics; + double DistanceFromCorpusCentroid = 0.0; + bool bPassedHardGates = false; + bool bSelected = false; + bool bUnavailable = false; + bool bOutlierPool = false; + FString RejectionReason; + }; +#endif + + struct FSeasonEvaluation + { + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; + EVoxelConnectivityResult PrimordialLawResult = EVoxelConnectivityResult::OutOfWindow; + FVoxelStrateMeasuredMetrics Metrics; + FString FailureReason; + }; + + class FSeasonStackSampler final : public IVoxelStrateDensitySampler + { + public: + explicit FSeasonStackSampler(const FVoxelOpStack& InStack) + : Stack(InStack) + { + } + + float SampleDensity(float WorldX, float WorldY, float WorldZ) const override + { + // FVoxelOpStack::EvalMC is the established MC-facing convention: negative solid, + // positive air. No generation path is called here; this class exists only for the + // offline measurement API. + return Stack.EvalMC(WorldX, WorldY, WorldZ); + } + + private: + const FVoxelOpStack& Stack; + }; + + bool IsFinitePolicy(const FVoxelSeasonSelectionPolicy& Policy) + { + const float FractionSum = Policy.GroundedFraction + Policy.OutlierFraction; + return FMath::IsFinite(Policy.GroundedFraction) + && FMath::IsFinite(Policy.OutlierFraction) + && Policy.GroundedFraction >= 0.0f + && Policy.OutlierFraction >= 0.0f + && FMath::IsFinite(FractionSum) && FractionSum > 0.0f + && FMath::IsFinite(Policy.AdjacentSimilarityDistanceThreshold) + && Policy.AdjacentSimilarityDistanceThreshold >= 0.0 + && FMath::IsFinite(Policy.AdjacentSimilarityPenaltyWeight) + && Policy.AdjacentSimilarityPenaltyWeight >= 0.0 + && FMath::IsFinite(Policy.NewArchetypeBonus) + && FMath::IsFinite(Policy.NewRecipeBonus) + && Policy.BossSlotInterval >= 0 + && FMath::IsFinite(Policy.MinimumLargestComponentShare) + && Policy.MinimumLargestComponentShare >= 0.0f + && Policy.MinimumLargestComponentShare <= 1.0f + && FMath::IsFinite(Policy.FeatureScaleNormalizationVoxels) + && Policy.FeatureScaleNormalizationVoxels > 0.0f + && FMath::IsFinite(Policy.ClearanceNormalizationVoxels) + && Policy.ClearanceNormalizationVoxels > 0.0f; + } + + bool IsValidMeasureSettings(const FVoxelStrateMeasureSettings& Settings) + { + return Settings.SampleStep > 0 + && Settings.RadiusInVoxels > 0 + && Settings.MaxCells > 0 + && Settings.MaxRouteRetries >= 0 + && Settings.HeadroomCells >= 0 + && Settings.InteriorMarginVoxels >= -1 + && FMath::IsFinite(Settings.CenterXY.X) + && FMath::IsFinite(Settings.CenterXY.Y) + && FMath::IsFinite(Settings.CoverMarginVoxels) + && Settings.CoverMarginVoxels >= 0.0f + && FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels) + && Settings.PlayerCapsuleRadiusVoxels > 0.0f + && FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels) + && Settings.PlayerCapsuleHalfHeightVoxels > 0.0f + && FMath::IsFinite(Settings.PlayerMaxStepHeightMeters) + && Settings.PlayerMaxStepHeightMeters >= 0.0f + && FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) + && Settings.PlayerWalkableFloorAngleDegrees >= 0.0f + && Settings.PlayerWalkableFloorAngleDegrees <= 90.0f + && FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) + && Settings.PlayerSupportPatchMinCoverageFraction > 0.0f + && Settings.PlayerSupportPatchMinCoverageFraction <= 1.0f; + } + + bool IsBossSlot(int32 DepthIndex, int32 TotalStrates, int32 Interval) + { + // Slot zero is the surface entry, and the terminal slot is deliberately never a boss. + return Interval > 0 && DepthIndex > 0 && DepthIndex < TotalStrates - 1 + && ((DepthIndex + 1) % Interval) == 0; + } + + uint32 SeasonAttemptHash(int32 SeasonSeed, int32 CandidateIndex) + { + // The attempt index is deliberately folded into the hash. Reordering candidate work on + // the build box therefore cannot accidentally turn an attempt into mutable RNG state. + uint32 Hash = VoxelHash::Mix(static_cast(SeasonSeed) ^ 0x53454153u); + Hash = VoxelHash::Mix(Hash ^ (static_cast(CandidateIndex) * 0x9E3779B9u)); + return VoxelHash::Mix(Hash ^ 0x54494552u); + } + + FString PercentageText(float Fraction) + { + return FString::Printf(TEXT("%.0f%%"), static_cast(Fraction * 100.0f)); + } + + void AddRejection(TMap& Counts, const FString& Reason) + { + const FString Key = Reason.IsEmpty() ? TEXT("unspecified") : Reason; + Counts.FindOrAdd(Key) += 1; + } + + void ExportRejectionCounts(const TMap& Counts, + TArray& OutCounts) + { + TArray Reasons; + Counts.GetKeys(Reasons); + Reasons.Sort([](const FString& A, const FString& B) { return A < B; }); + OutCounts.Reset(); + for (const FString& Reason : Reasons) + { + FVoxelSeasonRejectionCount& Row = OutCounts.AddDefaulted_GetRef(); + Row.Reason = Reason; + Row.Count = Counts.FindChecked(Reason); + } + } + + void CopyDefinitionParams(const UVoxelStrateDefinition& Definition, + FVoxelSeasonFixedStrate& OutFixed) + { + OutFixed = FVoxelSeasonFixedStrate(); + OutFixed.HeightInChunks = Definition.StrateHeightInChunks; + OutFixed.Archetype = Definition.GeneratorType; + OutFixed.SourceDefinitionPath = Definition.GetPathName(); + OutFixed.bUsesRecipe = false; + OutFixed.Params.TunnelNetworkParams = Definition.GenerationParams; + OutFixed.Params.SlabParams = Definition.SlabParams; + OutFixed.Params.MazeParams = Definition.MazeParams; + OutFixed.Params.SurfaceParams = Definition.SurfaceParams; + OutFixed.Params.VerticalShaftParams = Definition.VerticalShaftParams; + OutFixed.Params.FloatingIslandParams = Definition.FloatingIslandParams; + } + + bool ResolveFixedStrates(const FVoxelSeasonCompositionSettings& Settings, + int32 SeasonSeed, + TMap& OutFixed, + FString& OutError) + { + OutFixed = Settings.FixedStrates; + if (Settings.WorldSettings == nullptr) + { + return true; + } + + for (const TPair>& Pair + : Settings.WorldSettings->FixedStrates) + { + if (OutFixed.Contains(Pair.Key)) + { + continue; + } + UVoxelStrateDefinition* Definition = Pair.Value.LoadSynchronous(); + if (Definition == nullptr) + { + OutError = FString::Printf(TEXT("fixed strate slot %d could not load definition %s"), + Pair.Key, *Pair.Value.ToSoftObjectPath().ToString()); + return false; + } + FVoxelSeasonFixedStrate Fixed; + CopyDefinitionParams(*Definition, Fixed); + Fixed.Seed = SeasonSeed; + if (Fixed.SourceDefinitionPath.IsEmpty()) + { + Fixed.SourceDefinitionPath = Pair.Value.ToSoftObjectPath().ToString(); + } + OutFixed.Add(Pair.Key, MoveTemp(Fixed)); + } + return true; + } + +#if WITH_EDITOR + void MakeCandidateStrate(const FVoxelStrateComposerCandidate& Candidate, + int32 CandidateIndex, + int32 HeightInChunks, + int32 TopWorldZ, + int32 BottomWorldZ, + FVoxelSeasonStrate& OutStrate) + { + OutStrate = FVoxelSeasonStrate(); + OutStrate.DepthIndex = INDEX_NONE; + OutStrate.CandidateIndex = CandidateIndex; + OutStrate.Seed = Candidate.Seed; + OutStrate.HeightInChunks = HeightInChunks; + OutStrate.TopWorldZ = TopWorldZ; + OutStrate.BottomWorldZ = BottomWorldZ; + OutStrate.Archetype = Candidate.Archetype; + OutStrate.Recipe = Candidate.Recipe; + OutStrate.Params = Candidate.ArchetypeParams; + OutStrate.Regions = Candidate.Regions; + OutStrate.bUsesRecipe = true; + // Lateral composition remains a laboratory feature until its cross-seam law is perfect. + // The season artifact must therefore stay on the proved single-stack runtime contract. + OutStrate.bUsesRegions = VF_LateralRegionsAreShippable() + && Candidate.Regions.RegionCount > 1; + SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + if (OutStrate.Regions.IsValid()) + { + SetRegionRuntimeBounds(OutStrate.Regions, Candidate.Seed, CandidateIndex, + static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } + } +#endif + + void MakeFixedStrate(const FVoxelSeasonFixedStrate& Fixed, + int32 DepthIndex, + int32 TopWorldZ, + int32 BottomWorldZ, + FVoxelSeasonStrate& OutStrate) + { + OutStrate = FVoxelSeasonStrate(); + OutStrate.DepthIndex = DepthIndex; + OutStrate.CandidateIndex = INDEX_NONE; + OutStrate.Seed = Fixed.Seed; + OutStrate.HeightInChunks = Fixed.HeightInChunks; + OutStrate.TopWorldZ = TopWorldZ; + OutStrate.BottomWorldZ = BottomWorldZ; + OutStrate.Archetype = Fixed.Archetype; + OutStrate.Recipe = Fixed.Recipe; + OutStrate.Params = Fixed.Params; + OutStrate.Regions = Fixed.Regions; + OutStrate.SourceDefinitionPath = Fixed.SourceDefinitionPath; + OutStrate.bFixed = true; + OutStrate.bUsesRecipe = Fixed.bUsesRecipe; + OutStrate.bUsesRegions = Fixed.bUsesRegions && Fixed.Regions.IsValid(); + SetRuntimeBounds(OutStrate.Params, static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + if (OutStrate.bUsesRegions) + { + SetRegionRuntimeBounds(OutStrate.Regions, Fixed.Seed, DepthIndex, + static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } + } + + bool EvaluateStrate(const FVoxelSeasonStrate& Strate, + const FVoxelSeasonCompositionSettings& Settings, + FSeasonEvaluation& OutEvaluation, + FVoxelStrateSampleGrid* OutGrid = nullptr, + FVoxelStrateMetrics* OutRawMetrics = nullptr) + { + OutEvaluation = FSeasonEvaluation(); + if (OutGrid != nullptr) + { + *OutGrid = FVoxelStrateSampleGrid(); + } + if (OutRawMetrics != nullptr) + { + *OutRawMetrics = FVoxelStrateMetrics(); + } + + FVoxelOpStack Stack; + FVoxelOpContext Context; + FString BuildError; + bool bBuilt = false; + if (Strate.bUsesRegions) + { + bBuilt = VF_BuildStrateRegionStack( + Strate.Regions, Settings.OriginSpineRadius, nullptr, + Stack, Context, &BuildError); + } + else if (Strate.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Strate.Recipe, Strate.Params, Strate.Seed, + Settings.OriginSpineRadius, nullptr, Stack, + Context, &BuildError); + } + else + { +#if WITH_EDITOR + const float Seal = ActiveSeal(Strate.Params, Strate.Archetype); + bBuilt = VF_BuildNativeStrateStackForCandidate( + Strate.Archetype, Strate.Params, Strate.Seed, Settings.OriginSpineRadius, + 0.0f, Seal, nullptr, Stack, Context); + if (!bBuilt) + { + BuildError = TEXT("native fixed strate stack could not be materialised"); + } +#else + BuildError = TEXT("native fixed strate validation is editor-only"); +#endif + } + if (!bBuilt) + { + OutEvaluation.FailureReason = BuildError.IsEmpty() + ? TEXT("recipe/native stack build failed") : BuildError; + return false; + } + if (Context.WorldRadiusVoxels != 0.0f) + { + OutEvaluation.FailureReason = TEXT("WorldRadiusVoxels was not zero during composition"); + return false; + } + + const float Seal = Context.EdgeSealThickness; + if (!FMath::IsFinite(Seal) || Seal < 0.0f) + { + OutEvaluation.FailureReason = TEXT("boundary seal thickness is invalid"); + return false; + } + Stack.PrepareChunk(Context); + FSeasonStackSampler Sampler(Stack); + FVoxelStrateMetrics RawMetrics = VF_MeasureStrateWithSampler( + Sampler, Strate.BottomWorldZ, Strate.TopWorldZ, Seal, + Settings.MeasureSettings, OutGrid); + if (OutRawMetrics != nullptr) + { + *OutRawMetrics = RawMetrics; + } + if (!RawMetrics.bValid) + { + OutEvaluation.FailureReason = RawMetrics.RefusalReason.IsEmpty() + ? TEXT("measurement failed") + : FString::Printf(TEXT("measurement failed: %s"), *RawMetrics.RefusalReason); + return false; + } + + OutEvaluation.bPassedNonVacuous = RawMetrics.NumSampled > 0 + && RawMetrics.NumAir > 0 && RawMetrics.NumSolid > 0; + if (!OutEvaluation.bPassedNonVacuous) + { + OutEvaluation.FailureReason = TEXT("vacuous"); + return false; + } + + OutEvaluation.bPassedLargestComponent = + RawMetrics.LargestComponentShare >= Settings.SelectionPolicy.MinimumLargestComponentShare; + if (!OutEvaluation.bPassedLargestComponent) + { + OutEvaluation.FailureReason = FString::Printf( + TEXT("fragmented (largest component %.6f < %.6f)"), + RawMetrics.LargestComponentShare, + Settings.SelectionPolicy.MinimumLargestComponentShare); + return false; + } + + const float InteriorBottom = static_cast(Strate.BottomWorldZ) + + static_cast(RawMetrics.ResolvedMarginVoxels); + const float InteriorTop = static_cast(Strate.TopWorldZ) + - static_cast(RawMetrics.ResolvedMarginVoxels); + if (!FMath::IsFinite(InteriorBottom) || !FMath::IsFinite(InteriorTop) + || InteriorTop - InteriorBottom <= 1.0f) + { + OutEvaluation.FailureReason = TEXT("primordial law failed: no two interior endpoint cells"); + return false; + } + + FVoxelStrateMeasureSettings PlayerFitSettings = Settings.PlayerFitMeasureSettings; + // The season primordial law has always used the (0,0) spine as its arrival and departure + // line. Keep the fine ROI centred on that same physical route; centring it on the coarse + // largest-air representative can exclude the law endpoints and turn a valid route into + // an arbitrary OutOfWindow result. The player-fit pass still has its independent fine + // resolution and bounded radius. + PlayerFitSettings.CenterXY = FVector2D::ZeroVector; + PlayerFitSettings.CoverPointA.Reset(); + PlayerFitSettings.CoverPointB.Reset(); + FVoxelStrateMetrics PlayerFitMetrics; + const FVoxelConnectivityDiagnostics PlayerFitLaw = + VF_DiagnosePlayerFitConnectivityWithSampler( + Sampler, Strate.BottomWorldZ, Strate.TopWorldZ, Seal, + FVector(0.0f, 0.0f, InteriorBottom + 0.5f), + FVector(0.0f, 0.0f, InteriorTop - 0.5f), + PlayerFitSettings, &PlayerFitMetrics); + // The legacy fields remain the bounded coarse measurement. The player fields come from + // the separate one-voxel ROI that also backed the restricted route query. + RawMetrics.bPlayerFitResolved = PlayerFitMetrics.bPlayerFitResolved; + RawMetrics.PlayerFitRefusalReason = PlayerFitMetrics.PlayerFitRefusalReason; + RawMetrics.NumPlayerFitCells = PlayerFitMetrics.NumPlayerFitCells; + RawMetrics.PlayerFitFraction = PlayerFitMetrics.PlayerFitFraction; + RawMetrics.NumTraversableComponents = PlayerFitMetrics.NumTraversableComponents; + RawMetrics.LargestTraversableComponentCells = + PlayerFitMetrics.LargestTraversableComponentCells; + RawMetrics.TraversableComponentShare = PlayerFitMetrics.TraversableComponentShare; + RawMetrics.MinimumPlayerClearanceVoxels = + PlayerFitMetrics.MinimumPlayerClearanceVoxels; + if (OutRawMetrics != nullptr) + { + *OutRawMetrics = RawMetrics; + } + + OutEvaluation.PrimordialLawResult = PlayerFitLaw.Result; + const bool bPlayerFitExists = PlayerFitMetrics.bValid + && PlayerFitMetrics.bPlayerFitResolved + && PlayerFitMetrics.NumPlayerFitCells > 0 + && PlayerFitMetrics.NumTraversableComponents > 0 + && PlayerFitMetrics.TraversableComponentShare > 0.0f; + OutEvaluation.bPassedPrimordialLaw = bPlayerFitExists + && PlayerFitLaw.bValid + && PlayerFitLaw.Result == EVoxelConnectivityResult::Connected; + if (!OutEvaluation.bPassedPrimordialLaw) + { + OutEvaluation.FailureReason = FString::Printf( + TEXT("player-fit primordial law failed: %s%s"), + RejectionReasonForLaw(PlayerFitLaw.Result), + bPlayerFitExists ? TEXT("") : TEXT(" (no player-fitting floor volume)")); + return false; + } + + OutEvaluation.Metrics = VF_SummarizeStrateMetrics(RawMetrics); + if (!OutEvaluation.Metrics.IsUsable()) + { + OutEvaluation.FailureReason = TEXT("measurement summary was not usable"); + return false; + } + return true; + } + + FVoxelStratePromotionPolicy MakeMetricDistancePolicy( + const FVoxelSeasonSelectionPolicy& Policy) + { + FVoxelStratePromotionPolicy Result; + Result.FeatureScaleNormalizationVoxels = Policy.FeatureScaleNormalizationVoxels; + Result.ClearanceNormalizationVoxels = Policy.ClearanceNormalizationVoxels; + return Result; + } + + bool IsBetterScore(double Score, int32 CandidateIndex, + double BestScore, int32 BestCandidateIndex) + { + constexpr double TieEpsilon = 1.0e-12; + if (BestCandidateIndex == INDEX_NONE) + { + return true; + } + if (Score > BestScore + TieEpsilon) + { + return true; + } + return FMath::Abs(Score - BestScore) <= TieEpsilon + && CandidateIndex < BestCandidateIndex; + } + float ActiveTop(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Params.TunnelNetworkParams.StrateTopWorldZ; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Params.SlabParams.StrateTopWorldZ; + case ECaveGeneratorType::Maze: + return Params.MazeParams.StrateTopWorldZ; + case ECaveGeneratorType::SurfaceWorld: + return Params.SurfaceParams.StrateTopWorldZ; + case ECaveGeneratorType::VerticalShafts: + return Params.VerticalShaftParams.StrateTopWorldZ; + case ECaveGeneratorType::FloatingIslands: + return Params.FloatingIslandParams.StrateTopWorldZ; + default: + return 0.0f; + } + } + + float ActiveBottom(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Params.TunnelNetworkParams.StrateBottomWorldZ; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Params.SlabParams.StrateBottomWorldZ; + case ECaveGeneratorType::Maze: + return Params.MazeParams.StrateBottomWorldZ; + case ECaveGeneratorType::SurfaceWorld: + return Params.SurfaceParams.StrateBottomWorldZ; + case ECaveGeneratorType::VerticalShafts: + return Params.VerticalShaftParams.StrateBottomWorldZ; + case ECaveGeneratorType::FloatingIslands: + return Params.FloatingIslandParams.StrateBottomWorldZ; + default: + return 0.0f; + } + } + + float ActiveSeal(const FVoxelStrateArchetypeParams& Params, ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return Params.TunnelNetworkParams.BoundarySealThickness; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return Params.SlabParams.BoundarySealThickness; + case ECaveGeneratorType::Maze: + return Params.MazeParams.BoundarySealThickness; + case ECaveGeneratorType::SurfaceWorld: + return Params.SurfaceParams.BoundarySealThickness; + case ECaveGeneratorType::VerticalShafts: + return Params.VerticalShaftParams.BoundarySealThickness; + case ECaveGeneratorType::FloatingIslands: + return Params.FloatingIslandParams.BoundarySealThickness; + default: + return 0.0f; + } + } + + void SetRuntimeBounds(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; + } + + void SetRegionRuntimeBounds(FVoxelStrateRegionManifest& Regions, + int32 Seed, int32 StrateIndex, + float TopWorldZ, float BottomWorldZ) + { + VF_RekeyStrateRegionManifest(Regions, Seed, StrateIndex); + Regions.StrateTopWorldZ = TopWorldZ; + Regions.StrateBottomWorldZ = BottomWorldZ; + Regions.bHasGlobalStructuralParams = true; + for (FVoxelStrateRegion& Region : Regions.Regions) + { + VF_SetStrateArchetypeRuntimeBounds(Region.ArchetypeParams, + TopWorldZ, BottomWorldZ); + } + } + + bool SameFloatBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } + + const TCHAR* RejectionReasonForLaw(EVoxelConnectivityResult Result) + { + return VoxelSeasonManifestPrivate::ConnectivityName(Result); + } +} +#endif // WITH_EDITOR — composition, measurement and review helpers never ship + +namespace VoxelSeasonCompositionPrivate +{ +#if WITH_EDITOR + bool WriteReviewPage(const FVoxelSeasonManifest& Manifest, + const FVoxelSeasonCompositionSettings& Settings, + const FString& ReviewDirectory, + FString& OutReviewPath, + FString& OutError); +#endif +} + +#if WITH_EDITOR + +FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, + const FVoxelSeasonCompositionSettings& Settings) +{ + using namespace VoxelSeasonCompositionPrivate; + FVoxelSeasonManifest Manifest; + Manifest.Season = Settings.SeasonNumber != 0 + ? Settings.SeasonNumber + : (Settings.WorldSettings != nullptr ? Settings.WorldSettings->CurrentSeason : 0); + Manifest.Seed = SeasonSeed; + Manifest.SchemaVersion = FVoxelSeasonManifest::CurrentSchemaVersion; + Manifest.SelectionPolicy = Settings.SelectionPolicy; + Manifest.PolicyNote = TEXT( + "PROVISIONAL POLICY: grounded/outlier ratio, adjacency threshold/weight, variety bonuses, " + "boss cadence, and metric normalizers are review knobs. This is not a settled quality score. " + "Jahni reviews the selected descent page and may veto the season before publish."); + + auto Fail = [&](const FString& Reason) -> FVoxelSeasonManifest + { + Manifest.bValid = false; + Manifest.Error = Reason; + return Manifest; + }; + + if (Settings.WorldSettings != nullptr + && Settings.WorldSettings->WorldRadiusVoxels != 0.0f) + { + return Fail(TEXT("WorldRadiusVoxels must remain exactly 0 for season composition.")); + } + if (Settings.WorldRadiusVoxels != 0.0f) + { + return Fail(TEXT("WorldRadiusVoxels must remain exactly 0 for season composition.")); + } + if (Settings.CandidateCount <= 0 || Settings.CandidateCount > MaxCandidateCount) + { + return Fail(FString::Printf(TEXT("CandidateCount must be in [1,%d]."), MaxCandidateCount)); + } + if (Settings.SelectedStrateCount <= 0 + || Settings.SelectedStrateCount > MaxSelectedStrateCount) + { + return Fail(FString::Printf(TEXT("SelectedStrateCount must be in [1,%d]."), + MaxSelectedStrateCount)); + } + if (Settings.StrateHeightInChunks <= 0 + || Settings.StrateHeightInChunks > MaxStrateHeightInChunks) + { + return Fail(FString::Printf(TEXT("StrateHeightInChunks must be in [1,%d]."), + MaxStrateHeightInChunks)); + } + if (Settings.InterStrateGapChunks < 0 + || Settings.InterStrateGapChunks > MaxStrateHeightInChunks) + { + return Fail(TEXT("InterStrateGapChunks is outside the bounded composition range.")); + } + if (!FMath::IsFinite(Settings.OriginSpineRadius) || Settings.OriginSpineRadius < 0.0f) + { + return Fail(TEXT("OriginSpineRadius must be finite and non-negative.")); + } + if (!IsFinitePolicy(Settings.SelectionPolicy)) + { + return Fail(TEXT("SelectionPolicy contains a non-finite or out-of-range knob.")); + } + if (!IsValidMeasureSettings(Settings.MeasureSettings)) + { + return Fail(TEXT("MeasureSettings are invalid for a bounded offline pass.")); + } + if (!IsValidMeasureSettings(Settings.PlayerFitMeasureSettings)) + { + return Fail(TEXT("PlayerFitMeasureSettings are invalid for a bounded offline pass.")); + } + + TMap FixedStrates; + FString FixedError; + if (!ResolveFixedStrates(Settings, SeasonSeed, FixedStrates, FixedError)) + { + return Fail(FixedError); + } + for (const TPair& Pair : FixedStrates) + { + if (Pair.Key < 0 || Pair.Key >= Settings.SelectedStrateCount) + { + return Fail(FString::Printf(TEXT("FixedStrates index %d is outside [0,%d)."), + Pair.Key, Settings.SelectedStrateCount)); + } + if (!VoxelSeasonManifestPrivate::IsSupportedArchetype(Pair.Value.Archetype)) + { + return Fail(FString::Printf(TEXT("FixedStrates index %d has an unsupported archetype."), + Pair.Key)); + } + if (Pair.Value.HeightInChunks <= 0 + || Pair.Value.HeightInChunks > MaxStrateHeightInChunks) + { + return Fail(FString::Printf(TEXT("FixedStrates index %d has an invalid height."), + Pair.Key)); + } + } + + FVoxelStrateCorpus OwnedCorpus; + const FVoxelStrateCorpus* Corpus = Settings.Corpus; + if (Corpus == nullptr) + { + FString CorpusReport; + if (!OwnedCorpus.LoadFromAssetRegistry(CorpusReport) || !OwnedCorpus.IsValid()) + { + return Fail(FString::Printf(TEXT("could not load the strate corpus: %s"), + *CorpusReport)); + } + Corpus = &OwnedCorpus; + } + if (Corpus == nullptr || !Corpus->IsValid()) + { + return Fail(TEXT("season composition requires a valid strate corpus.")); + } + + Manifest.InputCorpusHash = Corpus->GetContentsHash(); + Manifest.CandidateCount = Settings.CandidateCount; + Manifest.TotalStrates = Settings.SelectedStrateCount; + Manifest.InterStrateGapChunks = Settings.InterStrateGapChunks; + Manifest.OriginSpineRadius = Settings.OriginSpineRadius; + Manifest.WorldRadiusVoxels = 0.0f; + + TArray Candidates; + Candidates.Reserve(Settings.CandidateCount); + for (int32 CandidateIndex = 0; CandidateIndex < Settings.CandidateCount; ++CandidateIndex) + { + FSeasonCandidateState& State = Candidates.AddDefaulted_GetRef(); + const int32 CandidateSeed = static_cast( + SeasonAttemptHash(SeasonSeed, CandidateIndex)); + State.Candidate = VF_RollStrateCandidate( + *Corpus, CandidateSeed, CandidateIndex, true); + if (!State.Candidate.bValid) + { + State.RejectionReason = State.Candidate.FailureReason.IsEmpty() + ? TEXT("candidate roll failed") : State.Candidate.FailureReason; + continue; + } + + State.DistanceFromCorpusCentroid = VF_DistanceFromStrateCorpusCentroid( + *Corpus, State.Candidate.ArchetypeParams, State.Candidate.Archetype); + if (!FMath::IsFinite(State.DistanceFromCorpusCentroid)) + { + State.DistanceFromCorpusCentroid = 0.0; + } + + FVoxelSeasonStrate Probe; + const int32 ProbeTopWorldZ = 32; + const int32 ProbeBottomWorldZ = ProbeTopWorldZ + - Settings.StrateHeightInChunks * 32; + MakeCandidateStrate(State.Candidate, CandidateIndex, + Settings.StrateHeightInChunks, ProbeTopWorldZ, + ProbeBottomWorldZ, Probe); + FSeasonEvaluation Evaluation; + if (EvaluateStrate(Probe, Settings, Evaluation)) + { + State.Metrics = Evaluation.Metrics; + State.bPassedHardGates = true; + } + else + { + State.RejectionReason = Evaluation.FailureReason; + } + } + + TArray SurvivorIndices; + SurvivorIndices.Reserve(Candidates.Num()); + for (int32 Index = 0; Index < Candidates.Num(); ++Index) + { + if (Candidates[Index].bPassedHardGates) + { + SurvivorIndices.Add(Index); + } + } + Manifest.SurvivorCount = SurvivorIndices.Num(); + + const int32 GeneratedSlotCount = Settings.SelectedStrateCount - FixedStrates.Num(); + if (GeneratedSlotCount < 0) + { + return Fail(TEXT("FixedStrates contains more slots than the requested season.")); + } + if (SurvivorIndices.Num() < GeneratedSlotCount) + { + return Fail(FString::Printf( + TEXT("only %d candidates survived the hard gates, but %d generated slots are required"), + SurvivorIndices.Num(), GeneratedSlotCount)); + } + + const float FractionSum = Settings.SelectionPolicy.GroundedFraction + + Settings.SelectionPolicy.OutlierFraction; + const int32 TargetOutlierCount = FMath::Clamp( + FMath::RoundToInt(static_cast(GeneratedSlotCount) + * Settings.SelectionPolicy.OutlierFraction / FractionSum), + 0, GeneratedSlotCount); + SurvivorIndices.Sort([&Candidates](int32 A, int32 B) + { + const double ADistance = Candidates[A].DistanceFromCorpusCentroid; + const double BDistance = Candidates[B].DistanceFromCorpusCentroid; + if (ADistance != BDistance) + { + return ADistance > BDistance; + } + return Candidates[A].Candidate.Index < Candidates[B].Candidate.Index; + }); + for (int32 Index = 0; Index < FMath::Min(TargetOutlierCount, SurvivorIndices.Num()); ++Index) + { + Candidates[SurvivorIndices[Index]].bOutlierPool = true; + } + + const FVoxelStratePromotionPolicy MetricDistancePolicy = + MakeMetricDistancePolicy(Settings.SelectionPolicy); + TSet UsedArchetypes; + TSet UsedRecipes; + FVoxelStrateMeasuredMetrics PreviousMetrics; + bool bHavePreviousMetrics = false; + int64 CurrentTopChunk = 0; + int32 GeneratedOrdinal = 0; + int32 SelectedGeneratedCount = 0; + int32 AssignedOutlierCount = 0; + int32 FixedSelectedCount = 0; + + auto GetLayoutBounds = [&](int32 HeightInChunks, + int32& OutTopWorldZ, int32& OutBottomWorldZ, + int64& OutBottomChunk) -> bool + { + if (HeightInChunks <= 0) return false; + const int64 TopChunk = CurrentTopChunk; + OutBottomChunk = TopChunk - static_cast(HeightInChunks) + 1; + const int64 TopWorld = (TopChunk + 1) * 32; + const int64 BottomWorld = OutBottomChunk * 32; + if (TopWorld < static_cast(TNumericLimits::Lowest()) + || TopWorld > static_cast(TNumericLimits::Max()) + || BottomWorld < static_cast(TNumericLimits::Lowest()) + || BottomWorld > static_cast(TNumericLimits::Max())) + { + return false; + } + OutTopWorldZ = static_cast(TopWorld); + OutBottomWorldZ = static_cast(BottomWorld); + return OutTopWorldZ > OutBottomWorldZ; + }; + + for (int32 DepthIndex = 0; DepthIndex < Settings.SelectedStrateCount; ++DepthIndex) + { + const FVoxelSeasonFixedStrate* Fixed = FixedStrates.Find(DepthIndex); + const int32 HeightInChunks = Fixed != nullptr + ? Fixed->HeightInChunks : Settings.StrateHeightInChunks; + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + int64 BottomChunk = 0; + if (!GetLayoutBounds(HeightInChunks, TopWorldZ, BottomWorldZ, BottomChunk)) + { + return Fail(FString::Printf(TEXT("season Z layout overflowed at slot %d"), DepthIndex)); + } + + if (Fixed != nullptr) + { + FVoxelSeasonStrate Strate; + MakeFixedStrate(*Fixed, DepthIndex, TopWorldZ, BottomWorldZ, Strate); + FSeasonEvaluation Evaluation; + if (!EvaluateStrate(Strate, Settings, Evaluation)) + { + return Fail(FString::Printf(TEXT("fixed slot %d failed validation: %s"), + DepthIndex, *Evaluation.FailureReason)); + } + Strate.Metrics = Evaluation.Metrics; + Strate.bPassedNonVacuous = Evaluation.bPassedNonVacuous; + Strate.bPassedLargestComponent = Evaluation.bPassedLargestComponent; + Strate.bPassedPrimordialLaw = Evaluation.bPassedPrimordialLaw; + Strate.PrimordialLawResult = Evaluation.PrimordialLawResult; + Strate.DistanceFromCorpusCentroid = VF_DistanceFromStrateCorpusCentroid( + *Corpus, Strate.Params, Strate.Archetype); + Strate.bBossSlot = IsBossSlot(DepthIndex, Settings.SelectedStrateCount, + Settings.SelectionPolicy.BossSlotInterval); + Strate.SelectionReason = TEXT("fixed: preserved from the absolute FixedStrates slot"); + if (Strate.bBossSlot) + { + Strate.SelectionReason += TEXT("; boss slot at the provisional five-slot cadence"); + } + Manifest.Strates.Add(MoveTemp(Strate)); + ++FixedSelectedCount; + UsedArchetypes.Add(static_cast(Fixed->Archetype)); + if (Fixed->bUsesRecipe) + { + UsedRecipes.Add(VF_HashStrateStructureRecipe(Fixed->Recipe)); + } + PreviousMetrics = Manifest.Strates.Last().Metrics; + bHavePreviousMetrics = true; + } + else + { + const int32 RemainingGeneratedSlots = GeneratedSlotCount - GeneratedOrdinal; + const int32 DesiredOutliersSoFar = FMath::RoundToInt( + static_cast(GeneratedOrdinal + 1) * TargetOutlierCount + / FMath::Max(1, GeneratedSlotCount)); + const bool bWantOutlier = DesiredOutliersSoFar > + AssignedOutlierCount; + + int32 BestStateIndex = INDEX_NONE; + double BestScore = -TNumericLimits::Max(); + bool bUsedClassFallback = false; + auto FindBest = [&](bool bRequireOutlierClass) -> bool + { + bool bFound = false; + for (int32 StateIndex = 0; StateIndex < Candidates.Num(); ++StateIndex) + { + FSeasonCandidateState& State = Candidates[StateIndex]; + if (!State.bPassedHardGates || State.bSelected || State.bUnavailable + || (bRequireOutlierClass && State.bOutlierPool != bWantOutlier)) + { + continue; + } + const bool bNewArchetype = !UsedArchetypes.Contains( + static_cast(State.Candidate.Archetype)); + const uint32 RecipeHash = VF_HashStrateStructureRecipe(State.Candidate.Recipe); + const bool bNewRecipe = !UsedRecipes.Contains(RecipeHash); + const bool bActualOutlier = State.bOutlierPool; + double Score = bActualOutlier + ? State.DistanceFromCorpusCentroid + : -State.DistanceFromCorpusCentroid; + if (bHavePreviousMetrics && State.Metrics.IsUsable()) + { + const double AdjacentDistance = VF_NormalizedMeasuredMetricDistance( + PreviousMetrics, State.Metrics, MetricDistancePolicy); + if (FMath::IsFinite(AdjacentDistance) + && AdjacentDistance < Settings.SelectionPolicy.AdjacentSimilarityDistanceThreshold) + { + Score -= (Settings.SelectionPolicy.AdjacentSimilarityDistanceThreshold + - AdjacentDistance) + * Settings.SelectionPolicy.AdjacentSimilarityPenaltyWeight; + } + } + if (bNewArchetype) + { + Score += Settings.SelectionPolicy.NewArchetypeBonus; + } + if (bNewRecipe) + { + Score += Settings.SelectionPolicy.NewRecipeBonus; + } + if (!FMath::IsFinite(Score)) + { + continue; + } + if (!bFound || IsBetterScore(Score, State.Candidate.Index, + BestScore, + Candidates[BestStateIndex].Candidate.Index)) + { + BestStateIndex = StateIndex; + BestScore = Score; + bFound = true; + } + } + return bFound; + }; + + if (!FindBest(true)) + { + bUsedClassFallback = FindBest(false); + } + if (BestStateIndex == INDEX_NONE) + { + return Fail(FString::Printf( + TEXT("no validated candidate remains for season slot %d (%d generated slots remain)"), + DepthIndex, RemainingGeneratedSlots)); + } + + bool bPlaced = false; + while (BestStateIndex != INDEX_NONE) + { + FSeasonCandidateState& State = Candidates[BestStateIndex]; + FVoxelSeasonStrate Strate; + MakeCandidateStrate(State.Candidate, State.Candidate.Index, + Settings.StrateHeightInChunks, TopWorldZ, + BottomWorldZ, Strate); + Strate.DepthIndex = DepthIndex; + if (Strate.bUsesRegions) + { + // Candidate probes use the attempt index only to make their provisional + // measurements independent. The persisted world is keyed by its absolute + // strate slot, so rekey before the final placement gate and measurement. + SetRegionRuntimeBounds(Strate.Regions, Strate.Seed, DepthIndex, + static_cast(TopWorldZ), + static_cast(BottomWorldZ)); + } + FSeasonEvaluation Evaluation; + if (!EvaluateStrate(Strate, Settings, Evaluation)) + { + State.bUnavailable = true; + State.RejectionReason = FString::Printf( + TEXT("final placement at depth %d failed: %s"), + DepthIndex, *Evaluation.FailureReason); + BestStateIndex = INDEX_NONE; + if (!FindBest(true)) + { + bUsedClassFallback = FindBest(false); + } + continue; + } + + const bool bActualOutlier = State.bOutlierPool; + const bool bNewArchetype = !UsedArchetypes.Contains( + static_cast(State.Candidate.Archetype)); + const uint32 RecipeHash = VF_HashStrateStructureRecipe(State.Candidate.Recipe); + const bool bNewRecipe = !UsedRecipes.Contains(RecipeHash); + Strate.Metrics = Evaluation.Metrics; + Strate.DistanceFromCorpusCentroid = State.DistanceFromCorpusCentroid; + Strate.bGrounded = !bActualOutlier; + Strate.bOutlier = bActualOutlier; + Strate.bBossSlot = IsBossSlot(DepthIndex, Settings.SelectedStrateCount, + Settings.SelectionPolicy.BossSlotInterval); + Strate.bPassedNonVacuous = Evaluation.bPassedNonVacuous; + Strate.bPassedLargestComponent = Evaluation.bPassedLargestComponent; + Strate.bPassedPrimordialLaw = Evaluation.bPassedPrimordialLaw; + Strate.PrimordialLawResult = Evaluation.PrimordialLawResult; + const FString ClassName = bActualOutlier ? TEXT("outlier") : TEXT("grounded"); + const FString DistanceDirection = bActualOutlier + ? TEXT("farthest surviving corpus-centroid distance") + : TEXT("nearest surviving corpus-centroid distance"); + Strate.SelectionReason = FString::Printf( + TEXT("%s (%s grounded / %s outlier placeholder): %s; " + "adjacency penalty and variety tie-breakers"), + *ClassName, + *PercentageText(Settings.SelectionPolicy.GroundedFraction / FractionSum), + *PercentageText(Settings.SelectionPolicy.OutlierFraction / FractionSum), + *DistanceDirection); + if (bUsedClassFallback) + { + Strate.SelectionReason += TEXT("; requested ratio class was exhausted"); + } + if (bNewArchetype || bNewRecipe) + { + Strate.SelectionReason += TEXT("; unseen "); + if (bNewArchetype) Strate.SelectionReason += TEXT("archetype"); + if (bNewArchetype && bNewRecipe) Strate.SelectionReason += TEXT("/"); + if (bNewRecipe) Strate.SelectionReason += TEXT("recipe"); + Strate.SelectionReason += TEXT(" bonus applied"); + } + if (Strate.bBossSlot) + { + Strate.SelectionReason += TEXT("; boss slot at the provisional five-slot cadence"); + } + + State.Metrics = Evaluation.Metrics; + State.bSelected = true; + State.RejectionReason.Reset(); + Manifest.Strates.Add(MoveTemp(Strate)); + UsedArchetypes.Add(static_cast(State.Candidate.Archetype)); + UsedRecipes.Add(RecipeHash); + PreviousMetrics = Manifest.Strates.Last().Metrics; + bHavePreviousMetrics = true; + ++GeneratedOrdinal; + ++SelectedGeneratedCount; + if (bActualOutlier) + { + ++AssignedOutlierCount; + } + bPlaced = true; + break; + } + if (!bPlaced) + { + return Fail(FString::Printf(TEXT("all remaining candidates failed final placement at slot %d"), + DepthIndex)); + } + } + + CurrentTopChunk = BottomChunk - 1 - static_cast(Settings.InterStrateGapChunks); + } + + if (Manifest.Strates.Num() != Settings.SelectedStrateCount + || SelectedGeneratedCount != GeneratedSlotCount + || FixedSelectedCount != FixedStrates.Num()) + { + return Fail(TEXT("season selector did not fill every absolute slot exactly once")); + } + + TMap RejectionCounts; + if (Settings.bIncludeCandidateAudit) + { + Manifest.CandidateAudit.Reserve(Candidates.Num()); + } + for (const FSeasonCandidateState& State : Candidates) + { + const FString Reason = State.bSelected + ? FString() + : (State.RejectionReason.IsEmpty() + ? TEXT("not selected by provisional policy") : State.RejectionReason); + if (!State.bSelected) + { + AddRejection(RejectionCounts, Reason); + } + if (Settings.bIncludeCandidateAudit) + { + FVoxelSeasonCandidateAudit& Row = Manifest.CandidateAudit.AddDefaulted_GetRef(); + Row.CandidateIndex = State.Candidate.Index; + Row.Seed = State.Candidate.Seed; + Row.Archetype = State.Candidate.Archetype; + Row.RecipeHash = State.Candidate.bValid + ? VF_HashStrateStructureRecipe(State.Candidate.Recipe) : 0; + Row.DistanceFromCorpusCentroid = State.DistanceFromCorpusCentroid; + Row.bPassedHardGates = State.bPassedHardGates; + Row.bSelected = State.bSelected; + Row.RejectionReason = Reason; + } + } + ExportRejectionCounts(RejectionCounts, Manifest.RejectionCounts); + Manifest.SelectedCount = Manifest.Strates.Num(); + Manifest.GroundedSelectedCount = 0; + Manifest.OutlierSelectedCount = 0; + Manifest.FixedSelectedCount = 0; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + if (Strate.bGrounded) ++Manifest.GroundedSelectedCount; + if (Strate.bOutlier) ++Manifest.OutlierSelectedCount; + if (Strate.bFixed) ++Manifest.FixedSelectedCount; + if (!Strate.bPassedNonVacuous || !Strate.bPassedLargestComponent + || !Strate.bPassedPrimordialLaw + || Strate.PrimordialLawResult != EVoxelConnectivityResult::Connected) + { + return Fail(FString::Printf(TEXT("selected slot %d failed the final primordial-law assertion"), + Strate.DepthIndex)); + } + } + Manifest.RejectedCount = FMath::Max(0, Manifest.CandidateCount - SelectedGeneratedCount); + + if (Settings.bWriteArtifacts) + { + const FString BaseDirectory = Settings.OutputDirectory.IsEmpty() + ? FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("VoxelForge"), TEXT("Seasons")) + : Settings.OutputDirectory; + const FString SeasonDirectory = FPaths::Combine( + BaseDirectory, + FString::Printf(TEXT("season_%04d_seed_%d"), Manifest.Season, Manifest.Seed)); + Manifest.ManifestPath = FPaths::Combine(SeasonDirectory, TEXT("season_manifest.json")); + if (Settings.bWriteReviewPage) + { + const FString ReviewDirectory = FPaths::Combine(SeasonDirectory, TEXT("review")); + if (!WriteReviewPage(Manifest, Settings, ReviewDirectory, + Manifest.ReviewPagePath, Manifest.Error)) + { + return Fail(Manifest.Error.IsEmpty() + ? TEXT("could not write selected-season review page") : Manifest.Error); + } + } + } + + Manifest.bValid = true; + Manifest.Error.Reset(); + Manifest.ContentHash = VF_ComputeVoxelSeasonManifestContentHash(Manifest); + if (Manifest.ContentHash.IsEmpty()) + { + return Fail(TEXT("could not hash composed season manifest")); + } + Manifest.SerializedJson = VF_SerializeVoxelSeasonManifest(Manifest); + if (Manifest.SerializedJson.IsEmpty()) + { + return Fail(TEXT("could not serialize composed season manifest")); + } + if (Settings.bWriteArtifacts) + { + FString SaveReport; + if (!VF_SaveVoxelSeasonManifest(Manifest.ManifestPath, Manifest, SaveReport)) + { + return Fail(SaveReport); + } + } + return Manifest; +} + +#endif + +#if WITH_EDITOR +FVoxelSeasonManifest VF_ComposeSeason(int32 SeasonSeed, const UVoxelSettings* WorldSettings) +{ + FVoxelSeasonCompositionSettings Settings; + Settings.WorldSettings = WorldSettings; + if (WorldSettings != nullptr) + { + Settings.SelectedStrateCount = WorldSettings->TotalStrates; + Settings.InterStrateGapChunks = WorldSettings->InterStrateGapChunks; + Settings.OriginSpineRadius = WorldSettings->OriginSpineRadius; + Settings.WorldRadiusVoxels = WorldSettings->WorldRadiusVoxels; + Settings.SeasonNumber = WorldSettings->CurrentSeason; + } + return VF_ComposeSeason(SeasonSeed, Settings); +} +#endif + +bool VF_BuildSeasonStrateStack(const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + using namespace VoxelSeasonRuntimePrivate; + auto Fail = [&](const FString& Reason) -> bool + { + if (OutError != nullptr) + { + *OutError = Reason; + } + return false; + }; + if (OutError != nullptr) + { + OutError->Reset(); + } + if (!VoxelSeasonManifestPrivate::IsSupportedArchetype(Strate.Archetype) + || Strate.HeightInChunks <= 0 + || !FMath::IsFinite(SpineRadius) || SpineRadius < 0.0f + || Strate.TopWorldZ <= Strate.BottomWorldZ) + { + return Fail(TEXT("Manifest strate has invalid identity, height, bounds, or spine radius.")); + } + + const float StoredTop = static_cast(Strate.TopWorldZ); + const float StoredBottom = static_cast(Strate.BottomWorldZ); + if (Strate.bUsesRegions) + { + if (!Strate.Regions.IsValid() + || Strate.Regions.RegionCount <= 1 + || Strate.Regions.Seed != Strate.Seed + || Strate.Regions.StrateIndex != Strate.DepthIndex + || Strate.Regions.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Strate.Seed, Strate.DepthIndex) + || !Strate.Regions.bHasGlobalStructuralParams + || !SameFloatBits(Strate.Regions.StrateTopWorldZ, StoredTop) + || !SameFloatBits(Strate.Regions.StrateBottomWorldZ, StoredBottom)) + { + return Fail(TEXT("Manifest lateral region description is invalid or not keyed to its slot.")); + } + } + if (!SameFloatBits(ActiveTop(Strate.Params, Strate.Archetype), StoredTop) + || !SameFloatBits(ActiveBottom(Strate.Params, Strate.Archetype), StoredBottom)) + { + return Fail(TEXT("Manifest bounds do not match the active parameter vector.")); + } + + bool bBuilt = false; + if (Strate.bUsesRegions) + { +#if WITH_EDITOR + bBuilt = VF_BuildStrateRegionStack(Strate.Regions, SpineRadius, StrateManager, + OutStack, OutContext, OutError); +#else + return Fail(TEXT("Lateral region manifests are not shippable.")); +#endif + } + else if (Strate.bUsesRecipe) + { + bBuilt = VF_BuildStackFromRecipe(Strate.Recipe, Strate.Params, Strate.Seed, + SpineRadius, StrateManager, OutStack, OutContext, + OutError); + } + else + { +#if WITH_EDITOR + const float Seal = VoxelSeasonCompositionPrivate::ActiveSeal( + Strate.Params, Strate.Archetype); + bBuilt = VF_BuildNativeStrateStackForCandidate( + Strate.Archetype, Strate.Params, Strate.Seed, SpineRadius, + 0.0f, Seal, StrateManager, OutStack, OutContext); + if (!bBuilt && OutError != nullptr) + { + *OutError = TEXT("Native manifest strate could not be materialised in the editor build."); + } +#else + bBuilt = false; + if (OutError != nullptr) + { + *OutError = TEXT("Native fixed-strate rebuild is editor/build-box only."); + } +#endif + } + if (!bBuilt) + { + return false; + } + if (OutContext.WorldRadiusVoxels != 0.0f) + { + return Fail(TEXT("Manifest rebuild changed the required WorldRadiusVoxels=0 invariant.")); + } + return true; +} + +bool VF_RebuildVoxelSeasonStrate(const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError) +{ + return VF_BuildSeasonStrateStack(Strate, SpineRadius, StrateManager, + OutStack, OutContext, OutError); +} + +#if WITH_EDITOR +namespace VoxelSeasonCompositionPrivate +{ + bool WriteReviewPage(const FVoxelSeasonManifest& Manifest, + const FVoxelSeasonCompositionSettings& Settings, + const FString& ReviewDirectory, + FString& OutReviewPath, + FString& OutError) + { + OutReviewPath.Reset(); + OutError.Reset(); + if (!IFileManager::Get().MakeDirectory(*ReviewDirectory, true)) + { + OutError = FString::Printf(TEXT("could not create season review directory %s"), + *ReviewDirectory); + return false; + } + + TArray Cards; + Cards.Reserve(Manifest.Strates.Num()); + FVoxelStratePreviewWindow CommonWindow; + bool bHaveWindow = false; + for (const FVoxelSeasonStrate& Strate : Manifest.Strates) + { + FVoxelStrateSampleGrid Grid; + FVoxelStrateMetrics RawMetrics; + FSeasonEvaluation Evaluation; + if (!EvaluateStrate(Strate, Settings, Evaluation, &Grid, &RawMetrics)) + { + OutError = FString::Printf(TEXT("selected slot %d failed its review re-measure: %s"), + Strate.DepthIndex, *Evaluation.FailureReason); + return false; + } + if (!Grid.IsValid()) + { + OutError = FString::Printf(TEXT("selected slot %d produced no review grid"), + Strate.DepthIndex); + return false; + } + + const FString RecipeString = Strate.bUsesRecipe + ? VF_FormatStrateStructureRecipe(Strate.Recipe) + : FString::Printf(TEXT("native/%s"), VF_GetStrateArchetypeName(Strate.Archetype)); + const FString ArrivalVerdict = FString::Printf( + TEXT("%s%s"), + VoxelSeasonManifestPrivate::ConnectivityName(Evaluation.PrimordialLawResult), + (Evaluation.bPassedPrimordialLaw ? TEXT("") : TEXT(" (failed)"))); + FVoxelStratePreviewCandidate& Card = Cards.AddDefaulted_GetRef(); + if (!VF_WriteStratePreviewCandidate( + ReviewDirectory, Strate.DepthIndex, RecipeString, Grid, RawMetrics, + Settings.MeasureSettings.HeadroomCells, + Strate.DistanceFromCorpusCentroid, ArrivalVerdict, false, TEXT(""), + Card, OutError)) + { + return false; + } + Card.bSeasonOrder = true; + Card.DepthIndex = Strate.DepthIndex; + Card.SelectionReason = Strate.SelectionReason; + Card.bBossSlot = Strate.bBossSlot; + if (!bHaveWindow) + { + CommonWindow = Card.Window; + bHaveWindow = true; + } + } + + if (!bHaveWindow || !CommonWindow.IsValid()) + { + OutError = TEXT("selected season has no valid preview measurement window"); + return false; + } + const FString RunTitle = FString::Printf( + TEXT("VoxelForge Season %d — seed %d — descent review"), Manifest.Season, Manifest.Seed); + if (!VF_WriteStratePreviewIndex(ReviewDirectory, RunTitle, CommonWindow, + Cards, OutReviewPath, OutError)) + { + return false; + } + return true; + } +} +#endif // WITH_EDITOR — review rendering never ships diff --git a/Standalone/Source/VoxelForge/Private/VoxelSettings.cpp b/Standalone/Source/VoxelForge/Private/VoxelSettings.cpp new file mode 100644 index 0000000..4319d38 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelSettings.cpp @@ -0,0 +1,51 @@ +#include "VoxelSettings.h" + +#include "VoxelSeasonAsset.h" + +int32 UVoxelSettings::GetEffectiveWorldSeed() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetSeasonSeed(); + } + } + return Seed; +} + +float UVoxelSettings::GetEffectiveOriginSpineRadius() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetOriginSpineRadius(); + } + } + return OriginSpineRadius; +} + +float UVoxelSettings::GetEffectiveWorldRadiusVoxels() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetWorldRadiusVoxels(); + } + } + return WorldRadiusVoxels; +} + +int32 UVoxelSettings::GetEffectiveSeasonNumber() const +{ + if (!Season.IsNull()) + { + if (const UVoxelSeasonAsset* Asset = Season.LoadSynchronous()) + { + return Asset->GetSeasonNumber(); + } + } + return CurrentSeason; +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelStats.cpp b/Standalone/Source/VoxelForge/Private/VoxelStats.cpp new file mode 100644 index 0000000..94105ce --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelStats.cpp @@ -0,0 +1,23 @@ +// VoxelStats.cpp +// Definitions for the VoxelForge runtime statistics. +// Définitions des statistiques runtime de VoxelForge. + +#include "VoxelStats.h" + +DEFINE_STAT(STAT_VoxelForgeTilesClassified); +DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllSolid); +DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllAir); +DEFINE_STAT(STAT_VoxelForgeTilesMeshed); +DEFINE_STAT(STAT_VoxelForgeTilesOpStackSolid); +DEFINE_STAT(STAT_VoxelForgeTilesOpStackAir); +DEFINE_STAT(STAT_VoxelForgeCaveMixOutOfLayout); +DEFINE_STAT(STAT_VoxelForgeCaveMixGap); +DEFINE_STAT(STAT_VoxelForgeCaveMixSurfaceWorld); +DEFINE_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots); +DEFINE_STAT(STAT_VoxelForgeCaveBailParams); +DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict); +DEFINE_STAT(STAT_VoxelForgeCaveBailFoldConflict); +DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance); +DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack); +DEFINE_STAT(STAT_VoxelForgeColumnMemoHit); +DEFINE_STAT(STAT_VoxelForgeColumnMemoMiss); diff --git a/Standalone/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Standalone/Source/VoxelForge/Private/VoxelStrateComposer.cpp new file mode 100644 index 0000000..4c3582b --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -0,0 +1,5787 @@ +// Offline strate corpus and parameter roll. + +#include "VoxelStrateComposer.h" + +#include "AssetRegistry/AssetData.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "Dom/JsonObject.h" +#include "Dom/JsonValue.h" +#include "HAL/FileManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Math/RandomStream.h" +#include "Modules/ModuleManager.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "UObject/EnumProperty.h" +#include "UObject/FieldIterator.h" +#include "UObject/UnrealType.h" +#include "UObject/UObjectGlobals.h" + +#include "VoxelSettings.h" +#include "VoxelStrateDefinition.h" +#include "VoxelTerrainOpDefinition.h" +#include "VoxelDensityOpStack.h" +#include "VoxelCaveMorphology.h" +#include "VoxelTypes.h" + +#include +#include + +#if WITH_EDITOR +namespace +{ + constexpr float GJitterFraction = 0.15f; + constexpr float GBootstrapJitterFraction = 0.25f; + constexpr double GNearZeroSpread = 1.0e-6; + constexpr double GBootstrapMagnitudeFloor = 1.0; + + static const ECaveGeneratorType GAllArchetypes[] = + { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater, + }; + + bool VF_IsTunnelArchetype(ECaveGeneratorType Archetype) + { + return Archetype == ECaveGeneratorType::TunnelNetwork + || Archetype == ECaveGeneratorType::Underwater; + } + + bool VF_IsSupportedArchetype(ECaveGeneratorType Archetype) + { + 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: + return true; + default: + return false; + } + } + + UStruct* VF_GetParamStruct(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return FStrateGenerationParams::StaticStruct(); + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return FSlabGenerationParams::StaticStruct(); + case ECaveGeneratorType::Maze: + return FMazeGenerationParams::StaticStruct(); + case ECaveGeneratorType::SurfaceWorld: + return FSurfaceGenerationParams::StaticStruct(); + case ECaveGeneratorType::VerticalShafts: + return FVerticalShaftParams::StaticStruct(); + case ECaveGeneratorType::FloatingIslands: + return FFloatingIslandParams::StaticStruct(); + default: + return nullptr; + } + } + + void* VF_GetParamMemory(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + const void* VF_GetParamMemory(const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return &Params.TunnelNetworkParams; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return &Params.SlabParams; + case ECaveGeneratorType::Maze: + return &Params.MazeParams; + case ECaveGeneratorType::SurfaceWorld: + return &Params.SurfaceParams; + case ECaveGeneratorType::VerticalShafts: + return &Params.VerticalShaftParams; + case ECaveGeneratorType::FloatingIslands: + return &Params.FloatingIslandParams; + default: + return nullptr; + } + } + + FString VF_SpreadKey(ECaveGeneratorType Archetype, const FString& FieldName) + { + return FString::Printf(TEXT("%d:%s"), static_cast(static_cast(Archetype)), + *FieldName); + } + + FString VF_SpreadKey(ECaveGeneratorType Archetype, const TCHAR* FieldName) + { + return VF_SpreadKey(Archetype, FString(FieldName)); + } + + template + EVoxelStrateFieldKind VF_FieldKindFor() + { + using ValueType = typename std::remove_cv< + typename std::remove_reference::type>::type; + + if (std::is_same::value) + { + return EVoxelStrateFieldKind::Boolean; + } + if (std::is_enum::value) + { + return EVoxelStrateFieldKind::Enum; + } + if (std::is_integral::value) + { + return EVoxelStrateFieldKind::Integer; + } + return EVoxelStrateFieldKind::Continuous; + } + + const FVoxelStrateFieldExclusion* VF_FindExclusion(const FString& FieldName) + { + for (const FVoxelStrateFieldExclusion& Exclusion : FVoxelStrateCorpus::GetNonTunableFields()) + { + if (Exclusion.FieldName == FieldName) + { + return &Exclusion; + } + } + return nullptr; + } + + bool VF_IsRuntimeField(const FString& FieldName) + { + return FieldName == TEXT("StrateTopWorldZ") + || FieldName == TEXT("StrateBottomWorldZ"); + } + + bool VF_IsExcludedForArchetype(ECaveGeneratorType Archetype, const FString& FieldName) + { + // These names are transport slots only for FStrateGenerationParams. Sibling families + // have real authored fields with some of the same names (e.g. Surface::TerraceHardness). + if (VF_IsTunnelArchetype(Archetype)) + { + return VF_FindExclusion(FieldName) != nullptr; + } + return VF_IsRuntimeField(FieldName); + } + + void VF_AddExclusion(TArray& Out, + const TCHAR* FieldName, const TCHAR* Reason) + { + FVoxelStrateFieldExclusion& Entry = Out.AddDefaulted_GetRef(); + Entry.FieldName = FieldName; + Entry.Reason = Reason; + } + + void VF_CollectProperties(UStruct* Struct, TMap& OutProperties) + { + OutProperties.Reset(); + if (Struct == nullptr) + { + return; + } + + for (TFieldIterator It(Struct, EFieldIteratorFlags::IncludeSuper); It; ++It) + { + FProperty* Property = *It; + if (Property != nullptr) + { + OutProperties.Add(Property->GetName(), Property); + } + } + } + + EVoxelStrateFieldKind VF_PropertyKind(const FProperty* Property) + { + if (Property == nullptr) + { + return EVoxelStrateFieldKind::Continuous; + } + if (CastField(Property) != nullptr) + { + return EVoxelStrateFieldKind::Boolean; + } + if (CastField(Property) != nullptr) + { + return EVoxelStrateFieldKind::Enum; + } + if (const FNumericProperty* NumericProperty = CastField(Property)) + { + if (NumericProperty->IsEnum()) + { + return EVoxelStrateFieldKind::Enum; + } + if (NumericProperty->IsInteger()) + { + return EVoxelStrateFieldKind::Integer; + } + } + return EVoxelStrateFieldKind::Continuous; + } + + bool VF_IsScalarProperty(const FProperty* Property) + { + return CastField(Property) != nullptr + || CastField(Property) != nullptr + || CastField(Property) != nullptr; + } + + bool VF_ReadPropertyValue(const FProperty* Property, const void* Memory, double& OutValue) + { + if (Property == nullptr || Memory == nullptr || !VF_IsScalarProperty(Property)) + { + return false; + } + + const void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* BoolProperty = CastField(Property)) + { + OutValue = BoolProperty->GetPropertyValue(ValuePtr) ? 1.0 : 0.0; + return true; + } + + const FNumericProperty* NumericProperty = CastField(Property); + const FNumericProperty* UnderlyingProperty = NumericProperty; + if (const FEnumProperty* EnumProperty = CastField(Property)) + { + UnderlyingProperty = EnumProperty->GetUnderlyingProperty(); + } + if (UnderlyingProperty == nullptr) + { + return false; + } + + if (UnderlyingProperty->IsFloatingPoint()) + { + OutValue = UnderlyingProperty->GetFloatingPointPropertyValue(ValuePtr); + } + else + { + // Current integer/enum fields are non-negative; the signed accessor gives the + // expected value for the byte-backed enum properties used by the strate structs. + OutValue = static_cast(UnderlyingProperty->GetSignedIntPropertyValue(ValuePtr)); + } + return FMath::IsFinite(OutValue); + } + + bool VF_WritePropertyValue(const FProperty* Property, void* Memory, double Value) + { + if (Property == nullptr || Memory == nullptr || !VF_IsScalarProperty(Property) + || !FMath::IsFinite(Value)) + { + return false; + } + + void* ValuePtr = Property->ContainerPtrToValuePtr(Memory); + if (const FBoolProperty* BoolProperty = CastField(Property)) + { + BoolProperty->SetPropertyValue(ValuePtr, Value >= 0.5); + return true; + } + + const FNumericProperty* NumericProperty = CastField(Property); + const FNumericProperty* UnderlyingProperty = NumericProperty; + if (const FEnumProperty* EnumProperty = CastField(Property)) + { + UnderlyingProperty = EnumProperty->GetUnderlyingProperty(); + } + if (UnderlyingProperty == nullptr) + { + return false; + } + + if (UnderlyingProperty->IsFloatingPoint()) + { + UnderlyingProperty->SetFloatingPointPropertyValue(ValuePtr, Value); + } + else + { + UnderlyingProperty->SetIntPropertyValue(ValuePtr, static_cast(FMath::RoundToInt(Value))); + } + return true; + } + + template + double VF_NativeValueAsDouble(T Value) + { + if constexpr (std::is_enum::value) + { + using UnderlyingType = typename std::underlying_type::type; + return static_cast(static_cast(Value)); + } + else + { + return static_cast(Value); + } + } + + template ::value, int>::type = 0> + void VF_AssignNativeValue(T& Target, double Value) + { + Target = static_cast(Value); + } + + template ::value, int>::type = 0> + void VF_AssignNativeValue(T& Target, double Value) + { + using UnderlyingType = typename std::underlying_type::type; + Target = static_cast(static_cast(FMath::RoundToInt(Value))); + } + + bool VF_ReadFStrateField(const FStrateGenerationParams& Params, + const FString& FieldName, double& OutValue) + { +#define VF_READ_FSTRATE_FIELD(Name) \ + if (FieldName == TEXT(#Name)) { OutValue = VF_NativeValueAsDouble(Params.Name); return true; } + VF_STRATE_PARAM_FIELDS(VF_READ_FSTRATE_FIELD, VF_READ_FSTRATE_FIELD) +#undef VF_READ_FSTRATE_FIELD + return false; + } + + bool VF_WriteFStrateField(FStrateGenerationParams& Params, + const FString& FieldName, double Value) + { +#define VF_WRITE_FSTRATE_FIELD(Name) \ + if (FieldName == TEXT(#Name)) { VF_AssignNativeValue(Params.Name, Value); return true; } + VF_STRATE_PARAM_FIELDS(VF_WRITE_FSTRATE_FIELD, VF_WRITE_FSTRATE_FIELD) +#undef VF_WRITE_FSTRATE_FIELD + return false; + } + + bool VF_ReadRuntimeField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double& OutValue) + { + if (Memory == nullptr || !VF_IsRuntimeField(FieldName)) + { + return false; + } + + if (FieldName == TEXT("StrateTopWorldZ")) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::Maze: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::SurfaceWorld: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::VerticalShafts: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + case ECaveGeneratorType::FloatingIslands: + OutValue = static_cast(Memory)->StrateTopWorldZ; + return true; + default: + return false; + } + } + + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::Maze: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::SurfaceWorld: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::VerticalShafts: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + case ECaveGeneratorType::FloatingIslands: + OutValue = static_cast(Memory)->StrateBottomWorldZ; + return true; + default: + return false; + } + } + + bool VF_WriteRuntimeField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double Value) + { + if (Memory == nullptr || !VF_IsRuntimeField(FieldName)) + { + return false; + } + + if (FieldName == TEXT("StrateTopWorldZ")) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::Maze: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::SurfaceWorld: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::VerticalShafts: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FloatingIslands: + static_cast(Memory)->StrateTopWorldZ = static_cast(Value); + return true; + default: + return false; + } + } + + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::Maze: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::SurfaceWorld: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::VerticalShafts: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + case ECaveGeneratorType::FloatingIslands: + static_cast(Memory)->StrateBottomWorldZ = static_cast(Value); + return true; + default: + return false; + } + } + + bool VF_ReadNamedField(const void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double& OutValue) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr || Memory == nullptr) + { + return false; + } + + if (VF_IsTunnelArchetype(Archetype) + && VF_ReadFStrateField(*static_cast(Memory), + FieldName, OutValue)) + { + return true; + } + + if (const FProperty* Property = FindFProperty(Struct, FName(*FieldName))) + { + return VF_ReadPropertyValue(Property, Memory, OutValue); + } + return VF_ReadRuntimeField(Memory, Archetype, FieldName, OutValue); + } + + bool VF_WriteNamedField(void* Memory, ECaveGeneratorType Archetype, + const FString& FieldName, double Value) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr || Memory == nullptr) + { + return false; + } + + if (VF_IsTunnelArchetype(Archetype) + && VF_WriteFStrateField(*static_cast(Memory), + FieldName, Value)) + { + return true; + } + + if (FProperty* Property = FindFProperty(Struct, FName(*FieldName))) + { + return VF_WritePropertyValue(Property, Memory, Value); + } + return VF_WriteRuntimeField(Memory, Archetype, FieldName, Value); + } + + const TCHAR* VF_GetTerrainOperationFieldPrefix(EVoxelTerrainOpType Type) + { + switch (Type) + { + case EVoxelTerrainOpType::Terrace: return TEXT("Terrace"); + case EVoxelTerrainOpType::LayerLines: return TEXT("LayerLine"); + case EVoxelTerrainOpType::Ribbing: return TEXT("Ribbing"); + case EVoxelTerrainOpType::Cliff: return TEXT("Cliff"); + case EVoxelTerrainOpType::Scallop: return TEXT("Scallop"); + case EVoxelTerrainOpType::Overhang: return TEXT("Overhang"); + case EVoxelTerrainOpType::Arch: return TEXT("Arch"); + case EVoxelTerrainOpType::Column: return TEXT("Column"); + case EVoxelTerrainOpType::Pit: return TEXT("Pit"); + case EVoxelTerrainOpType::Chimney: return TEXT("Chimney"); + case EVoxelTerrainOpType::Dome: return TEXT("Dome"); + case EVoxelTerrainOpType::Pinch: return TEXT("Pinch"); + default: return TEXT(""); + } + } + + void VF_AddTerrainOperationDefaultSamples( + TArray>& Values, + const TArray& Spreads) + { + if (Values.Num() != Spreads.Num()) + { + return; + } + + UVoxelTerrainOpDefinition* Operation = NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + if (Operation == nullptr) + { + return; + } + + static const EVoxelTerrainOpType OperationTypes[] = + { + EVoxelTerrainOpType::Terrace, + EVoxelTerrainOpType::LayerLines, + EVoxelTerrainOpType::Ribbing, + EVoxelTerrainOpType::Cliff, + EVoxelTerrainOpType::Scallop, + EVoxelTerrainOpType::Overhang, + EVoxelTerrainOpType::Arch, + EVoxelTerrainOpType::Column, + EVoxelTerrainOpType::Pit, + EVoxelTerrainOpType::Chimney, + EVoxelTerrainOpType::Dome, + EVoxelTerrainOpType::Pinch, + }; + + for (const EVoxelTerrainOpType Type : OperationTypes) + { + const TCHAR* Prefix = VF_GetTerrainOperationFieldPrefix(Type); + Operation->Type = Type; + FStrateGenerationParams Defaults; + Operation->ApplyTo(Defaults); + + // Seed only the fields owned by this operation. The values come from the actual + // UVoxelTerrainOpDefinition UPROPERTY initializers through ApplyTo; no second asset + // corpus and no duplicated default constants are introduced here. + for (int32 FieldIndex = 0; FieldIndex < Spreads.Num(); ++FieldIndex) + { + const FVoxelStrateFieldSpread& Spread = Spreads[FieldIndex]; + if (!VF_IsTunnelArchetype(Spread.Archetype) + || Spread.bExcluded + || !Spread.FieldName.StartsWith(Prefix)) + { + continue; + } + + double Value = 0.0; + if (VF_ReadNamedField(&Defaults, Spread.Archetype, Spread.FieldName, Value) + && FMath::IsFinite(Value)) + { + Values[FieldIndex].Add(Value); + } + } + } + } + + void VF_AddPropertyMetadata(FVoxelStrateFieldSpread& Spread, const FProperty* Property) + { +#if WITH_EDITOR + if (Property == nullptr) + { + return; + } + + const FString ClampMinText = Property->GetMetaData(TEXT("ClampMin")); + const FString ClampMaxText = Property->GetMetaData(TEXT("ClampMax")); + + if (!ClampMinText.IsEmpty()) + { + const double Value = FCString::Atod(*ClampMinText); + if (FMath::IsFinite(Value)) + { + Spread.bHasClampMin = true; + Spread.ClampMin = Value; + } + } + if (!ClampMaxText.IsEmpty()) + { + const double Value = FCString::Atod(*ClampMaxText); + if (FMath::IsFinite(Value)) + { + Spread.bHasClampMax = true; + Spread.ClampMax = Value; + } + } +#else + // UPROPERTY metadata is not a cooked-runtime contract. The composer is an offline tool; + // runtime/cook-time clamp data must be baked by a future commandlet. + (void)Spread; + (void)Property; +#endif + } + + void VF_AddSpreadDescriptor(TArray& OutSpreads, + TMap& OutIndices, + ECaveGeneratorType Archetype, + const FString& ParamStructName, + const TMap& Properties, + const FString& Name, EVoxelStrateFieldKind Kind) + { + FVoxelStrateFieldSpread& Spread = OutSpreads.AddDefaulted_GetRef(); + Spread.Archetype = Archetype; + Spread.ParamStructName = ParamStructName; + Spread.FieldName = Name; + Spread.Kind = Kind; + Spread.bExcluded = VF_IsExcludedForArchetype(Archetype, Name); + + if (const FProperty* const* Property = Properties.Find(Name)) + { + Spread.bReflected = (*Property != nullptr); + VF_AddPropertyMetadata(Spread, *Property); + } + + OutIndices.Add(VF_SpreadKey(Archetype, Name), OutSpreads.Num() - 1); + } + + void VF_ResetDeadTerrainTransportFields(FStrateGenerationParams& Params) + { + // Empirical liveness proved these direct FStrate slots are dead: the runtime bakes these + // three operation families from UVoxelTerrainOpDefinition values into room caches before + // sampling. Keep them at their neutral values until terrain-op structs are rolled too. + Params.ColumnDensity = 0.0f; + Params.ColumnMinRadius = 0.0f; + Params.ColumnMaxRadius = 0.0f; + Params.PitDensity = 0.0f; + Params.PitMinRadius = 0.0f; + Params.PitMaxRadius = 0.0f; + Params.PitDepth = 0.0f; + Params.ChimneyDensity = 0.0f; + Params.ChimneyMinRadius = 0.0f; + Params.ChimneyMaxRadius = 0.0f; + Params.ChimneyHeight = 0.0f; + Params.StrateTopWorldZ = 0.0f; + Params.StrateBottomWorldZ = 0.0f; + } + + void VF_ResetRuntimeFields(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + void* Memory = VF_GetParamMemory(Params, Archetype); + VF_WriteRuntimeField(Memory, Archetype, TEXT("StrateTopWorldZ"), 0.0); + VF_WriteRuntimeField(Memory, Archetype, TEXT("StrateBottomWorldZ"), 0.0); + } + + void VF_ResetExcludedFields(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + if (VF_IsTunnelArchetype(Archetype)) + { + VF_ResetDeadTerrainTransportFields(Params.TunnelNetworkParams); + } + else + { + VF_ResetRuntimeFields(Params, Archetype); + } + } + + void VF_AppendSchemaError(FString& SchemaError, const FString& Message) + { + SchemaError += Message; + SchemaError += TEXT(" "); + } + + void VF_ValidateFieldDescriptor(const TArray& Spreads, + ECaveGeneratorType Archetype, + const FString& FieldName, + bool& bSchemaValid, FString& SchemaError) + { + const FVoxelStrateFieldSpread* Spread = nullptr; + for (const FVoxelStrateFieldSpread& Candidate : Spreads) + { + if (Candidate.Archetype == Archetype && Candidate.FieldName == FieldName) + { + Spread = &Candidate; + break; + } + } + double NativeValue = 0.0; + FStrateGenerationParams NativeDefaults; + const bool bNativeFStrateField = VF_IsTunnelArchetype(Archetype) + && VF_ReadFStrateField(NativeDefaults, FieldName, NativeValue); + if (Spread != nullptr && !Spread->bReflected && !Spread->bExcluded + && !bNativeFStrateField) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Field '%s.%s' is neither reflected nor explicitly excluded."), + *Spread->ParamStructName, *FieldName)); + } + } + + void VF_ValidateStructProperties(const TMap& Properties, + const TSet& ExpectedNames, + const FString& ParamStructName, + ECaveGeneratorType Archetype, + bool& bSchemaValid, FString& SchemaError) + { + for (const TPair& Pair : Properties) + { + // A reflected compatibility property may be intentionally absent from the + // tunable-field X-macro. Keep it visible in reflection/serialization, but let the + // explicit exclusion list explain why it is not blended or rolled. + const bool bExplicitlyExcluded = + VF_IsTunnelArchetype(Archetype) && VF_FindExclusion(Pair.Key) != nullptr; + if (!ExpectedNames.Contains(Pair.Key) && !bExplicitlyExcluded) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Reflected %s field '%s' is absent from the composer field list."), + *ParamStructName, *Pair.Key)); + } + if (!VF_IsScalarProperty(Pair.Value)) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Reflected %s field '%s' is not scalar and cannot be rolled."), + *ParamStructName, *Pair.Key)); + } + } + } + + void VF_BlendParamStruct(FVoxelStrateArchetypeParams& Destination, + const FVoxelStrateArchetypeParams& Source, + ECaveGeneratorType Archetype, float Alpha) + { + void* DestinationMemory = VF_GetParamMemory(Destination, Archetype); + const void* SourceMemory = VF_GetParamMemory(Source, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (DestinationMemory == nullptr || SourceMemory == nullptr || Struct == nullptr) + { + return; + } + + if (VF_IsTunnelArchetype(Archetype)) + { + Destination.TunnelNetworkParams = FStrateGenerationParams::Lerp( + Destination.TunnelNetworkParams, Source.TunnelNetworkParams, Alpha); + return; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + if (!VF_IsScalarProperty(Pair.Value)) + { + continue; + } + + double A = 0.0; + double B = 0.0; + if (!VF_ReadPropertyValue(Pair.Value, DestinationMemory, A) + || !VF_ReadPropertyValue(Pair.Value, SourceMemory, B)) + { + continue; + } + + const EVoxelStrateFieldKind Kind = VF_PropertyKind(Pair.Value); + const double Value = Kind == EVoxelStrateFieldKind::Continuous + ? static_cast(FMath::Lerp(static_cast(A), static_cast(B), Alpha)) + : (Alpha < 0.5f ? A : B); + VF_WritePropertyValue(Pair.Value, DestinationMemory, Value); + } + } + + void VF_CopyDominantBooleans(FVoxelStrateArchetypeParams& Destination, + const FVoxelStrateArchetypeParams& Dominant, + ECaveGeneratorType Archetype) + { + void* DestinationMemory = VF_GetParamMemory(Destination, Archetype); + const void* DominantMemory = VF_GetParamMemory(Dominant, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (DestinationMemory == nullptr || DominantMemory == nullptr || Struct == nullptr) + { + return; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + if (VF_PropertyKind(Pair.Value) != EVoxelStrateFieldKind::Boolean) + { + continue; + } + double Value = 0.0; + if (VF_ReadPropertyValue(Pair.Value, DominantMemory, Value)) + { + VF_WritePropertyValue(Pair.Value, DestinationMemory, Value); + } + } + } + + void VF_JitterMeasuredField(double& Value, + const FVoxelStrateFieldSpread& Spread, + ECaveGeneratorType Archetype, + FRandomStream& Rng, + int32& OutBootstrapFieldCount, + TArray& OutBootstrapFieldNames) + { + const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min); + const bool bUseBootstrap = CorpusRange <= GNearZeroSpread; + + // Spread-derived jitter is the real rule. This bootstrap is only for a starved corpus: + // when season zero has supplied no useful measured range, use a fraction of this field's + // own magnitude, with a one-native-unit floor near zero. Promotion (Tier 5) should retire + // this fallback as the corpus grows. + const double JitterScale = bUseBootstrap + ? FMath::Max(FMath::Abs(Value), GBootstrapMagnitudeFloor) + : CorpusRange; + const float JitterFraction = bUseBootstrap + ? GBootstrapJitterFraction : GJitterFraction; + Value += static_cast((Rng.FRand() * 2.0f - 1.0f) * JitterFraction) + * JitterScale; + + if (bUseBootstrap) + { + ++OutBootstrapFieldCount; + OutBootstrapFieldNames.AddUnique(VF_SpreadKey(Archetype, Spread.FieldName)); + } + } + + void VF_JitterNativeParams(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + const FVoxelStrateCorpus& Corpus, + FRandomStream& Rng, + int32& OutBootstrapFieldCount, + TArray& OutBootstrapFieldNames) + { + void* Memory = VF_GetParamMemory(Params, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return; + } + + // FStrateGenerationParams deliberately keeps the terrain-detail transport fields as + // native, non-UPROPERTY members. Handle those fields through the same measured spread + // policy as reflected fields; otherwise removing an exclusion would only change the + // report while the candidate value stayed at its blended parent value. + if (VF_IsTunnelArchetype(Archetype)) + { + for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) + { + if (Spread.Archetype != Archetype + || Spread.bReflected + || Spread.bExcluded + || Spread.Kind != EVoxelStrateFieldKind::Continuous) + { + continue; + } + + double Value = 0.0; + if (VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value)) + { + VF_JitterMeasuredField(Value, Spread, Archetype, Rng, + OutBootstrapFieldCount, OutBootstrapFieldNames); + VF_WriteNamedField(Memory, Archetype, Spread.FieldName, Value); + } + } + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + if (VF_PropertyKind(Pair.Value) != EVoxelStrateFieldKind::Continuous) + { + continue; + } + + const FVoxelStrateFieldSpread* Spread = Corpus.FindSpread(Archetype, Pair.Key); + if (Spread == nullptr || Spread->bExcluded) + { + continue; + } + + double Value = 0.0; + if (VF_ReadPropertyValue(Pair.Value, Memory, Value)) + { + VF_JitterMeasuredField(Value, *Spread, Archetype, Rng, + OutBootstrapFieldCount, OutBootstrapFieldNames); + VF_WritePropertyValue(Pair.Value, Memory, Value); + } + } + } + + void VF_ClampNativeParams(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + const FVoxelStrateCorpus& Corpus) + { + void* Memory = VF_GetParamMemory(Params, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + const EVoxelStrateFieldKind Kind = VF_PropertyKind(Pair.Value); + if (Kind == EVoxelStrateFieldKind::Boolean) + { + continue; + } + + const FVoxelStrateFieldSpread* Spread = Corpus.FindSpread(Archetype, Pair.Key); + if (Spread == nullptr || Spread->bExcluded) + { + continue; + } + + double Value = 0.0; + if (!VF_ReadPropertyValue(Pair.Value, Memory, Value)) + { + continue; + } + if (Spread->bHasClampMin) + { + Value = FMath::Max(Value, Spread->ClampMin); + } + if (Spread->bHasClampMax) + { + Value = FMath::Min(Value, Spread->ClampMax); + } + VF_WritePropertyValue(Pair.Value, Memory, Value); + } + } + + void VF_RepairOrderedPair(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + const TCHAR* MinField, const TCHAR* MaxField) + { + void* Memory = VF_GetParamMemory(Params, Archetype); + double MinValue = 0.0; + double MaxValue = 0.0; + if (VF_ReadNamedField(Memory, Archetype, MinField, MinValue) + && VF_ReadNamedField(Memory, Archetype, MaxField, MaxValue) + && MinValue > MaxValue) + { + VF_WriteNamedField(Memory, Archetype, MinField, MaxValue); + VF_WriteNamedField(Memory, Archetype, MaxField, MinValue); + } + } + + void VF_RepairNativeOrderedPairs(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + VF_RepairOrderedPair(Params, Archetype, TEXT("MinRoomRadius"), TEXT("MaxRoomRadius")); + VF_RepairOrderedPair(Params, Archetype, TEXT("RoomFloorCutMin"), TEXT("RoomFloorCutMax")); + VF_RepairOrderedPair(Params, Archetype, TEXT("TunnelMinRadius"), TEXT("TunnelMaxRadius")); + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + VF_RepairOrderedPair(Params, Archetype, TEXT("FloorRelativeHeight"), TEXT("CeilingRelativeHeight")); + VF_RepairOrderedPair(Params, Archetype, TEXT("ColumnMinRadius"), TEXT("ColumnMaxRadius")); + break; + case ECaveGeneratorType::SurfaceWorld: + VF_RepairOrderedPair(Params, Archetype, TEXT("BaseGroundRelative"), TEXT("CeilingRelative")); + break; + case ECaveGeneratorType::VerticalShafts: + VF_RepairOrderedPair(Params, Archetype, TEXT("ShaftMinRadius"), TEXT("ShaftMaxRadius")); + break; + case ECaveGeneratorType::FloatingIslands: + VF_RepairOrderedPair(Params, Archetype, TEXT("IslandMinRadius"), TEXT("IslandMaxRadius")); + break; + case ECaveGeneratorType::Maze: + default: + break; + } + } + + uint32 VF_Avalanche(uint32 Value) + { + Value ^= Value >> 16; + Value *= 0x7feb352dU; + Value ^= Value >> 15; + Value *= 0x846ca68bU; + Value ^= Value >> 16; + return Value; + } + + struct FCorpusFreeRange + { + float Min = 0.0f; + float Max = 0.0f; + }; + + bool VF_SetCorpusFreeRange(FCorpusFreeRange& Out, float Min, float Max) + { + if (!FMath::IsFinite(Min) || !FMath::IsFinite(Max) || Min > Max) + { + return false; + } + Out.Min = Min; + Out.Max = Max; + return true; + } + + /** + * Finite envelopes for the corpus-free control. These are deliberately not copied from a + * corpus entry. UPROPERTY clamps are used where the type declares them; fields with only a + * lower clamp get the broadest finite envelope implied by the generator's voxel geometry. + * ConstraintSampled uses the same envelopes for its independent draws and clamps only after + * its dependent equations have been evaluated. + */ + bool VF_GetCorpusFreeRange(ECaveGeneratorType Archetype, const FString& FieldName, + float StrateHeightInVoxels, FCorpusFreeRange& Out) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + const float HQuarter = H * 0.25f; + const float HHalf = H * 0.5f; + + if (VF_IsRuntimeField(FieldName) || VF_IsExcludedForArchetype(Archetype, FieldName)) + { + return false; + } + + auto Set = [&Out](float Min, float Max) -> bool + { + return VF_SetCorpusFreeRange(Out, Min, Max); + }; + + if (VF_IsTunnelArchetype(Archetype)) + { + if (FieldName == TEXT("BaseDensity")) return Set(1.0f, 32.0f); + if (FieldName == TEXT("VerticalScale")) return Set(0.25f, 2.5f); + if (FieldName == TEXT("WormFrequency")) return Set(0.002f, 0.10f); + if (FieldName == TEXT("WormHorizontalBias")) return Set(1.0f, 10.0f); + if (FieldName == TEXT("WormThreshold")) return Set(0.01f, 0.50f); + if (FieldName == TEXT("WormStrength")) return Set(1.0f, 64.0f); + if (FieldName == TEXT("WormNetworkRange")) return Set(0.0f, H * 1.5f); + if (FieldName == TEXT("RoomSpacing")) return Set(64.0f, FMath::Max(96.0f, H * 0.75f)); + if (FieldName == TEXT("RoomDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("MinRoomRadius")) return Set(16.0f, FMath::Max(32.0f, HHalf)); + if (FieldName == TEXT("MaxRoomRadius")) return Set(16.0f, FMath::Max(48.0f, H * 0.75f)); + if (FieldName == TEXT("RoomHeightRatio")) return Set(0.10f, 1.0f); + if (FieldName == TEXT("RoomShapeVariety")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("RoomFloorCutMin")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("RoomFloorCutMax")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("FloorReliefStrength")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("FloorReliefFrequency")) return Set(0.001f, 0.10f); + if (FieldName == TEXT("RoomMouthRiseStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("RoomMouthRiseBlendVoxels")) return Set(8.0f, 128.0f); + if (FieldName == TEXT("OriginRoomRadius")) return Set(24.0f, FMath::Max(64.0f, H * 0.75f)); + if (FieldName == TEXT("TunnelMinRadius")) return Set(6.0f, FMath::Max(8.0f, HQuarter)); + if (FieldName == TEXT("TunnelMaxRadius")) return Set(8.0f, FMath::Max(16.0f, H * 0.25f)); + if (FieldName == TEXT("TunnelDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("MaxTunnelLength")) return Set(64.0f, H * 4.0f); + if (FieldName == TEXT("TunnelWarpStrength")) return Set(0.0f, H); + if (FieldName == TEXT("TunnelHorizontalBias")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("TunnelEndpointZOffset")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("TunnelFloorTerraceStepHeight")) return Set(0.25f, FMath::Max(2.0f, HQuarter)); + if (FieldName == TEXT("TunnelFloorMaxLedgeHeight")) return Set(1.0f, FMath::Max(4.0f, HQuarter)); + if (FieldName == TEXT("TunnelFloorGentleSlopeThreshold")) return Set(0.0f, 90.0f); + if (FieldName == TEXT("SDFBlendRadius")) return Set(2.0f, FMath::Max(4.0f, H * 0.15f)); + if (FieldName == TEXT("WaterLevelRelative")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("CaveWarpStrength")) return Set(0.0f, HHalf); + if (FieldName == TEXT("CaveWarpFrequency")) return Set(0.001f, 0.10f); + if (FieldName == TEXT("SurfaceRoughness")) return Set(0.5f, FMath::Max(4.0f, H * 0.04f)); + if (FieldName == TEXT("RoughnessFrequency")) return Set(0.001f, 0.20f); + if (FieldName == TEXT("BoundarySealThickness")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("DomainWarpStrength")) return Set(0.0f, HHalf); + if (FieldName == TEXT("DomainWarpFrequency")) return Set(0.001f, 0.20f); + if (FieldName == TEXT("FloorBias")) return Set(0.0f, FMath::Max(4.0f, H * 0.10f)); + if (FieldName == TEXT("TerraceStepHeight")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("TerraceHardness")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("TerraceNoiseDisplacement")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("LayerLineSpacing")) return Set(0.0f, H); + if (FieldName == TEXT("LayerLineDepth")) return Set(0.0f, H * 0.10f); + if (FieldName == TEXT("OverhangStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("OverhangDepth")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("OverhangFrequency")) return Set(0.001f, 0.20f); + if (FieldName == TEXT("RibbingSpacing")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("RibbingDepth")) return Set(0.0f, H * 0.10f); + if (FieldName == TEXT("CliffStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ScallopStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ScallopFrequency")) return Set(0.001f, 0.20f); + if (FieldName == TEXT("ArchDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ArchMinRadius")) return Set(6.0f, FMath::Max(12.0f, H * 0.20f)); + if (FieldName == TEXT("ArchMaxRadius")) return Set(12.0f, FMath::Max(24.0f, H * 0.30f)); + if (FieldName == TEXT("ColumnDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ColumnMinRadius")) return Set(4.0f, FMath::Max(8.0f, H * 0.20f)); + if (FieldName == TEXT("ColumnMaxRadius")) return Set(8.0f, FMath::Max(16.0f, H * 0.40f)); + if (FieldName == TEXT("PitDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("PitMinRadius")) return Set(8.0f, FMath::Max(16.0f, H * 0.20f)); + if (FieldName == TEXT("PitMaxRadius")) return Set(16.0f, FMath::Max(32.0f, H * 0.40f)); + if (FieldName == TEXT("PitDepth")) return Set(0.0f, HHalf); + if (FieldName == TEXT("ChimneyDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ChimneyMinRadius")) return Set(4.0f, FMath::Max(8.0f, H * 0.20f)); + if (FieldName == TEXT("ChimneyMaxRadius")) return Set(8.0f, FMath::Max(16.0f, H * 0.40f)); + if (FieldName == TEXT("ChimneyHeight")) return Set(0.0f, HHalf); + if (FieldName == TEXT("DomeDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("DomeMinRadius")) return Set(16.0f, FMath::Max(32.0f, H * 0.30f)); + if (FieldName == TEXT("DomeMaxRadius")) return Set(32.0f, FMath::Max(48.0f, H * 0.50f)); + if (FieldName == TEXT("DomeHeightRatio")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("PinchDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("PinchStrength")) return Set(0.0f, H); + if (FieldName == TEXT("PinchLength")) return Set(0.0f, H * 2.0f); + return false; + } + + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + if (FieldName == TEXT("FloorRelativeHeight")) return Set(0.0f, 0.95f); + if (FieldName == TEXT("CeilingRelativeHeight")) return Set(0.05f, 1.0f); + if (FieldName == TEXT("FloorRoughness")) return Set(0.5f, FMath::Max(4.0f, H * 0.04f)); + if (FieldName == TEXT("FloorRoughnessFrequency")) return Set(0.001f, 0.15f); + if (FieldName == TEXT("CeilingRoughness")) return Set(1.0f, FMath::Max(16.0f, H * 0.12f)); + if (FieldName == TEXT("CeilingRoughnessFrequency")) return Set(0.001f, 0.15f); + if (FieldName == TEXT("ColumnDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ColumnMinRadius")) return Set(8.0f, FMath::Max(12.0f, H * 0.20f)); + if (FieldName == TEXT("ColumnMaxRadius")) return Set(16.0f, FMath::Max(24.0f, H * 0.40f)); + if (FieldName == TEXT("ColumnSpacing")) return Set(64.0f, FMath::Max(96.0f, H * 0.75f)); + if (FieldName == TEXT("BoundarySealThickness")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("BaseDensity")) return Set(1.0f, 32.0f); + return false; + + case ECaveGeneratorType::Maze: + if (FieldName == TEXT("CellSize")) return Set(64.0f, FMath::Max(64.0f, FMath::Min(96.0f, H * 0.75f))); + if (FieldName == TEXT("CorridorRadius")) return Set(10.0f, 12.5f); + if (FieldName == TEXT("BranchProbability")) return Set(0.05f, 1.0f); + if (FieldName == TEXT("Verticality")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("SurfaceRoughness")) return Set(0.5f, FMath::Max(3.0f, H * 0.02f)); + if (FieldName == TEXT("BoundarySealThickness")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("BaseDensity")) return Set(1.0f, 32.0f); + return false; + + case ECaveGeneratorType::SurfaceWorld: + if (FieldName == TEXT("BaseGroundRelative")) return Set(0.05f, 0.90f); + if (FieldName == TEXT("ElevationRange")) return Set(24.0f, FMath::Max(80.0f, H * 0.50f)); + if (FieldName == TEXT("ContinentFrequency")) return Set(0.0005f, 0.10f); + if (FieldName == TEXT("MountainStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("MountainFrequency")) return Set(0.0005f, 0.10f); + if (FieldName == TEXT("DetailFrequency")) return Set(0.0005f, 0.20f); + if (FieldName == TEXT("SurfaceRoughness")) return Set(1.0f, FMath::Max(3.0f, H * 0.02f)); + if (FieldName == TEXT("HeightWarpStrength")) return Set(0.0f, FMath::Max(48.0f, H * 0.25f)); + if (FieldName == TEXT("HeightWarpFrequency")) return Set(0.0005f, 0.10f); + if (FieldName == TEXT("ReliefFrequency")) return Set(0.0002f, 0.02f); + if (FieldName == TEXT("ReliefStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ReliefContrast")) return Set(0.25f, 4.0f); + if (FieldName == TEXT("TerraceStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("TerraceHeight")) return Set(2.0f, FMath::Max(8.0f, H * 0.08f)); + if (FieldName == TEXT("TerraceHardness")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("LayerLineDepth")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("LayerLineSpacing")) return Set(2.0f, FMath::Max(8.0f, H * 0.25f)); + if (FieldName == TEXT("CliffStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("CliffSlopeThreshold")) return Set(0.05f, 2.0f); + if (FieldName == TEXT("CliffSharpness")) return Set(0.0f, 4.0f); + if (FieldName == TEXT("CliffSampleDist")) return Set(0.5f, HQuarter); + if (FieldName == TEXT("OverhangStrength")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("OverhangReach")) return Set(4.0f, FMath::Max(16.0f, H * 0.15f)); + if (FieldName == TEXT("OverhangHeight")) return Set(4.0f, FMath::Max(24.0f, H * 0.25f)); + if (FieldName == TEXT("OverhangFrequency")) return Set(0.0005f, 0.20f); + if (FieldName == TEXT("OverhangZScale")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("OverhangSlopeThreshold")) return Set(0.05f, 2.0f); + if (FieldName == TEXT("WaterLevelRelative")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("BeachWidth")) return Set(4.0f, FMath::Max(12.0f, H * 0.10f)); + if (FieldName == TEXT("CeilingRelative")) return Set(0.30f, 1.0f); + if (FieldName == TEXT("CeilingRoughness")) return Set(0.0f, HHalf); + if (FieldName == TEXT("CeilingRoughnessFrequency")) return Set(0.0005f, 0.20f); + if (FieldName == TEXT("CeilingUndulation")) return Set(0.0f, HHalf); + if (FieldName == TEXT("CeilingUndulationFrequency")) return Set(0.0005f, 0.10f); + if (FieldName == TEXT("CeilingRidgeStrength")) return Set(0.0f, HHalf); + if (FieldName == TEXT("CeilingRidgeFrequency")) return Set(0.0005f, 0.20f); + if (FieldName == TEXT("CeilingWarpStrength")) return Set(0.0f, H); + if (FieldName == TEXT("CeilingWarpFrequency")) return Set(0.0005f, 0.10f); + if (FieldName == TEXT("BoundarySealThickness")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("BaseDensity")) return Set(1.0f, 32.0f); + return false; + + case ECaveGeneratorType::VerticalShafts: + if (FieldName == TEXT("ShaftSpacing")) return Set(64.0f, FMath::Max(80.0f, FMath::Min(112.0f, H * 0.75f))); + if (FieldName == TEXT("ShaftDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ShaftMinRadius")) return Set(8.0f, 16.0f); + if (FieldName == TEXT("ShaftMaxRadius")) return Set(12.0f, 24.0f); + if (FieldName == TEXT("CrossConnectChance")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("ConnectorRadius")) return Set(6.0f, 12.0f); + if (FieldName == TEXT("LedgeSpacing")) return Set(8.0f, FMath::Max(32.0f, H * 0.25f)); + if (FieldName == TEXT("LedgeDepth")) return Set(2.0f, FMath::Max(4.0f, H * 0.08f)); + if (FieldName == TEXT("SurfaceRoughness")) return Set(0.5f, FMath::Max(3.0f, H * 0.02f)); + if (FieldName == TEXT("BoundarySealThickness")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("BaseDensity")) return Set(1.0f, 32.0f); + return false; + + case ECaveGeneratorType::FloatingIslands: + if (FieldName == TEXT("IslandSpacing")) return Set(96.0f, FMath::Max(112.0f, FMath::Min(160.0f, H * 0.75f))); + if (FieldName == TEXT("IslandDensity")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("IslandMinRadius")) return Set(24.0f, FMath::Max(32.0f, H * 0.30f)); + if (FieldName == TEXT("IslandMaxRadius")) return Set(48.0f, FMath::Max(64.0f, H * 0.50f)); + if (FieldName == TEXT("ThicknessRatio")) return Set(0.10f, 1.5f); + if (FieldName == TEXT("VerticalJitter")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("TopFlatten")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("SurfaceRoughness")) return Set(0.5f, FMath::Max(3.0f, H * 0.02f)); + if (FieldName == TEXT("SDFBlendRadius")) return Set(2.0f, FMath::Max(6.0f, H * 0.10f)); + if (FieldName == TEXT("BoundarySealThickness")) return Set(0.0f, HQuarter); + if (FieldName == TEXT("BaseDensity")) return Set(1.0f, 32.0f); + return false; + + default: + return false; + } + } + + uint32 VF_CorpusFreeRollSeed(ECaveGeneratorType Archetype, int32 Seed, int32 Index) + { + uint32 Value = 0xC0A5FEE1u ^ (static_cast(static_cast(Archetype)) * 0x9E3779B9u); + Value = VF_Avalanche(Value ^ static_cast(Seed)); + return VF_Avalanche(Value ^ (static_cast(Index) + 0x85EBCA6Bu)); + } + + float VF_RollCorpusFreeFloat(FRandomStream& Rng, const FCorpusFreeRange& Range) + { + return FMath::Lerp(Range.Min, Range.Max, Rng.FRand()); + } + + bool VF_IsConstraintDerivedTunnelField(const TCHAR* FieldName) + { + return FCString::Strcmp(FieldName, TEXT("BoundarySealThickness")) == 0 + || FCString::Strcmp(FieldName, TEXT("WormStrength")) == 0 + || FCString::Strcmp(FieldName, TEXT("WormNetworkRange")) == 0 + || FCString::Strcmp(FieldName, TEXT("MinRoomRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("MaxRoomRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("RoomFloorCutMax")) == 0 + || FCString::Strcmp(FieldName, TEXT("OriginRoomRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("TunnelMinRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("TunnelMaxRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("MaxTunnelLength")) == 0 + || FCString::Strcmp(FieldName, TEXT("TunnelWarpStrength")) == 0 + || FCString::Strcmp(FieldName, TEXT("SDFBlendRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("CaveWarpStrength")) == 0 + || FCString::Strcmp(FieldName, TEXT("SurfaceRoughness")) == 0 + || FCString::Strcmp(FieldName, TEXT("FloorBias")) == 0 + || FCString::Strcmp(FieldName, TEXT("TerraceStepHeight")) == 0 + || FCString::Strcmp(FieldName, TEXT("LayerLineSpacing")) == 0 + || FCString::Strcmp(FieldName, TEXT("LayerLineDepth")) == 0 + || FCString::Strcmp(FieldName, TEXT("OverhangDepth")) == 0 + || FCString::Strcmp(FieldName, TEXT("ArchMinRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("ArchMaxRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("DomeMinRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("DomeMaxRadius")) == 0 + || FCString::Strcmp(FieldName, TEXT("DomeHeightRatio")) == 0 + || FCString::Strcmp(FieldName, TEXT("PinchLength")) == 0; + } + + bool VF_SampleTunnelContinuousField(FStrateGenerationParams& Params, + const TCHAR* FieldName, + FRandomStream& ScalarRng, + float StrateHeightInVoxels, + bool bConstraintSampled) + { + const FString Name(FieldName); + if (VF_IsExcludedForArchetype(ECaveGeneratorType::TunnelNetwork, Name)) + { + return true; + } + if (bConstraintSampled && VF_IsConstraintDerivedTunnelField(FieldName)) + { + return true; + } + + FCorpusFreeRange Range; + if (!VF_GetCorpusFreeRange(ECaveGeneratorType::TunnelNetwork, Name, + StrateHeightInVoxels, Range)) + { + return false; + } + return VF_WriteFStrateField(Params, Name, VF_RollCorpusFreeFloat(ScalarRng, Range)); + } + + bool VF_SampleTunnelDiscreteField(FStrateGenerationParams& Params, + const TCHAR* FieldName, + FRandomStream& CategoryRng) + { + if (FCString::Strcmp(FieldName, TEXT("OriginRoomMaxConnections")) == 0) + { + Params.OriginRoomMaxConnections = CategoryRng.RandRange(0, 12); + return true; + } + if (FCString::Strcmp(FieldName, TEXT("bTunnelsFlowTowardOrigin")) == 0) + { + Params.bTunnelsFlowTowardOrigin = CategoryRng.FRand() < 0.5f; + return true; + } + if (FCString::Strcmp(FieldName, TEXT("bTunnelFloorTerracingEnabled")) == 0) + { + Params.bTunnelFloorTerracingEnabled = CategoryRng.FRand() < 0.75f; + return true; + } + if (FCString::Strcmp(FieldName, TEXT("TunnelFloorLedgeCountPreference")) == 0) + { + Params.TunnelFloorLedgeCountPreference = CategoryRng.RandRange(0, 3); + return true; + } + if (FCString::Strcmp(FieldName, TEXT("TunnelFloorMaxLedges")) == 0) + { + Params.TunnelFloorMaxLedges = CategoryRng.RandRange(1, 8); + return true; + } + if (FCString::Strcmp(FieldName, TEXT("RoughnessNoiseType")) == 0) + { + Params.RoughnessNoiseType = static_cast(CategoryRng.RandRange(0, 3)); + return true; + } + return false; + } + + bool VF_SampleTunnelFields(FStrateGenerationParams& Params, + FRandomStream& ScalarRng, + FRandomStream& CategoryRng, + float StrateHeightInVoxels, + bool bConstraintSampled) + { + bool bValid = true; +#define VF_SAMPLE_CORPUS_FREE_LERP(Name) \ + { \ + const bool bFieldValid = VF_SampleTunnelContinuousField( \ + Params, TEXT(#Name), ScalarRng, StrateHeightInVoxels, bConstraintSampled); \ + bValid = bFieldValid && bValid; \ + } +#define VF_SAMPLE_CORPUS_FREE_SNAP(Name) \ + { \ + const bool bFieldValid = VF_SampleTunnelDiscreteField(Params, TEXT(#Name), CategoryRng); \ + bValid = bFieldValid && bValid; \ + } + VF_STRATE_PARAM_FIELDS(VF_SAMPLE_CORPUS_FREE_LERP, VF_SAMPLE_CORPUS_FREE_SNAP) +#undef VF_SAMPLE_CORPUS_FREE_LERP +#undef VF_SAMPLE_CORPUS_FREE_SNAP + return bValid; + } + + bool VF_SampleReflectedFields(ECaveGeneratorType Archetype, + void* Memory, + FRandomStream& ScalarRng, + FRandomStream& CategoryRng, + float StrateHeightInVoxels, + FString* OutFailureReason = nullptr) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr || Memory == nullptr) + { + if (OutFailureReason != nullptr) + { + *OutFailureReason = Struct == nullptr + ? FString::Printf(TEXT("no reflected parameter struct for archetype %d"), + static_cast(Archetype)) + : TEXT("reflected parameter memory was null"); + } + return false; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + TArray FieldNames; + FieldNames.Reserve(Properties.Num()); + for (const TPair& Pair : Properties) + { + FieldNames.Add(Pair.Key); + } + FieldNames.Sort([](const FString& A, const FString& B) { return A < B; }); + + bool bValid = true; + for (const FString& FieldName : FieldNames) + { + FProperty* const* PropertyPtr = Properties.Find(FieldName); + FProperty* Property = PropertyPtr != nullptr ? *PropertyPtr : nullptr; + if (Property == nullptr || VF_IsRuntimeField(FieldName) + || VF_IsExcludedForArchetype(Archetype, FieldName)) + { + continue; + } + if (!VF_IsScalarProperty(Property)) + { + bValid = false; + if (OutFailureReason != nullptr && OutFailureReason->IsEmpty()) + { + *OutFailureReason = FString::Printf( + TEXT("reflected field '%s' is not scalar"), *FieldName); + } + continue; + } + + if (VF_PropertyKind(Property) == EVoxelStrateFieldKind::Boolean) + { + bValid = VF_WritePropertyValue(Property, Memory, + CategoryRng.FRand() < 0.5f ? 0.0 : 1.0) && bValid; + continue; + } + if (VF_PropertyKind(Property) == EVoxelStrateFieldKind::Enum) + { + // No current non-tunnel family has an enum. Keep the failure explicit if one is + // added without a legal-value declaration instead of silently writing a default. + bValid = false; + if (OutFailureReason != nullptr && OutFailureReason->IsEmpty()) + { + *OutFailureReason = FString::Printf( + TEXT("reflected enum field '%s' has no legal-value sampler"), *FieldName); + } + continue; + } + + FCorpusFreeRange Range; + if (!VF_GetCorpusFreeRange(Archetype, FieldName, + StrateHeightInVoxels, Range)) + { + bValid = false; + if (OutFailureReason != nullptr && OutFailureReason->IsEmpty()) + { + *OutFailureReason = FString::Printf( + TEXT("reflected field '%s' has no declared roll range"), *FieldName); + } + continue; + } + const bool bWrote = VF_WritePropertyValue(Property, Memory, + VF_RollCorpusFreeFloat(ScalarRng, Range)); + bValid = bWrote && bValid; + if (!bWrote && OutFailureReason != nullptr && OutFailureReason->IsEmpty()) + { + *OutFailureReason = FString::Printf( + TEXT("could not write reflected field '%s'"), *FieldName); + } + } + return bValid; + } + + float VF_CorpusFreeU(FRandomStream& Rng, float Min, float Max) + { + return FMath::Lerp(Min, Max, Rng.FRand()); + } + + void VF_ClampCorpusFreeField(void* Memory, ECaveGeneratorType Archetype, + const TCHAR* FieldName, float StrateHeightInVoxels) + { + if (Memory == nullptr || VF_IsRuntimeField(FieldName) + || VF_IsExcludedForArchetype(Archetype, FString(FieldName))) + { + return; + } + FCorpusFreeRange Range; + if (!VF_GetCorpusFreeRange(Archetype, FString(FieldName), StrateHeightInVoxels, Range)) + { + return; + } + double Value = 0.0; + if (VF_ReadNamedField(Memory, Archetype, FString(FieldName), Value)) + { + Value = FMath::Clamp(Value, static_cast(Range.Min), static_cast(Range.Max)); + VF_WriteNamedField(Memory, Archetype, FString(FieldName), Value); + } + } + + void VF_ClampCorpusFreeFields(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype, + float StrateHeightInVoxels) + { + void* Memory = VF_GetParamMemory(Params, Archetype); + if (VF_IsTunnelArchetype(Archetype)) + { +#define VF_CLAMP_CORPUS_FREE_LERP(Name) \ + VF_ClampCorpusFreeField(Memory, Archetype, TEXT(#Name), StrateHeightInVoxels); +#define VF_CLAMP_CORPUS_FREE_SNAP(Name) \ + do { } while (false); + VF_STRATE_PARAM_FIELDS(VF_CLAMP_CORPUS_FREE_LERP, VF_CLAMP_CORPUS_FREE_SNAP) +#undef VF_CLAMP_CORPUS_FREE_LERP +#undef VF_CLAMP_CORPUS_FREE_SNAP + return; + } + + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return; + } + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + VF_ClampCorpusFreeField(Memory, Archetype, *Pair.Key, StrateHeightInVoxels); + } + } + + bool VF_FailCorpusFreeConstraint(FString& OutViolation, const TCHAR* Relation, + float Left, float Right) + { + OutViolation = FString::Printf(TEXT("%s (%.6g vs %.6g)"), Relation, Left, Right); + return false; + } + + bool VF_ApplyConstraintTunnel(FStrateGenerationParams& P, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + // Human-scale derivation: an 8 m room is the smallest room-like volume (radius 16), + // a 20 m chamber is the large-room target (radius 40), and a 24 m hub is the three- + // chunk boss-space target. Keep the room-cell margin independent of those radii. + const float SpacingMin = FMath::Max(96.0f, H * 0.35f); + const float SpacingMax = FMath::Max(SpacingMin + 32.0f, FMath::Min(160.0f, H * 0.94f)); + const float Coverage = VF_CorpusFreeU(Rng, 0.08f, 0.18f); + + P.BoundarySealThickness = VF_CorpusFreeU( + Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f)); + P.RoomSpacing = VF_CorpusFreeU(Rng, SpacingMin, SpacingMax); + P.RoomDensity = VF_CorpusFreeU(Rng, 0.20f, 0.45f); + const float RadiusRatio = FMath::Sqrt( + Coverage / (PI * FMath::Max(P.RoomDensity, 0.001f))); + P.MaxRoomRadius = FMath::Min( + FMath::Max(40.0f, P.RoomSpacing * RadiusRatio), + FMath::Min(P.RoomSpacing * 0.45f, H * 0.30f)); + P.MinRoomRadius = FMath::Max( + 16.0f, P.MaxRoomRadius * VF_CorpusFreeU(Rng, 0.45f, 0.70f)); + P.MinRoomRadius = FMath::Min(P.MinRoomRadius, P.MaxRoomRadius); + P.RoomHeightRatio = VF_CorpusFreeU(Rng, 0.70f, 0.95f); + const float MaxRoomVerticalRadius = FMath::Max( + 8.0f, H * 0.50f - P.BoundarySealThickness - 1.0f); + P.OriginRoomRadius = FMath::Min( + P.MaxRoomRadius * VF_CorpusFreeU(Rng, 0.90f, 1.15f), + MaxRoomVerticalRadius / FMath::Max(P.RoomHeightRatio, 0.1f)); + P.OriginRoomRadius = FMath::Max(P.OriginRoomRadius, P.MinRoomRadius); + + P.RoomFloorCutMin = VF_CorpusFreeU(Rng, 0.55f, 0.75f); + P.RoomFloorCutMax = P.RoomFloorCutMin + VF_CorpusFreeU( + Rng, 0.0f, FMath::Min(0.25f, 1.0f - P.RoomFloorCutMin)); + P.FloorReliefStrength = VF_CorpusFreeU( + Rng, 0.0f, + FMath::Min(12.0f, P.MaxRoomRadius * P.RoomHeightRatio * 0.25f)); + + const float TunnelMinCap = FMath::Max(6.0f, FMath::Min(8.0f, P.MinRoomRadius * 0.45f)); + P.TunnelMinRadius = FMath::Max( + 6.0f, P.MinRoomRadius * VF_CorpusFreeU(Rng, 0.25f, 0.40f)); + P.TunnelMinRadius = FMath::Min(P.TunnelMinRadius, TunnelMinCap); + P.TunnelMaxRadius = FMath::Max( + P.TunnelMinRadius, + FMath::Min(8.0f, P.MinRoomRadius * VF_CorpusFreeU(Rng, 0.50f, 0.75f))); + + // Adjacent cells can differ in both X and Y. The placement jitter is [0.15, 0.85] + // cell, so the worst local diagonal separation is 1.7 * S * sqrt(2), not the + // axis-aligned 1.7 * S case. + const float HorizontalReach = 1.7f * P.RoomSpacing * FMath::Sqrt(2.0f); + const float VerticalReach = 2.0f * P.MaxRoomRadius * P.RoomHeightRatio; + const float NeighborReach = FMath::Sqrt( + HorizontalReach * HorizontalReach + VerticalReach * VerticalReach); + P.MaxTunnelLength = NeighborReach * VF_CorpusFreeU(Rng, 1.05f, 1.30f); + P.TunnelWarpStrength = P.MaxTunnelLength * VF_CorpusFreeU(Rng, 0.03f, 0.08f); + P.WormStrength = P.BaseDensity * VF_CorpusFreeU(Rng, 1.35f, 1.80f); + P.WormNetworkRange = P.RoomSpacing * VF_CorpusFreeU(Rng, 0.25f, 0.50f); + P.CaveWarpStrength = P.RoomSpacing * VF_CorpusFreeU(Rng, 0.03f, 0.10f); + + P.SDFBlendRadius = VF_CorpusFreeU( + Rng, 2.0f, FMath::Max(2.0f, FMath::Min(4.0f, P.TunnelMinRadius * 0.50f))); + const float RoughnessMax = FMath::Max( + 0.0f, (P.TunnelMinRadius - P.SDFBlendRadius - 0.25f) / VOXEL_NOISE_SCALE); + P.SurfaceRoughness = VF_CorpusFreeU(Rng, 0.5f, RoughnessMax * 0.60f); + P.FloorBias = VF_CorpusFreeU( + Rng, 0.0f, P.MaxRoomRadius * P.RoomHeightRatio * 0.25f); + + // Internal terrain-operation transport is kept within the same room/strate envelope. + P.TerraceStepHeight = VF_CorpusFreeU( + Rng, 0.0f, FMath::Min(16.0f, P.MaxRoomRadius * P.RoomHeightRatio * 0.35f)); + P.LayerLineSpacing = VF_CorpusFreeU( + Rng, 4.0f, FMath::Max(4.0f, FMath::Min(H * 0.25f, P.MaxRoomRadius))); + P.LayerLineDepth = VF_CorpusFreeU(Rng, 0.0f, P.BaseDensity * 0.10f); + P.OverhangDepth = VF_CorpusFreeU( + Rng, 0.0f, FMath::Min(P.MaxRoomRadius * 0.25f, H * 0.20f)); + P.ArchMinRadius = VF_CorpusFreeU( + Rng, 1.0f, FMath::Max(1.0f, P.TunnelMinRadius)); + P.ArchMaxRadius = FMath::Max( + P.ArchMinRadius, + P.MinRoomRadius * VF_CorpusFreeU(Rng, 0.20f, 0.45f)); + // The public envelope has a 32-voxel minimum dome radius. Apply that envelope before + // deriving the height ratio; otherwise a small sampled dome would be clamped larger + // after this equation and could violate the room-height relation. + P.DomeMaxRadius = FMath::Min( + P.MaxRoomRadius, + FMath::Max(32.0f, P.MaxRoomRadius * VF_CorpusFreeU(Rng, 0.30f, 0.60f))); + P.DomeMinRadius = P.DomeMaxRadius * VF_CorpusFreeU(Rng, 0.35f, 0.75f); + P.DomeHeightRatio = FMath::Min( + 1.0f, + (P.MaxRoomRadius * P.RoomHeightRatio) + / FMath::Max(P.DomeMaxRadius, 0.001f) + * VF_CorpusFreeU(Rng, 0.35f, 0.90f)); + P.PinchLength = P.MaxTunnelLength * VF_CorpusFreeU(Rng, 0.10f, 0.40f); + return true; + } + + bool VF_ApplyConstraintSlab(FSlabGenerationParams& P, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + P.BoundarySealThickness = VF_CorpusFreeU( + Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f)); + // A slab is an open fight/chamber volume, not a one-voxel shell. At the new + // eight-chunk default this leaves roughly 20-50 m between the two surfaces after + // reserving the 2.5-4 m feature envelopes below. + P.FloorRoughness = VF_CorpusFreeU(Rng, 1.0f, FMath::Max(4.0f, H * 0.04f)); + P.CeilingRoughness = VF_CorpusFreeU(Rng, 2.0f, FMath::Max(8.0f, H * 0.06f)); + + const float FloorNoise = VOXEL_NOISE_SCALE * P.FloorRoughness; + const float CeilingNoise = VOXEL_NOISE_SCALE * P.CeilingRoughness; + const float FloorClearance = VF_CorpusFreeU(Rng, H * 0.08f, H * 0.12f); + const float CeilingClearance = VF_CorpusFreeU(Rng, H * 0.08f, H * 0.12f); + P.FloorRelativeHeight = (P.BoundarySealThickness + FloorNoise + FloorClearance) / H; + P.CeilingRelativeHeight = 1.0f + - (P.BoundarySealThickness + CeilingNoise + CeilingClearance) / H; + + P.ColumnSpacing = VF_CorpusFreeU( + Rng, FMath::Max(96.0f, H * 0.375f), + FMath::Max(128.0f, FMath::Min(160.0f, H * 0.75f))); + P.ColumnMinRadius = VF_CorpusFreeU(Rng, 8.0f, 12.0f); + P.ColumnMaxRadius = FMath::Max( + P.ColumnMinRadius, + FMath::Min(P.ColumnSpacing * 0.40f, P.ColumnMinRadius + 8.0f)); + return true; + } + + bool VF_ApplyConstraintMaze(FMazeGenerationParams& P, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + // Body-derived lattice: a 3 m usable floor needs a 4.286 m clear diameter. At 12.5 cm per + // voxel that is 17.143 voxels, radius 8.571. The proven 3.75-voxel roughness envelope is + // added to the authored radius, then rounded up to 12.5: the worst-case usable floor is + // remains 0.7 * (2 * (12.5 - 3.75) * 0.125) = 1.53125 m with the current + // voxel-valued authoring. CellSize stays 64-96 voxels (8-12 m), so a rock web remains. + P.CellSize = VF_CorpusFreeU( + Rng, 64.0f, FMath::Max(64.0f, FMath::Min(96.0f, H * 0.75f))); + P.CorridorRadius = VF_CorpusFreeU( + Rng, 10.0f, FMath::Min(12.5f, P.CellSize * 0.20f)); + const float RoughnessCap = FMath::Max( + 0.0f, (P.CorridorRadius - 2.0f) + / (VOXEL_NOISE_SCALE * 1.5f)); + P.SurfaceRoughness = VF_CorpusFreeU(Rng, 0.5f, RoughnessCap * 0.60f); + P.BoundarySealThickness = VF_CorpusFreeU( + Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f)); + return true; + } + + bool VF_ApplyConstraintSurface(FSurfaceGenerationParams& P, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + P.BoundarySealThickness = VF_CorpusFreeU( + Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f)); + // Outdoor relief uses 6-20 m as its authored human-scale band; the absolute + // height is still tied to H so the sky cap has room above the terrain. + P.ElevationRange = VF_CorpusFreeU(Rng, H * 0.10f, H * 0.25f); + P.MountainStrength = VF_CorpusFreeU(Rng, 0.20f, 0.80f); + P.SurfaceRoughness = VF_CorpusFreeU(Rng, 1.0f, FMath::Max(3.0f, H * 0.02f)); + + const float GroundLowerDeviation = P.ElevationRange * 0.50f + + P.SurfaceRoughness * VOXEL_NOISE_SCALE; + const float GroundUpperDeviation = P.ElevationRange * (0.50f + P.MountainStrength) + + P.SurfaceRoughness * VOXEL_NOISE_SCALE; + const float GroundClearance = VF_CorpusFreeU(Rng, H * 0.04f, H * 0.08f); + P.CeilingRoughness = VF_CorpusFreeU(Rng, 0.0f, H * 0.02f); + P.CeilingUndulation = VF_CorpusFreeU(Rng, 0.0f, H * 0.02f); + P.CeilingRidgeStrength = VF_CorpusFreeU(Rng, 0.0f, H * 0.02f); + + P.TerraceStrength = VF_CorpusFreeU(Rng, 0.0f, 0.50f); + P.TerraceHeight = VF_CorpusFreeU( + Rng, 1.0f, FMath::Max(1.0f, FMath::Min(10.0f, H * 0.08f))); + P.LayerLineDepth = VF_CorpusFreeU(Rng, 0.0f, FMath::Min(2.0f, H * 0.02f)); + P.LayerLineSpacing = VF_CorpusFreeU( + Rng, FMath::Min(4.0f, H * 0.01f), FMath::Min(32.0f, H * 0.25f)); + P.OverhangStrength = VF_CorpusFreeU(Rng, 0.0f, 0.50f); + P.OverhangReach = VF_CorpusFreeU( + Rng, FMath::Min(4.0f, H * 0.01f), FMath::Min(20.0f, H * 0.15f)); + P.OverhangHeight = VF_CorpusFreeU( + Rng, FMath::Min(2.0f, H * 0.02f), FMath::Min(10.0f, H * 0.05f)); + P.BeachWidth = VF_CorpusFreeU(Rng, 0.0f, FMath::Min(16.0f, H * 0.10f)); + + const float CapClearance = VF_CorpusFreeU(Rng, H * 0.04f, H * 0.08f); + const float CapLowering = P.CeilingRoughness * VOXEL_NOISE_SCALE + + P.CeilingUndulation * VOXEL_NOISE_SCALE + P.CeilingRidgeStrength; + // Cliff is a height-space operation, not a categorical switch. Its equation pushes the + // structural height away from the four-sample local mean by + // CliffStrength * CliffSharpness. The structural field's total possible spread is + // bounded by ElevationRange * (1 + MountainStrength) + 2 * noiseScale * roughness. + // Reserve room for that bound, then reduce the requested strength if the remaining + // bottom/cap budget cannot contain it. This is a derived relation, not the final envelope + // clamp below; the other height operations receive the same budget treatment. + const float CliffSpread = P.ElevationRange * (1.0f + P.MountainStrength) + + 2.0f * VOXEL_NOISE_SCALE * P.SurfaceRoughness; + const float CliffCoefficient = FMath::Max(P.CliffSharpness, 0.0f) * CliffSpread; + const float OtherGroundFeatureBudget = FMath::Max(P.TerraceHeight, 0.0f) + + FMath::Abs(P.LayerLineDepth) + FMath::Max(P.BeachWidth, 0.0f); + const float RequiredGap = CapClearance + FMath::Max(P.OverhangHeight, 0.0f) + + 2.0f; + const float GroundWithoutFeatures = P.BoundarySealThickness + GroundClearance + + GroundLowerDeviation + GroundUpperDeviation; + constexpr float FitMargin = 1.0f; + const float FeatureCapacityFromBase = H * 0.90f + - (P.BoundarySealThickness + GroundClearance + GroundLowerDeviation) + - OtherGroundFeatureBudget - FitMargin; + const float FeatureCapacityFromCap = 0.5f * ( + H - P.BoundarySealThickness - RequiredGap - GroundWithoutFeatures - FitMargin) + - OtherGroundFeatureBudget; + const float MaxCliffBudget = FMath::Max(0.0f, + FMath::Min(FeatureCapacityFromBase, FeatureCapacityFromCap)); + if (CliffCoefficient > 0.0f) + { + P.CliffStrength = FMath::Min( + FMath::Max(P.CliffStrength, 0.0f), MaxCliffBudget / CliffCoefficient); + } + else + { + P.CliffStrength = 0.0f; + } + + const float CliffBudget = FMath::Max(P.CliffStrength, 0.0f) * CliffCoefficient; + const float GroundFeatureBudget = OtherGroundFeatureBudget + CliffBudget; + P.BaseGroundRelative = (P.BoundarySealThickness + GroundClearance + + GroundLowerDeviation + GroundFeatureBudget) / H; + const float GroundUpper = P.BoundarySealThickness + GroundClearance + + GroundLowerDeviation + GroundUpperDeviation + 2.0f * GroundFeatureBudget; + // Compute the cap BASE from the worst terrain height. The generator subtracts + // CapLowering afterwards (VoxelGenerator.cpp:2627-2652), so adding it here makes the + // final cap, rather than the unlowered base line, the dependent quantity. + P.CeilingRelative = (GroundUpper + RequiredGap + CapLowering) / H; + + return true; + } + + bool VF_ApplyConstraintVertical(FVerticalShaftParams& P, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + P.BoundarySealThickness = VF_CorpusFreeU( + Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f)); + // A shaft is a 4-7 m diameter vertical room; connectors are 3-6 m bores. + // The 20-28 m site spacing leaves a substantial rock web between shafts. + P.ShaftSpacing = VF_CorpusFreeU( + Rng, 80.0f, FMath::Max(96.0f, FMath::Min(112.0f, H * 0.90f))); + P.SurfaceRoughness = VF_CorpusFreeU(Rng, 1.0f, FMath::Max(3.0f, H * 0.02f)); + const float RoughnessReach = P.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f; + P.ShaftMinRadius = FMath::Max( + 8.0f, RoughnessReach + 0.5f + VF_CorpusFreeU(Rng, 1.0f, 3.0f)); + P.ShaftMaxRadius = FMath::Max( + P.ShaftMinRadius + 4.0f, + FMath::Min(P.ShaftSpacing * 0.40f, P.ShaftMinRadius + 8.0f)); + P.ConnectorRadius = FMath::Max( + 6.0f, RoughnessReach + 1.0f + VF_CorpusFreeU(Rng, 0.0f, 2.0f)); + P.LedgeDepth = VF_CorpusFreeU( + Rng, 2.0f, FMath::Max(4.0f, FMath::Min(6.0f, P.ShaftSpacing * 0.08f))); + const float FloorClearance = FMath::Max( + 1.0f, P.SurfaceRoughness * VOXEL_NOISE_SCALE + 1.0f); + P.LedgeSpacing = 2.0f * (P.LedgeDepth + FloorClearance) + + VF_CorpusFreeU( + Rng, FMath::Min(4.0f, H * 0.02f), FMath::Min(16.0f, H * 0.10f)); + return true; + } + + bool VF_ApplyConstraintFloating(FFloatingIslandParams& P, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + P.BoundarySealThickness = VF_CorpusFreeU( + Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f)); + // Island radii are deliberately larger than the roughness envelope: 12-24 m + // diameters read as land masses, while 28-40 m site spacing prevents mush. + P.IslandSpacing = VF_CorpusFreeU( + Rng, 112.0f, FMath::Max(112.0f, FMath::Min(160.0f, H * 0.75f))); + P.ThicknessRatio = VF_CorpusFreeU(Rng, 0.45f, 0.75f); + const float MaxByHeight = (H * 0.50f - P.BoundarySealThickness) + / FMath::Max(P.ThicknessRatio, 0.25f); + const float MaxRadius = FMath::Max( + 48.0f, FMath::Min(P.IslandSpacing * 0.45f, MaxByHeight * 0.85f)); + P.IslandMaxRadius = MaxRadius; + P.IslandMinRadius = FMath::Max( + 24.0f, P.IslandMaxRadius * VF_CorpusFreeU(Rng, 0.50f, 0.80f)); + P.SDFBlendRadius = VF_CorpusFreeU( + Rng, 2.0f, FMath::Min(6.0f, P.IslandMinRadius * 0.20f)); + const float RoughnessMax = FMath::Max( + 0.0f, (P.IslandMinRadius - P.SDFBlendRadius) / VOXEL_NOISE_SCALE); + P.SurfaceRoughness = VF_CorpusFreeU(Rng, 0.5f, RoughnessMax * 0.60f); + return true; + } + + bool VF_ApplyCorpusFreeConstraints(ECaveGeneratorType Archetype, + FVoxelStrateArchetypeParams& Params, + FRandomStream& Rng, + float StrateHeightInVoxels) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + return VF_ApplyConstraintTunnel(Params.TunnelNetworkParams, Rng, + StrateHeightInVoxels); + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + return VF_ApplyConstraintSlab(Params.SlabParams, Rng, + StrateHeightInVoxels); + case ECaveGeneratorType::Maze: + return VF_ApplyConstraintMaze(Params.MazeParams, Rng, + StrateHeightInVoxels); + case ECaveGeneratorType::SurfaceWorld: + return VF_ApplyConstraintSurface(Params.SurfaceParams, Rng, + StrateHeightInVoxels); + case ECaveGeneratorType::VerticalShafts: + return VF_ApplyConstraintVertical(Params.VerticalShaftParams, Rng, + StrateHeightInVoxels); + case ECaveGeneratorType::FloatingIslands: + return VF_ApplyConstraintFloating(Params.FloatingIslandParams, Rng, + StrateHeightInVoxels); + default: + return false; + } + } + + FVoxelStrateRollInfo VF_RollCorpusFreeInternal( + ECaveGeneratorType Archetype, int32 Seed, int32 Index, + EVoxelStrateCorpusFreeSamplingMode Mode, float StrateHeightInVoxels) + { + FVoxelStrateRollInfo Result; + Result.Archetype = Archetype; + if (!VF_IsSupportedArchetype(Archetype)) + { + Result.FailureReason = TEXT("The requested archetype is not supported."); + return Result; + } + if (!FMath::IsFinite(StrateHeightInVoxels) || StrateHeightInVoxels <= 0.0f) + { + Result.FailureReason = TEXT("StrateHeightInVoxels must be finite and positive."); + return Result; + } + + const uint32 BaseSeed = VF_CorpusFreeRollSeed(Archetype, Seed, Index); + FRandomStream ScalarRng(static_cast(VF_Avalanche(BaseSeed ^ 0x51A1A1A1u))); + // Keep categorical choices on a mode-independent stream. The comparison then changes + // only scalar coupling, never the bool/enum draw policy. + FRandomStream CategoryRng(static_cast(VF_Avalanche(BaseSeed ^ 0xC47E6070u))); + FVoxelStrateArchetypeParams Params; + const bool bConstraintSampled = + Mode == EVoxelStrateCorpusFreeSamplingMode::ConstraintSampled; + + bool bValid = false; + FString ReflectionFailure; + if (VF_IsTunnelArchetype(Archetype)) + { + bValid = VF_SampleTunnelFields(Params.TunnelNetworkParams, ScalarRng, + CategoryRng, StrateHeightInVoxels, + bConstraintSampled); + } + else + { + bValid = VF_SampleReflectedFields(Archetype, VF_GetParamMemory(Params, Archetype), + ScalarRng, CategoryRng, StrateHeightInVoxels, + &ReflectionFailure); + } + if (bValid && bConstraintSampled) + { + // Dependent constraints are authored in terms of the final vector, while the + // reflected envelope clamp below can move an independently sampled field (for + // example SurfaceWorld elevation) after that derivation. Treat the fully clamped + // vector as the candidate and deterministically resample until the declared + // relations hold; never return a vector whose coupling was only true pre-clamp. + constexpr int32 MaxConstraintAttempts = 64; + FString ConstraintViolation; + bool bConstraintValid = false; + for (int32 Attempt = 0; Attempt < MaxConstraintAttempts; ++Attempt) + { + bConstraintValid = VF_ApplyCorpusFreeConstraints( + Archetype, Params, ScalarRng, StrateHeightInVoxels); + if (!bConstraintValid) + { + break; + } + VF_ClampCorpusFreeFields(Params, Archetype, StrateHeightInVoxels); + VF_ResetExcludedFields(Params, Archetype); + if (VF_ValidateStrateCorpusFreeConstraints( + Archetype, Params, StrateHeightInVoxels, ConstraintViolation)) + { + break; + } + bConstraintValid = false; + } + if (!bConstraintValid) + { + Result.FailureReason = ConstraintViolation.IsEmpty() + ? TEXT("Constraint sampler could not produce a clamped legal vector.") + : FString::Printf(TEXT("Constraint sampler exhausted legal retries: %s"), + *ConstraintViolation); + return Result; + } + } + if (!bValid) + { + Result.FailureReason = ReflectionFailure.IsEmpty() + ? FString::Printf(TEXT("Corpus-free sampler rejected archetype %d (mode %d) " + "without a field-specific reason."), + static_cast(Archetype), static_cast(Mode)) + : ReflectionFailure; + return Result; + } + + if (!bConstraintSampled) + { + // Last safety net for the independent arm; no corpus/default value is consulted. + VF_ClampCorpusFreeFields(Params, Archetype, StrateHeightInVoxels); + VF_ResetExcludedFields(Params, Archetype); + } + Result.ArchetypeParams = Params; + Result.Params = Params.TunnelNetworkParams; + Result.bValid = true; + return Result; + } + + uint32 VF_RollSeed(uint32 CorpusHash, int32 Seed, int32 Index) + { + uint32 Value = CorpusHash ^ 0x9e3779b9U; + Value = VF_Avalanche(Value ^ static_cast(Seed)); + Value = VF_Avalanche(Value ^ (static_cast(Index) + 0x85ebca6bU)); + return Value; + } + + struct FParentGroup + { + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + TArray EntryIndices; + }; + + int32 VF_WeightedPick(const TArray& Candidates, + const TArray& Entries, + FRandomStream& Rng) + { + if (Candidates.Num() == 0) + { + return INDEX_NONE; + } + + float TotalWeight = 0.0f; + for (const int32 Candidate : Candidates) + { + if (Entries.IsValidIndex(Candidate)) + { + TotalWeight += FMath::Max(0.0f, Entries[Candidate].Weight); + } + } + + if (TotalWeight <= 0.0f) + { + return Candidates[Rng.RandRange(0, Candidates.Num() - 1)]; + } + + float Cursor = Rng.FRand() * TotalWeight; + for (const int32 Candidate : Candidates) + { + if (!Entries.IsValidIndex(Candidate)) + { + continue; + } + Cursor -= FMath::Max(0.0f, Entries[Candidate].Weight); + if (Cursor <= 0.0f) + { + return Candidate; + } + } + return Candidates.Last(); + } + + FVoxelStrateRollInfo VF_RollInternal(const FVoxelStrateCorpus& Corpus, + int32 Seed, int32 Index, + const ECaveGeneratorType* ForcedArchetype = nullptr) + { + FVoxelStrateRollInfo Result; + if (!Corpus.IsValid()) + { + Result.FailureReason = Corpus.IsSchemaValid() + ? TEXT("The corpus is empty.") + : Corpus.GetSchemaError(); + return Result; + } + + const TArray& Entries = Corpus.GetEntries(); + TArray Groups; + for (int32 EntryIndex = 0; EntryIndex < Entries.Num(); ++EntryIndex) + { + int32 GroupIndex = INDEX_NONE; + for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup) + { + if (Groups[CandidateGroup].Archetype == Entries[EntryIndex].Archetype) + { + GroupIndex = CandidateGroup; + break; + } + } + if (GroupIndex == INDEX_NONE) + { + FParentGroup& Group = Groups.AddDefaulted_GetRef(); + Group.Archetype = Entries[EntryIndex].Archetype; + GroupIndex = Groups.Num() - 1; + } + Groups[GroupIndex].EntryIndices.Add(EntryIndex); + } + + if (Groups.Num() == 0) + { + Result.FailureReason = TEXT("The corpus contains no usable archetype group."); + return Result; + } + + FRandomStream Rng(static_cast(VF_RollSeed(Corpus.GetContentsHash(), Seed, Index))); + + float TotalGroupWeight = 0.0f; + for (const FParentGroup& Group : Groups) + { + for (const int32 EntryIndex : Group.EntryIndices) + { + TotalGroupWeight += FMath::Max(0.0f, Entries[EntryIndex].Weight); + } + } + + int32 GroupIndex = 0; + if (ForcedArchetype != nullptr) + { + GroupIndex = INDEX_NONE; + for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup) + { + if (Groups[CandidateGroup].Archetype == *ForcedArchetype) + { + GroupIndex = CandidateGroup; + break; + } + } + if (GroupIndex == INDEX_NONE) + { + Result.FailureReason = TEXT("The requested archetype has no corpus group."); + return Result; + } + } + else if (TotalGroupWeight > 0.0f) + { + float Cursor = Rng.FRand() * TotalGroupWeight; + for (int32 CandidateGroup = 0; CandidateGroup < Groups.Num(); ++CandidateGroup) + { + float GroupWeight = 0.0f; + for (const int32 EntryIndex : Groups[CandidateGroup].EntryIndices) + { + GroupWeight += FMath::Max(0.0f, Entries[EntryIndex].Weight); + } + Cursor -= GroupWeight; + if (Cursor <= 0.0f) + { + GroupIndex = CandidateGroup; + break; + } + } + } + else + { + GroupIndex = Rng.RandRange(0, Groups.Num() - 1); + } + + const FParentGroup& Group = Groups[GroupIndex]; + const int32 ParentCount = Rng.RandRange(2, 3); + TArray Available = Group.EntryIndices; + Result.ParentEntryIndices.Reserve(ParentCount); + Result.ParentWeights.Reserve(ParentCount); + + for (int32 ParentSlot = 0; ParentSlot < ParentCount; ++ParentSlot) + { + // Once a group is exhausted, replacement is intentional. It keeps the requested + // 2–3-parent algorithm defined for a one-entry group and exposes that limitation in + // provenance instead of silently inventing a second vector. + const TArray& Candidates = Available.Num() > 0 ? Available : Group.EntryIndices; + const int32 ParentIndex = VF_WeightedPick(Candidates, Entries, Rng); + if (ParentIndex == INDEX_NONE) + { + Result.FailureReason = TEXT("Parent selection failed for a non-empty group."); + return Result; + } + Result.ParentEntryIndices.Add(ParentIndex); + Result.ParentWeights.Add(0.1f + Rng.FRand()); + Available.Remove(ParentIndex); + } + + float WeightSum = 0.0f; + for (const float Weight : Result.ParentWeights) + { + WeightSum += Weight; + } + if (!(WeightSum > 0.0f)) + { + Result.FailureReason = TEXT("Parent blend weights were not positive."); + return Result; + } + for (float& Weight : Result.ParentWeights) + { + Weight /= WeightSum; + } + + Result.DominantParentPosition = 0; + for (int32 ParentSlot = 1; ParentSlot < Result.ParentWeights.Num(); ++ParentSlot) + { + if (Result.ParentWeights[ParentSlot] > Result.ParentWeights[Result.DominantParentPosition]) + { + Result.DominantParentPosition = ParentSlot; + } + } + + Result.Archetype = Group.Archetype; + Result.ArchetypeParams = Entries[Result.ParentEntryIndices[0]].ArchetypeParams; + float AccumulatedWeight = Result.ParentWeights[0]; + for (int32 ParentSlot = 1; ParentSlot < Result.ParentEntryIndices.Num(); ++ParentSlot) + { + const float ThisWeight = Result.ParentWeights[ParentSlot]; + const float Alpha = ThisWeight / (AccumulatedWeight + ThisWeight); + VF_BlendParamStruct(Result.ArchetypeParams, + Entries[Result.ParentEntryIndices[ParentSlot]].ArchetypeParams, + Result.Archetype, Alpha); + AccumulatedWeight += ThisWeight; + } + + const int32 DominantParent = Result.ParentEntryIndices[Result.DominantParentPosition]; + VF_CopyDominantBooleans(Result.ArchetypeParams, + Entries[DominantParent].ArchetypeParams, + Result.Archetype); + VF_JitterNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus, Rng, + Result.BootstrapJitterFieldCount, + Result.BootstrapJitterFieldNames); + VF_ClampNativeParams(Result.ArchetypeParams, Result.Archetype, Corpus); + VF_RepairNativeOrderedPairs(Result.ArchetypeParams, Result.Archetype); + VF_ResetExcludedFields(Result.ArchetypeParams, Result.Archetype); + + // Preserve the original compact API's compatibility view. Detailed callers use the + // correctly typed active family in ArchetypeParams. + Result.Params = Result.ArchetypeParams.TunnelNetworkParams; + Result.bValid = true; + return Result; + } + + struct FStructureModifierCandidate + { + EVoxelStrateOpClass OpClass; + EVoxelStrateParamBlock ParamBlock; + }; + + static const EVoxelStrateOpClass GStructureShapeSources[] = + { + EVoxelStrateOpClass::RoomGraphSource, + EVoxelStrateOpClass::LatticeCorridorSource, + EVoxelStrateOpClass::ShaftFieldSource, + EVoxelStrateOpClass::IslandBlobSource, + EVoxelStrateOpClass::NoiseRibbonSource, + }; + + // Fixed order is intentional. This is a source-controlled catalogue, not a hash-container + // iteration; changing it is a manifest-versioned design change rather than an accidental RNG + // change. + static const FStructureModifierCandidate GStructureModifiers[] = + { + { EVoxelStrateOpClass::SdfRoughnessMod, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::SdfCarve, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::SdfFill, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::CaveRoughnessMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::WormFieldSource, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::GridColumnMod, EVoxelStrateParamBlock::Slab }, + { EVoxelStrateOpClass::DensityNoiseCarveMod, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::DensityNoiseFillMod, EVoxelStrateParamBlock::None }, + { EVoxelStrateOpClass::CaveTerraceMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::LayerLineMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::RibbingMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::CaveOverhangMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::CaveCliffMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::ScallopMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::CaveArchMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::RoomColumnMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::DomeMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::PinchMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::FloorBiasMod, EVoxelStrateParamBlock::TunnelNetwork }, + { EVoxelStrateOpClass::ShaftLedgeMod, EVoxelStrateParamBlock::VerticalShaft }, + }; + + uint32 VF_StructureRollSeed(int32 Seed, int32 Index) + { + uint32 Value = VF_Avalanche(static_cast(Seed) ^ 0xD1CEB00Bu); + Value = VF_Avalanche(Value ^ (static_cast(Index) + 0x7F4A7C15u)); + return Value; + } + + bool VF_IsContractLegalAfter(EVoxelOpChannelMask AvailableChannels, + EVoxelOpResourceMask AvailableResources, + const FVoxelStrateOpContract& Contract) + { + if ((Contract.Reads & static_cast(~AvailableChannels)) != 0 + || (Contract.RequiredResources + & static_cast(~AvailableResources)) != 0) + { + return false; + } + if (Contract.bAdditive + && (Contract.Writes == VoxelOpChannels::None + || (Contract.Writes & static_cast(~Contract.Reads)) != 0)) + { + return false; + } + for (const EVoxelOpChannelMask Channel : + { VoxelOpChannels::Density, VoxelOpChannels::Sdf }) + { + if ((Contract.Writes & Channel) == 0 || (Contract.Reads & Channel) != 0) + { + continue; + } + // All channels are already published by root + shape. A write-only candidate would + // therefore be a replacement clobber and is excluded before it can enter a recipe. + return false; + } + return true; + } + + FVoxelOpRecipeEntry VF_MakeRecipeEntry(EVoxelStrateOpClass OpClass, + EVoxelStrateParamBlock ParamBlock) + { + FVoxelOpRecipeEntry Entry; + Entry.OpClass = OpClass; + Entry.ParamBlock = ParamBlock; + return Entry; + } + + FVoxelOpStackRecipe VF_RollStructureInternal(int32 Seed, int32 Index) + { + FVoxelOpStackRecipe Recipe; + FRandomStream Rng(static_cast(VF_StructureRollSeed(Seed, Index))); + + Recipe.RootPolarity = Rng.RandRange(0, 1) == 0 + ? EVoxelStrateRootPolarity::RockCarve + : EVoxelStrateRootPolarity::VoidFill; + + const EVoxelStrateOpClass ShapeClass = GStructureShapeSources[ + Rng.RandRange(0, UE_ARRAY_COUNT(GStructureShapeSources) - 1)]; + const EVoxelStrateParamBlock ShapeBlock = + ShapeClass == EVoxelStrateOpClass::RoomGraphSource + ? EVoxelStrateParamBlock::TunnelNetwork + : ShapeClass == EVoxelStrateOpClass::ShaftFieldSource + ? EVoxelStrateParamBlock::VerticalShaft + : ShapeClass == EVoxelStrateOpClass::IslandBlobSource + ? EVoxelStrateParamBlock::FloatingIsland + : EVoxelStrateParamBlock::Maze; + + Recipe.Root = VF_MakeRecipeEntry( + Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::ConstantVoidSource + : EVoxelStrateOpClass::ConstantRockSource, + ShapeBlock); + Recipe.ShapeSource = VF_MakeRecipeEntry(ShapeClass, ShapeBlock); + Recipe.Conversion = VF_MakeRecipeEntry( + Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill + ? EVoxelStrateOpClass::SdfFill + : EVoxelStrateOpClass::SdfCarve, + ShapeBlock); + Recipe.StructuralParamBlock = ShapeBlock; + + FVoxelStrateOpContract ShapeContract; + const bool bShapeDeclared = VoxelDensityOps::GetStrateOpContract(ShapeClass, ShapeContract); + checkf(bShapeDeclared, TEXT("Structure catalogue contains an undeclared shape source.")); + + const EVoxelOpChannelMask AvailableChannels = VoxelOpChannels::Density | VoxelOpChannels::Sdf; + const EVoxelOpResourceMask AvailableResources = ShapeContract.ProvidedResources; + + TArray Legal; + for (const FStructureModifierCandidate& Candidate : GStructureModifiers) + { + FVoxelStrateOpContract Contract; + if (!VoxelDensityOps::GetStrateOpContract(Candidate.OpClass, Contract)) + { + checkf(false, TEXT("Structure catalogue contains an undeclared modifier.")); + continue; + } + if (VF_IsContractLegalAfter(AvailableChannels, AvailableResources, Contract)) + { + Legal.Add(Candidate); + } + } + + const int32 K = Rng.RandRange(4, 8); + checkf(Legal.Num() >= K, + TEXT("Structure roller has fewer legal modifiers than its rolled k.")); + for (int32 ModifierIndex = 0; ModifierIndex < K && Legal.Num() > 0; ++ModifierIndex) + { + const int32 Pick = Rng.RandRange(0, Legal.Num() - 1); + const FStructureModifierCandidate Candidate = Legal[Pick]; + Recipe.Modifiers.Add(VF_MakeRecipeEntry( + Candidate.OpClass, + Candidate.ParamBlock == EVoxelStrateParamBlock::None + ? ShapeBlock : Candidate.ParamBlock)); + Legal.RemoveAt(Pick, 1, EAllowShrinking::No); + } + return Recipe; + } +} + +FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics( + const FVoxelStrateMetrics& Metrics) +{ + FVoxelStrateMeasuredMetrics Result; + Result.bValid = Metrics.bValid; + Result.NumSampled = Metrics.NumSampled; + Result.NumAir = Metrics.NumAir; + Result.NumSolid = Metrics.NumSolid; + Result.AirFraction = Metrics.AirFraction; + Result.NumAirComponents = Metrics.NumAirComponents; + Result.LargestComponentShare = Metrics.LargestComponentShare; + Result.LargestComponentPoint = Metrics.LargestComponentPoint; + Result.LargestComponentCells = Metrics.LargestComponentCells; + Result.NumComponentsAtLeast1Pct = Metrics.NumComponentsAtLeast1Pct; + Result.WalkableFraction = Metrics.WalkableFraction; + Result.WalkableFloorColumns = Metrics.WalkableFloorColumns; + Result.WalkableFloorAreaFraction = Metrics.WalkableFloorAreaFraction; + Result.NumWalkableSurfaceComponents = Metrics.NumWalkableSurfaceComponents; + Result.LargestWalkableSurfaceColumns = Metrics.LargestWalkableSurfaceColumns; + Result.LargestWalkableSurfaceShare = Metrics.LargestWalkableSurfaceShare; + Result.MedianFeatureScale = Metrics.MedianFeatureScale; + Result.MedianVerticalClearance = Metrics.MedianVerticalClearance; + Result.bPlayerFitResolved = Metrics.bPlayerFitResolved; + Result.PlayerFitRefusalReason = Metrics.PlayerFitRefusalReason; + Result.NumPlayerFitCells = Metrics.NumPlayerFitCells; + Result.PlayerFitFraction = Metrics.PlayerFitFraction; + Result.NumTraversableComponents = Metrics.NumTraversableComponents; + Result.LargestTraversableComponentCells = Metrics.LargestTraversableComponentCells; + Result.TraversableComponentShare = Metrics.TraversableComponentShare; + Result.MinimumPlayerClearanceVoxels = Metrics.MinimumPlayerClearanceVoxels; + Result.ResolvedMarginVoxels = Metrics.ResolvedMarginVoxels; + Result.SampledMinZ = Metrics.SampledMinZ; + Result.SampledMaxZ = Metrics.SampledMaxZ; + Result.SampledNumX = Metrics.SampledNumX; + Result.SampledNumY = Metrics.SampledNumY; + Result.SampledNumZ = Metrics.SampledNumZ; + Result.SampledMinX = Metrics.SampledMinX; + Result.SampledMaxX = Metrics.SampledMaxX; + Result.SampledMinY = Metrics.SampledMinY; + Result.SampledMaxY = Metrics.SampledMaxY; + Result.AirComponentCells = Metrics.AirComponentCells; + return Result; +} + +namespace +{ + constexpr int32 GPromotionStoreSchemaVersion = 2; + + bool VF_GetJsonValue(const TSharedPtr& Object, + const TCHAR* FieldName, + EJson ExpectedType, + TSharedPtr& OutValue) + { + OutValue.Reset(); + if (!Object.IsValid()) + { + return false; + } + + const TSharedPtr* Found = Object->Values.Find(FieldName); + if (Found == nullptr || !Found->IsValid() || (*Found)->Type != ExpectedType) + { + return false; + } + OutValue = *Found; + return true; + } + + bool VF_ReadJsonNumber(const TSharedPtr& Object, + const TCHAR* FieldName, double& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Number, Value)) + { + return false; + } + OutValue = Value->AsNumber(); + return FMath::IsFinite(OutValue); + } + + bool VF_ReadJsonNumber(const TSharedPtr& Object, + const FString& FieldName, double& OutValue) + { + return VF_ReadJsonNumber(Object, *FieldName, OutValue); + } + + bool VF_ReadJsonInt64(const TSharedPtr& Object, + const TCHAR* FieldName, int64& OutValue) + { + double Number = 0.0; + if (!VF_ReadJsonNumber(Object, FieldName, Number) + || Number < -9223372036854775807.0 + || Number > 9223372036854775807.0) + { + return false; + } + const int64 Integral = static_cast(Number); + if (static_cast(Integral) != Number) + { + return false; + } + OutValue = Integral; + return true; + } + + bool VF_ReadJsonInt32(const TSharedPtr& Object, + const TCHAR* FieldName, int32& OutValue) + { + int64 Value = 0; + if (!VF_ReadJsonInt64(Object, FieldName, Value) + || Value < static_cast(TNumericLimits::Lowest()) + || Value > static_cast(TNumericLimits::Max())) + { + return false; + } + OutValue = static_cast(Value); + return true; + } + + bool VF_ReadJsonFloat(const TSharedPtr& Object, + const TCHAR* FieldName, float& OutValue) + { + double Value = 0.0; + if (!VF_ReadJsonNumber(Object, FieldName, Value) + || Value < -static_cast(TNumericLimits::Max()) + || Value > static_cast(TNumericLimits::Max())) + { + return false; + } + OutValue = static_cast(Value); + return FMath::IsFinite(OutValue); + } + + bool VF_ReadJsonBool(const TSharedPtr& Object, + const TCHAR* FieldName, bool& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Boolean, Value)) + { + return false; + } + OutValue = Value->AsBool(); + return true; + } + + bool VF_ReadJsonString(const TSharedPtr& Object, + const TCHAR* FieldName, FString& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::String, Value)) + { + return false; + } + OutValue = Value->AsString(); + return true; + } + + bool VF_ReadJsonObject(const TSharedPtr& Object, + const TCHAR* FieldName, + TSharedPtr& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Object, Value)) + { + return false; + } + OutValue = Value->AsObject(); + return OutValue.IsValid(); + } + + bool VF_ReadJsonArray(const TSharedPtr& Object, + const TCHAR* FieldName, + const TArray>*& OutValue) + { + TSharedPtr Value; + if (!VF_GetJsonValue(Object, FieldName, EJson::Array, Value)) + { + return false; + } + OutValue = &Value->AsArray(); + return true; + } + + TSharedPtr VF_SerializeRecipeEntry(const FVoxelOpRecipeEntry& Entry) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("op_class"), static_cast(Entry.OpClass)); + Object->SetNumberField(TEXT("param_block"), static_cast(Entry.ParamBlock)); + return Object; + } + + bool VF_DeserializeRecipeEntry(const TSharedPtr& Object, + FVoxelOpRecipeEntry& OutEntry) + { + int32 OpClass = 0; + int32 ParamBlock = 0; + if (!VF_ReadJsonInt32(Object, TEXT("op_class"), OpClass) + || !VF_ReadJsonInt32(Object, TEXT("param_block"), ParamBlock) + || OpClass < static_cast(EVoxelStrateOpClass::ConstantRockSource) + || OpClass > static_cast(EVoxelStrateOpClass::DensityNoiseFillMod) + || ParamBlock < static_cast(EVoxelStrateParamBlock::None) + || ParamBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland)) + { + return false; + } + OutEntry.OpClass = static_cast(OpClass); + OutEntry.ParamBlock = static_cast(ParamBlock); + return true; + } + + TSharedPtr VF_SerializeRecipe(const FVoxelOpStackRecipe& Recipe) + { + TSharedPtr Object = MakeShared(); + Object->SetNumberField(TEXT("root_polarity"), static_cast(Recipe.RootPolarity)); + Object->SetObjectField(TEXT("root"), VF_SerializeRecipeEntry(Recipe.Root)); + Object->SetObjectField(TEXT("shape_source"), VF_SerializeRecipeEntry(Recipe.ShapeSource)); + Object->SetObjectField(TEXT("conversion"), VF_SerializeRecipeEntry(Recipe.Conversion)); + Object->SetNumberField(TEXT("structural_param_block"), + static_cast(Recipe.StructuralParamBlock)); + + TArray> Modifiers; + Modifiers.Reserve(Recipe.Modifiers.Num()); + for (const FVoxelOpRecipeEntry& Modifier : Recipe.Modifiers) + { + Modifiers.Add(MakeShared(VF_SerializeRecipeEntry(Modifier))); + } + Object->SetArrayField(TEXT("modifiers"), MoveTemp(Modifiers)); + return Object; + } + + bool VF_DeserializeRecipe(const TSharedPtr& Object, + FVoxelOpStackRecipe& OutRecipe) + { + int32 RootPolarity = 0; + int32 StructuralParamBlock = 0; + TSharedPtr Root; + TSharedPtr ShapeSource; + TSharedPtr Conversion; + const TArray>* Modifiers = nullptr; + if (!VF_ReadJsonInt32(Object, TEXT("root_polarity"), RootPolarity) + || !VF_ReadJsonObject(Object, TEXT("root"), Root) + || !VF_ReadJsonObject(Object, TEXT("shape_source"), ShapeSource) + || !VF_ReadJsonObject(Object, TEXT("conversion"), Conversion) + || !VF_ReadJsonInt32(Object, TEXT("structural_param_block"), StructuralParamBlock) + || !VF_ReadJsonArray(Object, TEXT("modifiers"), Modifiers) + || RootPolarity < static_cast(EVoxelStrateRootPolarity::RockCarve) + || RootPolarity > static_cast(EVoxelStrateRootPolarity::VoidFill) + || StructuralParamBlock < static_cast(EVoxelStrateParamBlock::None) + || StructuralParamBlock > static_cast(EVoxelStrateParamBlock::FloatingIsland) + || Modifiers->Num() < 4 || Modifiers->Num() > 8 + || !VF_DeserializeRecipeEntry(Root, OutRecipe.Root) + || !VF_DeserializeRecipeEntry(ShapeSource, OutRecipe.ShapeSource) + || !VF_DeserializeRecipeEntry(Conversion, OutRecipe.Conversion)) + { + return false; + } + + OutRecipe.RootPolarity = static_cast(RootPolarity); + OutRecipe.StructuralParamBlock = static_cast(StructuralParamBlock); + OutRecipe.Modifiers.Reset(); + OutRecipe.Modifiers.Reserve(Modifiers->Num()); + for (const TSharedPtr& Value : *Modifiers) + { + if (!Value.IsValid() || Value->Type != EJson::Object) + { + return false; + } + FVoxelOpRecipeEntry& Entry = OutRecipe.Modifiers.AddDefaulted_GetRef(); + if (!VF_DeserializeRecipeEntry(Value->AsObject(), Entry)) + { + return false; + } + } + return true; + } + + TSharedPtr VF_SerializeArchetypeParams( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params) + { + TSharedPtr Object = MakeShared(); + const void* Memory = VF_GetParamMemory(Params, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return Object; + } + + if (VF_IsTunnelArchetype(Archetype)) + { +#define VF_JSON_WRITE_TUNNEL_LERPF(Name) \ + do { \ + const FString FieldName(TEXT(#Name)); \ + if (!VF_IsExcludedForArchetype(Archetype, FieldName)) { \ + double Value = 0.0; \ + if (VF_ReadNamedField(Memory, Archetype, FieldName, Value)) \ + { Object->SetNumberField(FieldName, Value); } \ + } \ + } while (false); +#define VF_JSON_WRITE_TUNNEL_SNAPF(Name) VF_JSON_WRITE_TUNNEL_LERPF(Name) + VF_STRATE_PARAM_FIELDS(VF_JSON_WRITE_TUNNEL_LERPF, VF_JSON_WRITE_TUNNEL_SNAPF) +#undef VF_JSON_WRITE_TUNNEL_LERPF +#undef VF_JSON_WRITE_TUNNEL_SNAPF + return Object; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + TArray Names; + for (const TPair& Pair : Properties) + { + Names.Add(Pair.Key); + } + Names.Sort([](const FString& A, const FString& B) { return A < B; }); + for (const FString& FieldName : Names) + { + if (VF_IsRuntimeField(FieldName) || VF_IsExcludedForArchetype(Archetype, FieldName)) + { + continue; + } + FProperty* const* PropertyPtr = Properties.Find(FieldName); + if (PropertyPtr == nullptr || *PropertyPtr == nullptr + || !VF_IsScalarProperty(*PropertyPtr)) + { + continue; + } + double Value = 0.0; + if (VF_ReadPropertyValue(*PropertyPtr, Memory, Value)) + { + Object->SetNumberField(FieldName, Value); + } + } + return Object; + } + + TSharedPtr VF_SerializeAllArchetypeParams( + const FVoxelStrateArchetypeParams& Params) + { + // A recipe may read more than the active archetype's family (for example a tunnel + // modifier can be selected while the shape source belongs to another family). Persist + // the complete native parameter vector so re-verification measures the same recipe. + TSharedPtr Object = MakeShared(); + Object->SetObjectField(TEXT("tunnel_network"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::TunnelNetwork, Params)); + Object->SetObjectField(TEXT("slab"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::FlatPlain, Params)); + Object->SetObjectField(TEXT("maze"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::Maze, Params)); + Object->SetObjectField(TEXT("surface"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::SurfaceWorld, Params)); + Object->SetObjectField(TEXT("vertical_shaft"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::VerticalShafts, Params)); + Object->SetObjectField(TEXT("floating_island"), + VF_SerializeArchetypeParams( + ECaveGeneratorType::FloatingIslands, Params)); + return Object; + } + + void VF_ResetParamFamily(FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + Params.TunnelNetworkParams = FStrateGenerationParams(); + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Params.SlabParams = FSlabGenerationParams(); + break; + case ECaveGeneratorType::Maze: + Params.MazeParams = FMazeGenerationParams(); + break; + case ECaveGeneratorType::SurfaceWorld: + Params.SurfaceParams = FSurfaceGenerationParams(); + break; + case ECaveGeneratorType::VerticalShafts: + Params.VerticalShaftParams = FVerticalShaftParams(); + break; + case ECaveGeneratorType::FloatingIslands: + Params.FloatingIslandParams = FFloatingIslandParams(); + break; + default: + break; + } + } + + bool VF_DeserializeArchetypeParams( + ECaveGeneratorType Archetype, + const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid() || !VF_IsSupportedArchetype(Archetype)) + { + return false; + } + + // Reset only the family being read. The promoted record stores all families, and each + // successive call must preserve the values already decoded for the other families. + VF_ResetParamFamily(OutParams, Archetype); + void* Memory = VF_GetParamMemory(OutParams, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Memory == nullptr || Struct == nullptr) + { + return false; + } + + bool bValid = true; + if (VF_IsTunnelArchetype(Archetype)) + { +#define VF_JSON_READ_TUNNEL_LERPF(Name) \ + do { \ + const FString FieldName(TEXT(#Name)); \ + if (!VF_IsExcludedForArchetype(Archetype, FieldName)) { \ + double Value = 0.0; \ + const bool bFieldValid = VF_ReadJsonNumber(Object, FieldName, Value) \ + && VF_WriteNamedField(Memory, Archetype, FieldName, Value); \ + bValid = bFieldValid && bValid; \ + } \ + } while (false); +#define VF_JSON_READ_TUNNEL_SNAPF(Name) VF_JSON_READ_TUNNEL_LERPF(Name) + VF_STRATE_PARAM_FIELDS(VF_JSON_READ_TUNNEL_LERPF, VF_JSON_READ_TUNNEL_SNAPF) +#undef VF_JSON_READ_TUNNEL_LERPF +#undef VF_JSON_READ_TUNNEL_SNAPF + } + else + { + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + const FString& FieldName = Pair.Key; + if (VF_IsRuntimeField(FieldName) || VF_IsExcludedForArchetype(Archetype, FieldName) + || Pair.Value == nullptr || !VF_IsScalarProperty(Pair.Value)) + { + continue; + } + double Value = 0.0; + const bool bFieldValid = VF_ReadJsonNumber(Object, FieldName, Value) + && VF_WritePropertyValue(Pair.Value, Memory, Value); + bValid = bFieldValid && bValid; + } + } + + VF_ResetExcludedFields(OutParams, Archetype); + return bValid; + } + + bool VF_DeserializeAllArchetypeParams( + const TSharedPtr& Object, + FVoxelStrateArchetypeParams& OutParams) + { + if (!Object.IsValid()) + { + return false; + } + + TSharedPtr Tunnel; + TSharedPtr Slab; + TSharedPtr Maze; + TSharedPtr Surface; + TSharedPtr Vertical; + TSharedPtr Floating; + if (!VF_ReadJsonObject(Object, TEXT("tunnel_network"), Tunnel) + || !VF_ReadJsonObject(Object, TEXT("slab"), Slab) + || !VF_ReadJsonObject(Object, TEXT("maze"), Maze) + || !VF_ReadJsonObject(Object, TEXT("surface"), Surface) + || !VF_ReadJsonObject(Object, TEXT("vertical_shaft"), Vertical) + || !VF_ReadJsonObject(Object, TEXT("floating_island"), Floating)) + { + return false; + } + + OutParams = FVoxelStrateArchetypeParams(); + bool bValid = true; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::TunnelNetwork, Tunnel, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::FlatPlain, Slab, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::Maze, Maze, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::SurfaceWorld, Surface, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::VerticalShafts, Vertical, OutParams) && bValid; + bValid = VF_DeserializeArchetypeParams( + ECaveGeneratorType::FloatingIslands, Floating, OutParams) && bValid; + return bValid; + } + + void VF_SetJsonMetricNumbers(const TSharedPtr& Object, + const FVoxelStrateMeasuredMetrics& Metrics) + { + Object->SetBoolField(TEXT("valid"), Metrics.bValid); + Object->SetNumberField(TEXT("num_sampled"), static_cast(Metrics.NumSampled)); + Object->SetNumberField(TEXT("num_air"), static_cast(Metrics.NumAir)); + Object->SetNumberField(TEXT("num_solid"), static_cast(Metrics.NumSolid)); + Object->SetNumberField(TEXT("air_fraction"), Metrics.AirFraction); + Object->SetNumberField(TEXT("num_air_components"), Metrics.NumAirComponents); + Object->SetNumberField(TEXT("largest_component_share"), Metrics.LargestComponentShare); + Object->SetNumberField(TEXT("largest_component_cells"), + static_cast(Metrics.LargestComponentCells)); + Object->SetNumberField(TEXT("num_components_at_least_1_pct"), + Metrics.NumComponentsAtLeast1Pct); + Object->SetNumberField(TEXT("walkable_fraction"), Metrics.WalkableFraction); + Object->SetNumberField(TEXT("walkable_floor_columns"), + static_cast(Metrics.WalkableFloorColumns)); + Object->SetNumberField(TEXT("walkable_floor_area_fraction"), + Metrics.WalkableFloorAreaFraction); + Object->SetNumberField(TEXT("num_walkable_surface_components"), + Metrics.NumWalkableSurfaceComponents); + Object->SetNumberField(TEXT("largest_walkable_surface_columns"), + static_cast(Metrics.LargestWalkableSurfaceColumns)); + Object->SetNumberField(TEXT("largest_walkable_surface_share"), + Metrics.LargestWalkableSurfaceShare); + Object->SetNumberField(TEXT("median_feature_scale"), Metrics.MedianFeatureScale); + Object->SetNumberField(TEXT("median_vertical_clearance"), Metrics.MedianVerticalClearance); + Object->SetBoolField(TEXT("player_fit_resolved"), Metrics.bPlayerFitResolved); + Object->SetStringField(TEXT("player_fit_refusal_reason"), Metrics.PlayerFitRefusalReason); + Object->SetNumberField(TEXT("num_player_fit_cells"), + static_cast(Metrics.NumPlayerFitCells)); + Object->SetNumberField(TEXT("player_fit_fraction"), Metrics.PlayerFitFraction); + Object->SetNumberField(TEXT("num_traversable_components"), + Metrics.NumTraversableComponents); + Object->SetNumberField(TEXT("largest_traversable_component_cells"), + static_cast(Metrics.LargestTraversableComponentCells)); + Object->SetNumberField(TEXT("traversable_component_share"), + Metrics.TraversableComponentShare); + Object->SetNumberField(TEXT("minimum_player_clearance_voxels"), + Metrics.MinimumPlayerClearanceVoxels); + Object->SetNumberField(TEXT("resolved_margin_voxels"), Metrics.ResolvedMarginVoxels); + Object->SetNumberField(TEXT("sampled_min_z"), Metrics.SampledMinZ); + Object->SetNumberField(TEXT("sampled_max_z"), Metrics.SampledMaxZ); + Object->SetNumberField(TEXT("sampled_num_x"), Metrics.SampledNumX); + Object->SetNumberField(TEXT("sampled_num_y"), Metrics.SampledNumY); + Object->SetNumberField(TEXT("sampled_num_z"), Metrics.SampledNumZ); + Object->SetNumberField(TEXT("sampled_min_x"), Metrics.SampledMinX); + Object->SetNumberField(TEXT("sampled_max_x"), Metrics.SampledMaxX); + Object->SetNumberField(TEXT("sampled_min_y"), Metrics.SampledMinY); + Object->SetNumberField(TEXT("sampled_max_y"), Metrics.SampledMaxY); + + TSharedPtr Point = MakeShared(); + Point->SetNumberField(TEXT("x"), Metrics.LargestComponentPoint.X); + Point->SetNumberField(TEXT("y"), Metrics.LargestComponentPoint.Y); + Point->SetNumberField(TEXT("z"), Metrics.LargestComponentPoint.Z); + Object->SetObjectField(TEXT("largest_component_point"), Point); + + TArray> Components; + Components.Reserve(Metrics.AirComponentCells.Num()); + for (const int64 Cells : Metrics.AirComponentCells) + { + Components.Add(MakeShared(static_cast(Cells))); + } + Object->SetArrayField(TEXT("air_component_cells"), MoveTemp(Components)); + } + + TSharedPtr VF_SerializeMetrics(const FVoxelStrateMeasuredMetrics& Metrics) + { + TSharedPtr Object = MakeShared(); + VF_SetJsonMetricNumbers(Object, Metrics); + return Object; + } + + bool VF_DeserializeMetrics(const TSharedPtr& Object, + FVoxelStrateMeasuredMetrics& OutMetrics) + { + if (!Object.IsValid()) + { + return false; + } + + bool bValid = true; + bValid = VF_ReadJsonBool(Object, TEXT("valid"), OutMetrics.bValid) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_sampled"), OutMetrics.NumSampled) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_air"), OutMetrics.NumAir) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_solid"), OutMetrics.NumSolid) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("air_fraction"), OutMetrics.AirFraction) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("num_air_components"), OutMetrics.NumAirComponents) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("largest_component_share"), + OutMetrics.LargestComponentShare) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("largest_component_cells"), + OutMetrics.LargestComponentCells) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("num_components_at_least_1_pct"), + OutMetrics.NumComponentsAtLeast1Pct) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("walkable_fraction"), OutMetrics.WalkableFraction) && bValid; + // These fields were added after the first promotion-store schema. Keep old records + // readable: their absence means the new diagnostic is unknown, not zero evidence. + if (Object->HasField(TEXT("walkable_floor_columns"))) + { + bValid = VF_ReadJsonInt64(Object, TEXT("walkable_floor_columns"), + OutMetrics.WalkableFloorColumns) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("walkable_floor_area_fraction"), + OutMetrics.WalkableFloorAreaFraction) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("num_walkable_surface_components"), + OutMetrics.NumWalkableSurfaceComponents) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("largest_walkable_surface_columns"), + OutMetrics.LargestWalkableSurfaceColumns) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("largest_walkable_surface_share"), + OutMetrics.LargestWalkableSurfaceShare) && bValid; + } + bValid = VF_ReadJsonFloat(Object, TEXT("median_feature_scale"), + OutMetrics.MedianFeatureScale) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("median_vertical_clearance"), + OutMetrics.MedianVerticalClearance) && bValid; + if (Object->HasField(TEXT("player_fit_resolved"))) + { + bValid = VF_ReadJsonBool(Object, TEXT("player_fit_resolved"), + OutMetrics.bPlayerFitResolved) && bValid; + bValid = VF_ReadJsonString(Object, TEXT("player_fit_refusal_reason"), + OutMetrics.PlayerFitRefusalReason) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("num_player_fit_cells"), + OutMetrics.NumPlayerFitCells) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("player_fit_fraction"), + OutMetrics.PlayerFitFraction) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("num_traversable_components"), + OutMetrics.NumTraversableComponents) && bValid; + bValid = VF_ReadJsonInt64(Object, TEXT("largest_traversable_component_cells"), + OutMetrics.LargestTraversableComponentCells) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("traversable_component_share"), + OutMetrics.TraversableComponentShare) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("minimum_player_clearance_voxels"), + OutMetrics.MinimumPlayerClearanceVoxels) && bValid; + } + bValid = VF_ReadJsonInt32(Object, TEXT("resolved_margin_voxels"), + OutMetrics.ResolvedMarginVoxels) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_min_z"), OutMetrics.SampledMinZ) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_max_z"), OutMetrics.SampledMaxZ) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_num_x"), OutMetrics.SampledNumX) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_num_y"), OutMetrics.SampledNumY) && bValid; + bValid = VF_ReadJsonInt32(Object, TEXT("sampled_num_z"), OutMetrics.SampledNumZ) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_min_x"), OutMetrics.SampledMinX) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_max_x"), OutMetrics.SampledMaxX) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_min_y"), OutMetrics.SampledMinY) && bValid; + bValid = VF_ReadJsonFloat(Object, TEXT("sampled_max_y"), OutMetrics.SampledMaxY) && bValid; + + TSharedPtr Point; + bValid = VF_ReadJsonObject(Object, TEXT("largest_component_point"), Point) && bValid; + if (Point.IsValid()) + { + bValid = VF_ReadJsonNumber(Point, TEXT("x"), OutMetrics.LargestComponentPoint.X) && bValid; + bValid = VF_ReadJsonNumber(Point, TEXT("y"), OutMetrics.LargestComponentPoint.Y) && bValid; + bValid = VF_ReadJsonNumber(Point, TEXT("z"), OutMetrics.LargestComponentPoint.Z) && bValid; + } + + const TArray>* Components = nullptr; + bValid = VF_ReadJsonArray(Object, TEXT("air_component_cells"), Components) && bValid; + OutMetrics.AirComponentCells.Reset(); + if (Components != nullptr) + { + OutMetrics.AirComponentCells.Reserve(Components->Num()); + for (const TSharedPtr& Value : *Components) + { + if (!Value.IsValid() || Value->Type != EJson::Number + || !FMath::IsFinite(Value->AsNumber())) + { + bValid = false; + continue; + } + const double Number = Value->AsNumber(); + const int64 Cells = static_cast(Number); + if (Number < 0.0 || static_cast(Cells) != Number) + { + bValid = false; + continue; + } + OutMetrics.AirComponentCells.Add(Cells); + } + } + return bValid; + } + + TSharedPtr VF_SerializePromotableRecord( + const FVoxelStratePromotableRecord& Record) + { + TSharedPtr Object = MakeShared(); + Object->SetStringField(TEXT("record_id"), Record.RecordId); + Object->SetNumberField(TEXT("season"), Record.Season); + Object->SetNumberField(TEXT("seed"), Record.Seed); + Object->SetNumberField(TEXT("candidate_index"), Record.CandidateIndex); + Object->SetNumberField(TEXT("input_corpus_hash"), + static_cast(Record.InputCorpusHash)); + Object->SetStringField(TEXT("archetype"), VF_GetStrateArchetypeName(Record.Archetype)); + Object->SetObjectField(TEXT("parameters"), + VF_SerializeAllArchetypeParams(Record.Params)); + Object->SetObjectField(TEXT("recipe"), VF_SerializeRecipe(Record.Recipe)); + Object->SetObjectField(TEXT("measured_metrics"), VF_SerializeMetrics(Record.MeasuredMetrics)); + Object->SetBoolField(TEXT("passed_non_vacuous"), Record.bPassedNonVacuous); + Object->SetBoolField(TEXT("passed_largest_component"), Record.bPassedLargestComponent); + Object->SetBoolField(TEXT("passed_primordial_law"), Record.bPassedPrimordialLaw); + return Object; + } + + bool VF_DeserializePromotableRecord( + const TSharedPtr& Object, + FVoxelStratePromotableRecord& OutRecord) + { + if (!Object.IsValid() + || !VF_ReadJsonString(Object, TEXT("record_id"), OutRecord.RecordId) + || OutRecord.RecordId.IsEmpty() + || !VF_ReadJsonInt32(Object, TEXT("season"), OutRecord.Season) + || !VF_ReadJsonInt32(Object, TEXT("seed"), OutRecord.Seed) + || !VF_ReadJsonInt32(Object, TEXT("candidate_index"), OutRecord.CandidateIndex)) + { + return false; + } + + int64 CorpusHash = 0; + FString ArchetypeName; + TSharedPtr Params; + TSharedPtr Recipe; + TSharedPtr Metrics; + if (!VF_ReadJsonInt64(Object, TEXT("input_corpus_hash"), CorpusHash) + || CorpusHash <= 0 || CorpusHash > static_cast(TNumericLimits::Max()) + || !VF_ReadJsonString(Object, TEXT("archetype"), ArchetypeName) + || !VF_ReadJsonObject(Object, TEXT("parameters"), Params) + || !VF_ReadJsonObject(Object, TEXT("recipe"), Recipe) + || !VF_ReadJsonObject(Object, TEXT("measured_metrics"), Metrics) + || !VF_ReadJsonBool(Object, TEXT("passed_non_vacuous"), OutRecord.bPassedNonVacuous) + || !VF_ReadJsonBool(Object, TEXT("passed_largest_component"), OutRecord.bPassedLargestComponent) + || !VF_ReadJsonBool(Object, TEXT("passed_primordial_law"), OutRecord.bPassedPrimordialLaw)) + { + return false; + } + + bool bArchetypeFound = false; + for (const ECaveGeneratorType Archetype : GAllArchetypes) + { + if (ArchetypeName == VF_GetStrateArchetypeName(Archetype)) + { + OutRecord.Archetype = Archetype; + bArchetypeFound = true; + break; + } + } + if (!bArchetypeFound + || !VF_DeserializeAllArchetypeParams(Params, OutRecord.Params) + || !VF_DeserializeRecipe(Recipe, OutRecord.Recipe) + || !VF_DeserializeMetrics(Metrics, OutRecord.MeasuredMetrics)) + { + return false; + } + OutRecord.InputCorpusHash = static_cast(CorpusHash); + return true; + } + + bool VF_MeasuredMetricsExactlyEqual(const FVoxelStrateMeasuredMetrics& A, + const FVoxelStrateMeasuredMetrics& B) + { + return A.bValid == B.bValid + && A.NumSampled == B.NumSampled + && A.NumAir == B.NumAir + && A.NumSolid == B.NumSolid + && A.AirFraction == B.AirFraction + && A.NumAirComponents == B.NumAirComponents + && A.LargestComponentShare == B.LargestComponentShare + && A.LargestComponentPoint == B.LargestComponentPoint + && A.LargestComponentCells == B.LargestComponentCells + && A.NumComponentsAtLeast1Pct == B.NumComponentsAtLeast1Pct + && A.WalkableFraction == B.WalkableFraction + && A.WalkableFloorColumns == B.WalkableFloorColumns + && A.WalkableFloorAreaFraction == B.WalkableFloorAreaFraction + && A.NumWalkableSurfaceComponents == B.NumWalkableSurfaceComponents + && A.LargestWalkableSurfaceColumns == B.LargestWalkableSurfaceColumns + && A.LargestWalkableSurfaceShare == B.LargestWalkableSurfaceShare + && A.MedianFeatureScale == B.MedianFeatureScale + && A.MedianVerticalClearance == B.MedianVerticalClearance + && A.bPlayerFitResolved == B.bPlayerFitResolved + && A.PlayerFitRefusalReason == B.PlayerFitRefusalReason + && A.NumPlayerFitCells == B.NumPlayerFitCells + && A.PlayerFitFraction == B.PlayerFitFraction + && A.NumTraversableComponents == B.NumTraversableComponents + && A.LargestTraversableComponentCells == B.LargestTraversableComponentCells + && A.TraversableComponentShare == B.TraversableComponentShare + && A.MinimumPlayerClearanceVoxels == B.MinimumPlayerClearanceVoxels + && A.ResolvedMarginVoxels == B.ResolvedMarginVoxels + && A.SampledMinZ == B.SampledMinZ + && A.SampledMaxZ == B.SampledMaxZ + && A.SampledNumX == B.SampledNumX + && A.SampledNumY == B.SampledNumY + && A.SampledNumZ == B.SampledNumZ + && A.SampledMinX == B.SampledMinX + && A.SampledMaxX == B.SampledMaxX + && A.SampledMinY == B.SampledMinY + && A.SampledMaxY == B.SampledMaxY + && A.AirComponentCells == B.AirComponentCells; + } + + bool VF_SaveJsonObject(const FString& FilePath, + const TSharedPtr& Object, + FString& OutError) + { + const FString Directory = FPaths::GetPath(FilePath); + if (!Directory.IsEmpty() && !IFileManager::Get().MakeDirectory(*Directory, true)) + { + OutError = FString::Printf(TEXT("could not create JSON directory %s"), *Directory); + return false; + } + + FString JsonText; + TSharedRef>> Writer = + TJsonWriterFactory>::Create(&JsonText); + if (!FJsonSerializer::Serialize(Object.ToSharedRef(), Writer) || !Writer->Close()) + { + OutError = FString::Printf(TEXT("could not serialise JSON for %s"), *FilePath); + return false; + } + if (!FFileHelper::SaveStringToFile(JsonText, *FilePath)) + { + OutError = FString::Printf(TEXT("could not write JSON file %s"), *FilePath); + return false; + } + return true; + } + + bool VF_LoadJsonObject(const FString& FilePath, + TSharedPtr& OutObject, + FString& OutError) + { + FString JsonText; + if (!FFileHelper::LoadFileToString(JsonText, *FilePath)) + { + OutError = FString::Printf(TEXT("could not read JSON file %s"), *FilePath); + return false; + } + + TSharedRef> Reader = TJsonReaderFactory<>::Create(JsonText); + if (!FJsonSerializer::Deserialize(Reader, OutObject) || !OutObject.IsValid()) + { + OutError = FString::Printf(TEXT("invalid JSON in %s"), *FilePath); + return false; + } + return true; + } + + FString VF_PromotionSortKey(const FVoxelStratePromotableRecord& Record) + { + if (!Record.RecordId.IsEmpty()) + { + return Record.RecordId; + } + return FString::Printf(TEXT("season_%08d_seed_%08d_candidate_%08d_archetype_%d_recipe_%08x"), + Record.Season, Record.Seed, Record.CandidateIndex, + static_cast(static_cast(Record.Archetype)), + VF_HashStrateStructureRecipe(Record.Recipe)); + } + + bool VF_MetricsAreFinite(const FVoxelStrateMeasuredMetrics& Metrics) + { + return FMath::IsFinite(Metrics.AirFraction) + && FMath::IsFinite(Metrics.LargestComponentShare) + && FMath::IsFinite(Metrics.WalkableFraction) + && FMath::IsFinite(Metrics.WalkableFloorAreaFraction) + && FMath::IsFinite(Metrics.LargestWalkableSurfaceShare) + && FMath::IsFinite(Metrics.MedianFeatureScale) + && (!Metrics.bPlayerFitResolved + || (FMath::IsFinite(Metrics.PlayerFitFraction) + && FMath::IsFinite(Metrics.TraversableComponentShare) + && FMath::IsFinite(Metrics.MinimumPlayerClearanceVoxels))) + && FMath::IsFinite(Metrics.LargestComponentPoint.X) + && FMath::IsFinite(Metrics.LargestComponentPoint.Y) + && FMath::IsFinite(Metrics.LargestComponentPoint.Z); + } +} + +const TArray& FVoxelStrateCorpus::GetNonTunableFields() +{ + static const TArray Exclusions = [] + { + TArray Result; + + const TCHAR* DeadTerrainOpReason = TEXT( + "Empirically dead when only this FStrateGenerationParams slot changes: the runtime " + "bakes Column/Pit/Chimney geometry from UVoxelTerrainOpDefinition values into the " + "room cache, so this direct strata slot does not feed GetDensityAt."); + VF_AddExclusion(Result, TEXT("ColumnDensity"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ColumnMinRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ColumnMaxRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitDensity"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitMinRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitMaxRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("PitDepth"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyDensity"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyMinRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyMaxRadius"), DeadTerrainOpReason); + VF_AddExclusion(Result, TEXT("ChimneyHeight"), DeadTerrainOpReason); + + VF_AddExclusion(Result, TEXT("bTunnelFloorEnabled"), TEXT( + "Deprecated serialized compatibility field: tunnel geometry always owns its floor, " + "so this value is ignored and must not be blended or rolled.")); + + VF_AddExclusion(Result, TEXT("StrateTopWorldZ"), TEXT( + "Runtime Z bound supplied by UVoxelStrateManager for the active layout slot; not a tunable.")); + VF_AddExclusion(Result, TEXT("StrateBottomWorldZ"), TEXT( + "Runtime Z bound supplied by UVoxelStrateManager for the active layout slot; not a tunable.")); + return Result; + }(); + return Exclusions; +} + +void FVoxelStrateCorpus::Reset() +{ + Entries.Reset(); + FieldSpreads.Reset(); + SkippedDefinitions.Reset(); + SchemaError.Reset(); + bSchemaValid = true; + bClampMetadataAvailableAtBuild = false; +} + +void FVoxelStrateCorpus::RebuildSpreads() +{ + FieldSpreads.Reset(); + SchemaError.Reset(); + bSchemaValid = true; + + TMap Indices; + for (const ECaveGeneratorType Archetype : GAllArchetypes) + { + UStruct* Struct = VF_GetParamStruct(Archetype); + if (Struct == nullptr) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("No parameter struct is registered for archetype %s."), + VF_GetStrateArchetypeName(Archetype))); + continue; + } + + const FString ParamStructName = Struct->GetName(); + TMap Properties; + VF_CollectProperties(Struct, Properties); + TSet ExpectedNames; + + if (VF_IsTunnelArchetype(Archetype)) + { +#define VF_COMPOSER_ADD_LERPF(Name) \ + ExpectedNames.Add(TEXT(#Name)); \ + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, Properties, \ + TEXT(#Name), EVoxelStrateFieldKind::Continuous); +#define VF_COMPOSER_ADD_SNAP(Name) \ + ExpectedNames.Add(TEXT(#Name)); \ + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, Properties, \ + TEXT(#Name), VF_FieldKindFor().Name)>()); + VF_STRATE_PARAM_FIELDS(VF_COMPOSER_ADD_LERPF, VF_COMPOSER_ADD_SNAP) +#undef VF_COMPOSER_ADD_LERPF +#undef VF_COMPOSER_ADD_SNAP + } + else + { + for (const TPair& Pair : Properties) + { + if (VF_IsScalarProperty(Pair.Value)) + { + ExpectedNames.Add(Pair.Key); + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, + Properties, Pair.Key, VF_PropertyKind(Pair.Value)); + } + } + } + + // Sibling structs keep runtime bounds as ordinary C++ fields, just as + // FStrateGenerationParams does. Include them in the report and mark them excluded even + // though reflection cannot see them. + if (!VF_IsTunnelArchetype(Archetype)) + { + ExpectedNames.Add(TEXT("StrateTopWorldZ")); + ExpectedNames.Add(TEXT("StrateBottomWorldZ")); + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, Properties, + TEXT("StrateTopWorldZ"), EVoxelStrateFieldKind::Continuous); + VF_AddSpreadDescriptor(FieldSpreads, Indices, Archetype, ParamStructName, Properties, + TEXT("StrateBottomWorldZ"), EVoxelStrateFieldKind::Continuous); + } + + VF_ValidateStructProperties(Properties, ExpectedNames, ParamStructName, Archetype, + bSchemaValid, SchemaError); + + if (VF_IsTunnelArchetype(Archetype)) + { + for (const FVoxelStrateFieldExclusion& Exclusion : GetNonTunableFields()) + { + if (!ExpectedNames.Contains(Exclusion.FieldName) + && !Properties.Contains(Exclusion.FieldName)) + { + bSchemaValid = false; + VF_AppendSchemaError(SchemaError, + FString::Printf(TEXT("Explicit exclusion '%s' is absent from %s."), + *Exclusion.FieldName, *ParamStructName)); + } + } + } + + for (const FString& ExpectedName : ExpectedNames) + { + VF_ValidateFieldDescriptor(FieldSpreads, Archetype, ExpectedName, + bSchemaValid, SchemaError); + } + } + + TArray> Values; + Values.SetNum(FieldSpreads.Num()); + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); + if (Memory == nullptr) + { + continue; + } + + for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) + { + const FVoxelStrateFieldSpread& Spread = FieldSpreads[FieldIndex]; + if (Spread.Archetype != Entry.Archetype) + { + continue; + } + + double Value = 0.0; + if (VF_ReadNamedField(Memory, Entry.Archetype, Spread.FieldName, Value) + && FMath::IsFinite(Value)) + { + Values[FieldIndex].Add(Value); + } + } + } + + for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) + { + int32 AuthoredSampleCount = 0; + int32 PromotedSampleCount = 0; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Archetype != FieldSpreads[FieldIndex].Archetype) + { + continue; + } + double Value = 0.0; + const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); + if (Memory != nullptr + && VF_ReadNamedField(Memory, Entry.Archetype, FieldSpreads[FieldIndex].FieldName, Value) + && FMath::IsFinite(Value)) + { + if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted) + { + ++PromotedSampleCount; + } + else + { + ++AuthoredSampleCount; + } + } + } + FieldSpreads[FieldIndex].AuthoredSampleCount = AuthoredSampleCount; + FieldSpreads[FieldIndex].PromotedSampleCount = PromotedSampleCount; + } + VF_AddTerrainOperationDefaultSamples(Values, FieldSpreads); + + for (int32 FieldIndex = 0; FieldIndex < FieldSpreads.Num(); ++FieldIndex) + { + FVoxelStrateFieldSpread& Spread = FieldSpreads[FieldIndex]; + const TArray& Samples = Values[FieldIndex]; + Spread.DefaultSeedCount = Samples.Num() - Spread.AuthoredSampleCount + - Spread.PromotedSampleCount; + Spread.SampleCount = Samples.Num(); + if (Samples.Num() == 0) + { + continue; + } + + Spread.Min = Samples[0]; + Spread.Max = Samples[0]; + double Sum = 0.0; + for (const double Sample : Samples) + { + Spread.Min = FMath::Min(Spread.Min, Sample); + Spread.Max = FMath::Max(Spread.Max, Sample); + Sum += Sample; + } + Spread.Mean = Sum / static_cast(Samples.Num()); + + double Variance = 0.0; + for (const double Sample : Samples) + { + const double Delta = Sample - Spread.Mean; + Variance += Delta * Delta; + } + Spread.StdDev = FMath::Sqrt(FMath::Max(0.0, Variance / static_cast(Samples.Num()))); + } + +#if WITH_EDITOR + bClampMetadataAvailableAtBuild = true; +#else + bClampMetadataAvailableAtBuild = false; +#endif +} + +bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, + const FStrateGenerationParams& Params, float Weight) +{ + FVoxelStrateArchetypeParams ArchetypeParams; + ArchetypeParams.TunnelNetworkParams = Params; + return AddEntry(SourcePath, SourceName, Archetype, ArchetypeParams, Weight); +} + +bool FVoxelStrateCorpus::AddEntry(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, float Weight) +{ + return AddEntryInternal(SourcePath, SourceName, Archetype, Params, Weight, + EVoxelStrateCorpusProvenance::Project, + nullptr, nullptr, FString(), INDEX_NONE, 0, INDEX_NONE, 0, true); +} + +bool FVoxelStrateCorpus::AddEntryInternal( + const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, + float Weight, EVoxelStrateCorpusProvenance Provenance, + const FVoxelOpStackRecipe* Recipe, + const FVoxelStrateMeasuredMetrics* Metrics, + const FString& PromotionRecordId, + int32 PromotionSeason, + int32 PromotionSeed, + int32 PromotionCandidateIndex, + uint32 PromotionInputCorpusHash, + bool bRebuild) +{ + if (!VF_IsSupportedArchetype(Archetype)) + { + SkippedDefinitions.Add(FString::Printf( + TEXT("%s skipped: unknown archetype value %d."), + *SourcePath, static_cast(static_cast(Archetype)))); + return false; + } + if (!FMath::IsFinite(Weight) || Weight <= 0.0f) + { + SkippedDefinitions.Add(FString::Printf( + TEXT("%s skipped: corpus weight %.9g is not positive and finite."), + *SourcePath, Weight)); + return false; + } + + FVoxelStrateCorpusEntry& Entry = Entries.AddDefaulted_GetRef(); + Entry.SourcePath = SourcePath; + Entry.SourceName = SourceName.IsEmpty() ? SourcePath : SourceName; + Entry.Archetype = Archetype; + Entry.ArchetypeParams = Params; + Entry.Params = Params.TunnelNetworkParams; + Entry.Weight = Weight; + Entry.Provenance = Provenance; + Entry.bHasRecipe = Recipe != nullptr; + if (Recipe != nullptr) + { + Entry.Recipe = *Recipe; + } + Entry.bHasMeasuredMetrics = Metrics != nullptr; + if (Metrics != nullptr) + { + Entry.MeasuredMetrics = *Metrics; + } + Entry.PromotionRecordId = PromotionRecordId; + Entry.PromotionSeason = PromotionSeason; + Entry.PromotionSeed = PromotionSeed; + Entry.PromotionCandidateIndex = PromotionCandidateIndex; + Entry.PromotionInputCorpusHash = PromotionInputCorpusHash; + + Entries.Sort([](const FVoxelStrateCorpusEntry& A, const FVoxelStrateCorpusEntry& B) + { + if (A.SourcePath != B.SourcePath) { return A.SourcePath < B.SourcePath; } + if (static_cast(A.Archetype) != static_cast(B.Archetype)) + { + return static_cast(A.Archetype) < static_cast(B.Archetype); + } + return A.SourceName < B.SourceName; + }); + if (bRebuild) + { + RebuildSpreads(); + } + return true; +} + +bool FVoxelStrateCorpus::LoadFromDefinitions( + const TArray& Definitions, FString& OutReport) +{ + Reset(); + + TArray SortedDefinitions; + for (UVoxelStrateDefinition* Definition : Definitions) + { + if (Definition != nullptr) + { + SortedDefinitions.Add(Definition); + } + } + SortedDefinitions.Sort([](const UVoxelStrateDefinition& A, const UVoxelStrateDefinition& B) + { + return A.GetPathName() < B.GetPathName(); + }); + + TSet SeenPaths; + int32 NumDefinitionEntries = 0; + for (UVoxelStrateDefinition* Definition : SortedDefinitions) + { + const FString SourcePath = Definition->GetPathName(); + if (SeenPaths.Contains(SourcePath)) + { + continue; + } + SeenPaths.Add(SourcePath); + + const FString SourceName = Definition->StrateName.ToString().IsEmpty() + ? Definition->GetName() + : Definition->StrateName.ToString(); + + if (!VF_IsSupportedArchetype(Definition->GeneratorType)) + { + SkippedDefinitions.Add(FString::Printf( + TEXT("%s (%s) skipped: unknown GeneratorType value %d."), + *SourcePath, *SourceName, + static_cast(static_cast(Definition->GeneratorType)))); + continue; + } + + FVoxelStrateArchetypeParams Params; + Params.TunnelNetworkParams = Definition->GenerationParams; + Params.SlabParams = Definition->SlabParams; + Params.MazeParams = Definition->MazeParams; + Params.SurfaceParams = Definition->SurfaceParams; + Params.VerticalShaftParams = Definition->VerticalShaftParams; + Params.FloatingIslandParams = Definition->FloatingIslandParams; + + if (AddEntry(SourcePath, SourceName, Definition->GeneratorType, Params, 1.0f)) + { + ++NumDefinitionEntries; + } + } + + // C++ member initializers in VoxelStrateTypes.h are hand-tuned, known-good starting points. + // Add one member for every exact archetype so an empty group still has a valid parent and so + // the defaults are measured alongside project assets. + FVoxelStrateArchetypeParams Defaults; + int32 NumDefaultEntries = 0; + for (const ECaveGeneratorType Archetype : GAllArchetypes) + { + const FString ArchetypeName = VF_GetStrateArchetypeName(Archetype); + const FString DefaultPath = FString::Printf( + TEXT("/VoxelForge/ComposerDefaults/%s"), *ArchetypeName); + const FString DefaultName = FString::Printf(TEXT("Default_%s"), *ArchetypeName); + if (AddEntryInternal(DefaultPath, DefaultName, Archetype, Defaults, 1.0f, + EVoxelStrateCorpusProvenance::Default, + nullptr, nullptr, FString(), INDEX_NONE, 0, INDEX_NONE, 0, true)) + { + ++NumDefaultEntries; + } + } + + RebuildSpreads(); + TSet ArchetypeSet; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + ArchetypeSet.Add(static_cast(Entry.Archetype)); + } + OutReport = FString::Printf( + TEXT("Corpus definitions: %d resolved project vectors + %d archetype defaults = %d corpus " + "members; %d exact-archetype groups; %d field descriptors measured. "), + NumDefinitionEntries, NumDefaultEntries, Entries.Num(), ArchetypeSet.Num(), + FieldSpreads.Num()); + for (const FString& Skipped : SkippedDefinitions) + { + OutReport += Skipped; + OutReport += TEXT(" "); + } + if (!SchemaError.IsEmpty()) + { + OutReport += TEXT("Schema error: "); + OutReport += SchemaError; + } + return IsValid(); +} + +static bool VF_IsProjectPackagePath(const FString& PackagePath) +{ + return PackagePath == TEXT("/Game") + || PackagePath.StartsWith(TEXT("/Game/"), ESearchCase::IgnoreCase); +} + +static bool VF_IsIgnoredSavedCopy(const FString& PackagePath) +{ + return PackagePath.Contains(TEXT("/Saved/Autosaves"), ESearchCase::IgnoreCase) + || PackagePath.Contains(TEXT("/Saved/Cooked"), ESearchCase::IgnoreCase); +} + +bool FVoxelStrateCorpus::LoadFromAssetRegistry( + FString& OutReport, + const FString& PromotedStorePath, + const IVoxelStratePromotionVerifier* PromotionVerifier) +{ + FAssetRegistryModule& AssetRegistryModule = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + + TArray AssetData; + AssetRegistryModule.Get().GetAssetsByClass( + UVoxelStrateDefinition::StaticClass()->GetClassPathName(), AssetData, true); + AssetData.Sort([](const FAssetData& A, const FAssetData& B) + { + return A.GetObjectPathString() < B.GetObjectPathString(); + }); + + TArray Definitions; + TSet SeenObjectPaths; + int32 NumIgnoredSaved = 0; + int32 NumOutsideProject = 0; + int32 NumDuplicateRecords = 0; + int32 NumUnresolved = 0; + for (const FAssetData& Asset : AssetData) + { + const FString PackagePath = Asset.PackageName.ToString(); + const FString ObjectPath = Asset.GetObjectPathString(); + if (!VF_IsProjectPackagePath(PackagePath)) + { + ++NumOutsideProject; + continue; + } + if (VF_IsIgnoredSavedCopy(PackagePath) || VF_IsIgnoredSavedCopy(ObjectPath)) + { + ++NumIgnoredSaved; + continue; + } + if (SeenObjectPaths.Contains(ObjectPath)) + { + ++NumDuplicateRecords; + continue; + } + SeenObjectPaths.Add(ObjectPath); + + UVoxelStrateDefinition* Definition = Cast(Asset.GetAsset()); + if (Definition == nullptr) + { + ++NumUnresolved; + continue; + } + Definitions.Add(Definition); + } + + FString DefinitionReport; + const bool bLoaded = LoadFromDefinitions(Definitions, DefinitionReport); + int32 NumBaseMetricsMeasured = 0; + int32 NumBaseMetricsUnusable = 0; + int32 NumBaseMetricsUnavailable = 0; + if (PromotionVerifier != nullptr) + { + for (FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted) + { + continue; + } + + FVoxelStrateMeasuredMetrics Metrics; + if (PromotionVerifier->MeasureCorpusEntry(Entry, Metrics) + && Metrics.bValid) + { + Entry.MeasuredMetrics = Metrics; + Entry.bHasMeasuredMetrics = true; + ++NumBaseMetricsMeasured; + if (!Metrics.IsUsable()) + { + ++NumBaseMetricsUnusable; + } + } + else + { + Entry.bHasMeasuredMetrics = false; + ++NumBaseMetricsUnavailable; + } + } + } + OutReport = FString::Printf( + TEXT("Asset Registry strate scan: %d records; %d project candidates; %d Saved/Autosaves " + "or Saved/Cooked copies ignored; %d outside project; %d duplicate records; %d unresolved. " + "%s Base metric audit: %d measured (%d usable, %d vacuous), %d unavailable."), + AssetData.Num(), SeenObjectPaths.Num(), NumIgnoredSaved, NumOutsideProject, + NumDuplicateRecords, NumUnresolved, *DefinitionReport, + NumBaseMetricsMeasured, NumBaseMetricsMeasured - NumBaseMetricsUnusable, + NumBaseMetricsUnusable, NumBaseMetricsUnavailable); + + const FString ResolvedPromotedStorePath = PromotedStorePath.IsEmpty() + ? FPaths::ProjectSavedDir() / TEXT("VoxelForge") / TEXT("StrateCorpus") + / TEXT("promoted_strates.json") + : PromotedStorePath; + FString PromotedReport; + const bool bPromotedLoaded = LoadPromotedRecords( + ResolvedPromotedStorePath, PromotionVerifier, PromotedReport); + OutReport += FString::Printf( + TEXT(" Promotion store: %s Membership provenance: project=%d default=%d promoted=%d."), + *PromotedReport, + NumForProvenance(EVoxelStrateCorpusProvenance::Project), + NumForProvenance(EVoxelStrateCorpusProvenance::Default), + NumForProvenance(EVoxelStrateCorpusProvenance::Promoted)); + return bLoaded && NumUnresolved == 0 && bPromotedLoaded; +} + +int32 FVoxelStrateCorpus::NumForArchetype(ECaveGeneratorType Archetype) const +{ + int32 Count = 0; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Archetype == Archetype) + { + ++Count; + } + } + return Count; +} + +int32 FVoxelStrateCorpus::NumForProvenance(EVoxelStrateCorpusProvenance Provenance) const +{ + int32 Count = 0; + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.Provenance == Provenance) + { + ++Count; + } + } + return Count; +} + +const FVoxelStrateFieldSpread* FVoxelStrateCorpus::FindSpread( + ECaveGeneratorType Archetype, const FString& FieldName) const +{ + for (const FVoxelStrateFieldSpread& Spread : FieldSpreads) + { + if (Spread.Archetype == Archetype && Spread.FieldName == FieldName) + { + return &Spread; + } + } + return nullptr; +} + +const FVoxelStrateFieldSpread* FVoxelStrateCorpus::FindSpread(const FString& FieldName) const +{ + // Compatibility lookup: the original API had one tunnel-family namespace. Prefer + // TunnelNetwork, then fall back to the first matching family for old diagnostics. + if (const FVoxelStrateFieldSpread* Spread = FindSpread(ECaveGeneratorType::TunnelNetwork, FieldName)) + { + return Spread; + } + for (const FVoxelStrateFieldSpread& Spread : FieldSpreads) + { + if (Spread.FieldName == FieldName) + { + return &Spread; + } + } + return nullptr; +} + +uint32 FVoxelStrateCorpus::GetContentsHash() const +{ + uint32 Hash = 2166136261U; + + auto HashBytes = [&Hash](const void* Data, SIZE_T Size) + { + const uint8* Bytes = static_cast(Data); + for (SIZE_T ByteIndex = 0; ByteIndex < Size; ++ByteIndex) + { + Hash ^= static_cast(Bytes[ByteIndex]); + Hash *= 16777619U; + } + }; + auto HashString = [&HashBytes](const FString& String) + { + for (int32 CharIndex = 0; CharIndex < String.Len(); ++CharIndex) + { + const uint32 Code = static_cast(String[CharIndex]); + HashBytes(&Code, sizeof(Code)); + } + const uint32 Terminator = 0U; + HashBytes(&Terminator, sizeof(Terminator)); + }; + + for (const FVoxelStrateCorpusEntry& Entry : Entries) + { + HashString(Entry.SourcePath); + HashString(Entry.SourceName); + const uint8 Archetype = static_cast(Entry.Archetype); + HashBytes(&Archetype, sizeof(Archetype)); + HashBytes(&Entry.Weight, sizeof(Entry.Weight)); + const bool bPromotedEntry = Entry.Provenance == EVoxelStrateCorpusProvenance::Promoted; + + // Preserve the Tier 4 hash byte-for-byte for the existing project/default corpus. A + // promoted member is a new input, however, and its recipe must participate in the hash so + // a season can be reproduced from exactly the same cumulative corpus contents. + if (bPromotedEntry) + { + const uint8 PromotedMarker = 1; + HashBytes(&PromotedMarker, sizeof(PromotedMarker)); + const uint32 RecipeHash = Entry.bHasRecipe + ? VF_HashStrateStructureRecipe(Entry.Recipe) : 0U; + HashBytes(&RecipeHash, sizeof(RecipeHash)); + } + + const void* Memory = VF_GetParamMemory(Entry.ArchetypeParams, Entry.Archetype); + UStruct* Struct = VF_GetParamStruct(Entry.Archetype); + if (Memory == nullptr || Struct == nullptr) + { + continue; + } + + if (VF_IsTunnelArchetype(Entry.Archetype)) + { +#define VF_HASH_FSTRATE_FIELD(Name) \ + if (!bPromotedEntry || !VF_IsRuntimeField(TEXT(#Name))) { \ + HashString(TEXT(#Name)); \ + HashBytes(&Entry.ArchetypeParams.TunnelNetworkParams.Name, \ + sizeof(Entry.ArchetypeParams.TunnelNetworkParams.Name)); \ + } + VF_STRATE_PARAM_FIELDS(VF_HASH_FSTRATE_FIELD, VF_HASH_FSTRATE_FIELD) +#undef VF_HASH_FSTRATE_FIELD + } + else + { + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + HashString(Pair.Key); + if (VF_IsScalarProperty(Pair.Value)) + { + const void* ValuePtr = Pair.Value->ContainerPtrToValuePtr(Memory); + HashBytes(ValuePtr, Pair.Value->GetSize()); + } + } + + static const TCHAR* RuntimeFields[] = + { + TEXT("StrateTopWorldZ"), TEXT("StrateBottomWorldZ") + }; + for (const TCHAR* RuntimeField : RuntimeFields) + { + double Value = 0.0; + if (!bPromotedEntry + && VF_ReadNamedField(Memory, Entry.Archetype, RuntimeField, Value)) + { + HashString(RuntimeField); + HashBytes(&Value, sizeof(Value)); + } + } + } + + if (bPromotedEntry) + { + // Promoted recipes carry all six native families. Include the complete vector in the + // cumulative corpus identity, while omitting runtime Z bounds so a saved record and + // its reloaded form hash identically. + const ECaveGeneratorType Families[] = + { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + }; + for (const ECaveGeneratorType Family : Families) + { + const uint8 FamilyCode = static_cast(Family); + HashBytes(&FamilyCode, sizeof(FamilyCode)); + const void* FamilyMemory = VF_GetParamMemory(Entry.ArchetypeParams, Family); + UStruct* FamilyStruct = VF_GetParamStruct(Family); + if (FamilyMemory == nullptr || FamilyStruct == nullptr) + { + continue; + } + + if (VF_IsTunnelArchetype(Family)) + { +#define VF_HASH_PROMOTED_TUNNEL_FIELD(Name) \ + if (!VF_IsRuntimeField(TEXT(#Name))) { \ + HashString(TEXT(#Name)); \ + HashBytes(&Entry.ArchetypeParams.TunnelNetworkParams.Name, \ + sizeof(Entry.ArchetypeParams.TunnelNetworkParams.Name)); \ + } + VF_STRATE_PARAM_FIELDS(VF_HASH_PROMOTED_TUNNEL_FIELD, VF_HASH_PROMOTED_TUNNEL_FIELD) +#undef VF_HASH_PROMOTED_TUNNEL_FIELD + } + else + { + TMap FamilyProperties; + VF_CollectProperties(FamilyStruct, FamilyProperties); + TArray FamilyNames; + for (const TPair& Pair : FamilyProperties) + { + FamilyNames.Add(Pair.Key); + } + FamilyNames.Sort([](const FString& A, const FString& B) + { + return A < B; + }); + for (const FString& FieldName : FamilyNames) + { + const FProperty* const* PropertyPtr = FamilyProperties.Find(FieldName); + if (PropertyPtr == nullptr || *PropertyPtr == nullptr + || !VF_IsScalarProperty(*PropertyPtr)) + { + continue; + } + HashString(FieldName); + const void* ValuePtr = (*PropertyPtr)->ContainerPtrToValuePtr(FamilyMemory); + HashBytes(ValuePtr, (*PropertyPtr)->GetSize()); + } + } + } + } + } + return Hash; +} + +const TCHAR* VF_GetStrateCorpusProvenanceName(EVoxelStrateCorpusProvenance Provenance) +{ + switch (Provenance) + { + case EVoxelStrateCorpusProvenance::Project: return TEXT("project"); + case EVoxelStrateCorpusProvenance::Default: return TEXT("default"); + case EVoxelStrateCorpusProvenance::Promoted: return TEXT("promoted"); + } + return TEXT("unknown"); +} + +bool VF_AreStrateParamsBitIdentical(const FStrateGenerationParams& A, + const FStrateGenerationParams& B) +{ +#define VF_COMPOSER_COMPARE_LERPF(Name) \ + if (FMemory::Memcmp(&A.Name, &B.Name, sizeof(A.Name)) != 0) { return false; } +#define VF_COMPOSER_COMPARE_SNAP(Name) \ + if (A.Name != B.Name) { return false; } + VF_STRATE_PARAM_FIELDS(VF_COMPOSER_COMPARE_LERPF, VF_COMPOSER_COMPARE_SNAP) +#undef VF_COMPOSER_COMPARE_LERPF +#undef VF_COMPOSER_COMPARE_SNAP + return true; +} + +bool VF_AreStrateArchetypeParamsBitIdentical( + const FVoxelStrateArchetypeParams& A, + const FVoxelStrateArchetypeParams& B, + ECaveGeneratorType Archetype) +{ + if (VF_IsTunnelArchetype(Archetype)) + { + return VF_AreStrateParamsBitIdentical(A.TunnelNetworkParams, B.TunnelNetworkParams); + } + + const void* AMemory = VF_GetParamMemory(A, Archetype); + const void* BMemory = VF_GetParamMemory(B, Archetype); + UStruct* Struct = VF_GetParamStruct(Archetype); + if (AMemory == nullptr || BMemory == nullptr || Struct == nullptr) + { + return false; + } + + TMap Properties; + VF_CollectProperties(Struct, Properties); + for (const TPair& Pair : Properties) + { + const FProperty* Property = Pair.Value; + const void* AValue = Property->ContainerPtrToValuePtr(AMemory); + const void* BValue = Property->ContainerPtrToValuePtr(BMemory); + if (const FBoolProperty* BoolProperty = CastField(Property)) + { + if (BoolProperty->GetPropertyValue(AValue) != BoolProperty->GetPropertyValue(BValue)) + { + return false; + } + } + else if (FMemory::Memcmp(AValue, BValue, Property->GetSize()) != 0) + { + return false; + } + } + + double ATop = 0.0; + double BTop = 0.0; + double ABottom = 0.0; + double BBottom = 0.0; + return VF_ReadRuntimeField(AMemory, Archetype, TEXT("StrateTopWorldZ"), ATop) + && VF_ReadRuntimeField(BMemory, Archetype, TEXT("StrateTopWorldZ"), BTop) + && VF_ReadRuntimeField(AMemory, Archetype, TEXT("StrateBottomWorldZ"), ABottom) + && VF_ReadRuntimeField(BMemory, Archetype, TEXT("StrateBottomWorldZ"), BBottom) + && FMemory::Memcmp(&ATop, &BTop, sizeof(ATop)) == 0 + && FMemory::Memcmp(&ABottom, &BBottom, sizeof(ABottom)) == 0; +} + +double VF_DistanceFromStrateCorpusCentroid( + const FVoxelStrateCorpus& Corpus, + const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype) +{ + const void* Memory = VF_GetParamMemory(Params, Archetype); + if (Memory == nullptr) + { + return 0.0; + } + + double DistanceSquared = 0.0; + for (const FVoxelStrateFieldSpread& Spread : Corpus.GetFieldSpreads()) + { + if (Spread.Archetype != Archetype || Spread.bExcluded + || Spread.Kind == EVoxelStrateFieldKind::Boolean + || Spread.Kind == EVoxelStrateFieldKind::Enum) + { + continue; + } + + double Value = 0.0; + if (!VF_ReadNamedField(Memory, Archetype, Spread.FieldName, Value)) + { + continue; + } + + const double CorpusRange = FMath::Max(0.0, Spread.Max - Spread.Min); + const double Scale = CorpusRange > GNearZeroSpread + ? CorpusRange + : FMath::Max(FMath::Abs(Spread.Mean) * GBootstrapJitterFraction, + GBootstrapMagnitudeFloor); + if (FMath::IsFinite(Value) && FMath::IsFinite(Spread.Mean) && Scale > 0.0) + { + const double Normalized = (Value - Spread.Mean) / Scale; + DistanceSquared += Normalized * Normalized; + } + } + return FMath::Sqrt(FMath::Max(0.0, DistanceSquared)); +} + +double VF_NormalizedMeasuredMetricDistance( + const FVoxelStrateMeasuredMetrics& A, + const FVoxelStrateMeasuredMetrics& B, + const FVoxelStratePromotionPolicy& Policy) +{ + if (!A.IsUsable() || !B.IsUsable() + || !FMath::IsFinite(Policy.FeatureScaleNormalizationVoxels) + || !FMath::IsFinite(Policy.ClearanceNormalizationVoxels) + || Policy.FeatureScaleNormalizationVoxels <= 0.0f + || Policy.ClearanceNormalizationVoxels <= 0.0f) + { + return TNumericLimits::Max(); + } + + const double FeatureDelta = static_cast(A.MedianFeatureScale - B.MedianFeatureScale) + / static_cast(Policy.FeatureScaleNormalizationVoxels); + const double ClearanceDelta = static_cast(A.MedianVerticalClearance + - B.MedianVerticalClearance) + / static_cast(Policy.ClearanceNormalizationVoxels); + const double AirDelta = static_cast(A.AirFraction - B.AirFraction); + const double LargestDelta = static_cast(A.LargestComponentShare + - B.LargestComponentShare); + const double WalkableDelta = static_cast(A.WalkableFraction - B.WalkableFraction); + const double DistanceSquared = AirDelta * AirDelta + + LargestDelta * LargestDelta + + WalkableDelta * WalkableDelta + + FeatureDelta * FeatureDelta + + ClearanceDelta * ClearanceDelta; + return FMath::Sqrt(FMath::Max(0.0, DistanceSquared)); +} + +FVoxelStratePromotionBatchResult VF_SelectStratePromotions( + const FVoxelStrateCorpus& Corpus, + const TArray& Candidates, + const FVoxelStratePromotionPolicy& Policy) +{ + FVoxelStratePromotionBatchResult Result; + Result.NumCandidates = Candidates.Num(); + Result.MinimumAcceptedDistance = 0.0; + + if (!FMath::IsFinite(Policy.MinimumNormalizedMeasuredMetricDistance) + || Policy.MinimumNormalizedMeasuredMetricDistance <= 0.0 + || Policy.MaxPromotionsPerSeason < 0 + || !FMath::IsFinite(Policy.FeatureScaleNormalizationVoxels) + || !FMath::IsFinite(Policy.ClearanceNormalizationVoxels) + || Policy.FeatureScaleNormalizationVoxels <= 0.0f + || Policy.ClearanceNormalizationVoxels <= 0.0f) + { + return Result; + } + Result.bPolicyValid = true; + + const uint32 ExpectedCorpusHash = Corpus.GetContentsHash(); + TArray Ordered = Candidates; + Ordered.Sort([](const FVoxelStratePromotableRecord& A, + const FVoxelStratePromotableRecord& B) + { + const FString AKey = VF_PromotionSortKey(A); + const FString BKey = VF_PromotionSortKey(B); + if (AKey != BKey) + { + return AKey < BKey; + } + if (A.InputCorpusHash != B.InputCorpusHash) + { + return A.InputCorpusHash < B.InputCorpusHash; + } + return A.CandidateIndex < B.CandidateIndex; + }); + + double MinimumDistance = TNumericLimits::Max(); + for (const FVoxelStratePromotableRecord& Candidate : Ordered) + { + if (Candidate.InputCorpusHash != ExpectedCorpusHash) + { + ++Result.NumCorpusHashRejected; + continue; + } + if (!Candidate.bPassedNonVacuous + || !Candidate.bPassedLargestComponent + || !Candidate.bPassedPrimordialLaw + || !Candidate.MeasuredMetrics.IsUsable() + || !VF_MetricsAreFinite(Candidate.MeasuredMetrics) + || !VF_IsSupportedArchetype(Candidate.Archetype) + || Candidate.RecordId.IsEmpty()) + { + ++Result.NumValidationRejected; + continue; + } + + bool bNearDuplicate = false; + for (const FVoxelStrateCorpusEntry& Entry : Corpus.GetEntries()) + { + if (Entry.bHasMeasuredMetrics && Entry.MeasuredMetrics.IsUsable()) + { + const double Distance = VF_NormalizedMeasuredMetricDistance( + Candidate.MeasuredMetrics, Entry.MeasuredMetrics, Policy); + if (Distance < Policy.MinimumNormalizedMeasuredMetricDistance) + { + bNearDuplicate = true; + break; + } + } + else if (Entry.Archetype == Candidate.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Entry.ArchetypeParams, Candidate.Params, Candidate.Archetype)) + { + bNearDuplicate = true; + break; + } + } + if (!bNearDuplicate) + { + for (const FVoxelStratePromotableRecord& Accepted : Result.Promoted) + { + const double Distance = VF_NormalizedMeasuredMetricDistance( + Candidate.MeasuredMetrics, Accepted.MeasuredMetrics, Policy); + if (Distance < Policy.MinimumNormalizedMeasuredMetricDistance + || (Candidate.Archetype == Accepted.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Candidate.Params, Accepted.Params, Candidate.Archetype))) + { + bNearDuplicate = true; + break; + } + MinimumDistance = FMath::Min(MinimumDistance, Distance); + } + } + if (bNearDuplicate) + { + ++Result.NumNearDuplicateRejected; + continue; + } + if (Result.Promoted.Num() >= Policy.MaxPromotionsPerSeason) + { + ++Result.NumCapRejected; + continue; + } + + Result.Promoted.Add(Candidate); + // A first accepted record has no pairwise distance. Keep the public value at zero until + // the batch contains a meaningful comparison. + if (MinimumDistance < TNumericLimits::Max()) + { + Result.MinimumAcceptedDistance = MinimumDistance; + } + } + return Result; +} + +bool VF_SaveStratePromotedRecords( + const FString& StorePath, + const TArray& Records, + FString& OutReport) +{ + if (StorePath.IsEmpty()) + { + OutReport = TEXT("promotion store path is empty"); + return false; + } + + TArray SortedRecords = Records; + SortedRecords.Sort([](const FVoxelStratePromotableRecord& A, + const FVoxelStratePromotableRecord& B) + { + return VF_PromotionSortKey(A) < VF_PromotionSortKey(B); + }); + + TSet RecordIds; + TArray> JsonRecords; + JsonRecords.Reserve(SortedRecords.Num()); + for (const FVoxelStratePromotableRecord& Record : SortedRecords) + { + if (Record.RecordId.IsEmpty() || RecordIds.Contains(Record.RecordId)) + { + OutReport = FString::Printf( + TEXT("promotion store contains an empty or duplicate record id (%s)"), + *Record.RecordId); + return false; + } + RecordIds.Add(Record.RecordId); + JsonRecords.Add(MakeShared(VF_SerializePromotableRecord(Record))); + } + + TSharedPtr Root = MakeShared(); + Root->SetStringField(TEXT("format"), TEXT("VoxelForgePromotedStrates")); + Root->SetNumberField(TEXT("schema_version"), GPromotionStoreSchemaVersion); + Root->SetNumberField(TEXT("record_count"), SortedRecords.Num()); + Root->SetArrayField(TEXT("records"), MoveTemp(JsonRecords)); + + FString Error; + if (!VF_SaveJsonObject(StorePath, Root, Error)) + { + OutReport = Error; + return false; + } + OutReport = FString::Printf(TEXT("saved %d promoted records to %s"), + SortedRecords.Num(), *StorePath); + return true; +} + +bool VF_SaveStrateSeasonManifest( + const FString& ManifestPath, + const FVoxelStrateSeasonManifest& Manifest, + FString& OutReport) +{ + if (ManifestPath.IsEmpty()) + { + OutReport = TEXT("season manifest path is empty"); + return false; + } + + TSharedPtr Root = MakeShared(); + Root->SetStringField(TEXT("format"), TEXT("VoxelForgeSeasonManifest")); + Root->SetNumberField(TEXT("schema_version"), GPromotionStoreSchemaVersion); + Root->SetNumberField(TEXT("season"), Manifest.Season); + Root->SetNumberField(TEXT("seed"), Manifest.Seed); + Root->SetNumberField(TEXT("input_corpus_hash"), + static_cast(Manifest.InputCorpusHash)); + Root->SetNumberField(TEXT("candidate_count"), Manifest.CandidateCount); + Root->SetNumberField(TEXT("survivor_count"), Manifest.SurvivorCount); + Root->SetNumberField(TEXT("promoted_count"), Manifest.PromotedCount); + Root->SetNumberField(TEXT("corpus_size"), Manifest.CorpusSize); + Root->SetNumberField(TEXT("project_count"), Manifest.ProjectCount); + Root->SetNumberField(TEXT("default_count"), Manifest.DefaultCount); + Root->SetNumberField(TEXT("promoted_corpus_count"), Manifest.PromotedCorpusCount); + Root->SetNumberField(TEXT("survival_rate"), Manifest.SurvivalRate); + Root->SetNumberField(TEXT("measured_spread"), Manifest.MeasuredSpread); + + FString Error; + if (!VF_SaveJsonObject(ManifestPath, Root, Error)) + { + OutReport = Error; + return false; + } + OutReport = FString::Printf(TEXT("saved season %d manifest to %s"), + Manifest.Season, *ManifestPath); + return true; +} + +bool FVoxelStrateCorpus::LoadPromotedRecords( + const FString& StorePath, + const IVoxelStratePromotionVerifier* PromotionVerifier, + FString& OutReport) +{ + if (StorePath.IsEmpty()) + { + OutReport = TEXT("no promotion store path supplied; 0 promoted members loaded"); + return false; + } + if (!IFileManager::Get().FileExists(*StorePath)) + { + OutReport = FString::Printf(TEXT("%s is absent; 0 promoted members loaded"), *StorePath); + return true; + } + + TSharedPtr Root; + FString JsonError; + if (!VF_LoadJsonObject(StorePath, Root, JsonError)) + { + OutReport = JsonError; + return false; + } + + int32 SchemaVersion = 0; + int32 StoredRecordCount = 0; + const TArray>* JsonRecords = nullptr; + if (!VF_ReadJsonInt32(Root, TEXT("schema_version"), SchemaVersion) + || SchemaVersion != GPromotionStoreSchemaVersion + || !VF_ReadJsonInt32(Root, TEXT("record_count"), StoredRecordCount) + || !VF_ReadJsonArray(Root, TEXT("records"), JsonRecords)) + { + OutReport = FString::Printf(TEXT("%s has unsupported or malformed promotion-store schema"), + *StorePath); + return false; + } + if (StoredRecordCount != JsonRecords->Num()) + { + OutReport = FString::Printf( + TEXT("%s has record_count=%d but contains %d records"), + *StorePath, StoredRecordCount, JsonRecords->Num()); + return false; + } + + if (PromotionVerifier == nullptr) + { + // A stored metric is evidence for a human, never an admission ticket. Without a + // world-specific verifier there is no safe way to establish the primordial law, so this + // compatibility call intentionally leaves the base corpus unchanged. + OutReport = FString::Printf( + TEXT("%s contains %d records; verifier was null, so all promoted records were skipped"), + *StorePath, JsonRecords->Num()); + return true; + } + + int32 NumLoaded = 0; + int32 NumMalformed = 0; + int32 NumRejected = 0; + int32 NumMetricReplaced = 0; + TSet SeenRecordIds; + for (const TSharedPtr& JsonValue : *JsonRecords) + { + if (!JsonValue.IsValid() || JsonValue->Type != EJson::Object) + { + ++NumMalformed; + continue; + } + + FVoxelStratePromotableRecord Record; + if (!VF_DeserializePromotableRecord(JsonValue->AsObject(), Record) + || SeenRecordIds.Contains(Record.RecordId)) + { + ++NumMalformed; + continue; + } + SeenRecordIds.Add(Record.RecordId); + + FVoxelStratePromotionVerification Verification; + if (!PromotionVerifier->Verify(Record, Verification) + || !Verification.PassedAllGates()) + { + ++NumRejected; + continue; + } + + if (!VF_MeasuredMetricsExactlyEqual(Record.MeasuredMetrics, + Verification.MeasuredMetrics)) + { + ++NumMetricReplaced; + } + const FString SourcePath = FString::Printf( + TEXT("/VoxelForge/Promoted/%s"), *Record.RecordId); + if (!AddEntryInternal(SourcePath, Record.RecordId, Record.Archetype, Record.Params, 1.0f, + EVoxelStrateCorpusProvenance::Promoted, + &Record.Recipe, &Verification.MeasuredMetrics, + Record.RecordId, Record.Season, Record.Seed, + Record.CandidateIndex, Record.InputCorpusHash, false)) + { + ++NumRejected; + continue; + } + ++NumLoaded; + } + + RebuildSpreads(); + OutReport = FString::Printf( + TEXT("%s: %d records re-verified; %d promoted members loaded; %d rejected by fresh gates; " + "%d malformed/duplicate; stored metrics replaced for %d records"), + *StorePath, NumLoaded + NumRejected, NumLoaded, NumRejected, NumMalformed, + NumMetricReplaced); + return true; +} + +bool FVoxelStrateCorpus::SetMeasuredMetrics( + const FString& SourcePath, + const FVoxelStrateMeasuredMetrics& Metrics) +{ + for (FVoxelStrateCorpusEntry& Entry : Entries) + { + if (Entry.SourcePath == SourcePath) + { + Entry.MeasuredMetrics = Metrics; + Entry.bHasMeasuredMetrics = Metrics.bValid; + return true; + } + } + return false; +} +#endif // WITH_EDITOR — corpus loading, promotion and parameter/structure roll implementation + +void VF_SetStrateArchetypeRuntimeBounds( + 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; +} + +namespace VoxelStrateRegionPrivate +{ + constexpr uint32 PartitionSalt = 0x5245474Eu; // "REGN" + constexpr uint32 PositionSaltX = 0x584F4646u; // "XOFF" + constexpr uint32 PositionSaltY = 0x594F4646u; // "YOFF" + constexpr uint32 RegionSalt = 0x52494458u; // "RIDX" + + static int32 CellAt(float World, float CellSize) + { + return FMath::FloorToInt(World / CellSize); + } + + static FVoxelStrateRegionSite MakeSite(int32 CellX, int32 CellY, + int32 InRegionCount, uint32 InPartitionSeed, + float CellSize) + { + FVoxelStrateRegionSite Site; + Site.CellX = CellX; + Site.CellY = CellY; + + const uint32 H = VoxelHash::Cell(CellX, CellY, InPartitionSeed ^ PartitionSalt); + const float JitterX = VoxelHash::ToFloatSigned( + VoxelHash::Mix(H ^ PositionSaltX)) * 0.35f; + const float JitterY = VoxelHash::ToFloatSigned( + VoxelHash::Mix(H ^ PositionSaltY)) * 0.35f; + Site.WorldX = (static_cast(CellX) + 0.5f + JitterX) * CellSize; + Site.WorldY = (static_cast(CellY) + 0.5f + JitterY) * CellSize; + + const uint32 RegionHash = VoxelHash::Cell( + CellX, CellY, InPartitionSeed ^ RegionSalt); + Site.Region = InRegionCount > 1 + ? static_cast(RegionHash % static_cast(InRegionCount)) : 0; + return Site; + } + + static void GatherSites(float WorldX, float WorldY, int32 InRegionCount, + uint32 InPartitionSeed, float CellSize, + TArray& OutSites) + { + OutSites.Reset(); + if (!(CellSize > 0.0f) || !FMath::IsFinite(CellSize)) + { + return; + } + + const int32 CenterX = CellAt(WorldX, CellSize); + const int32 CenterY = CellAt(WorldY, CellSize); + OutSites.Reserve(25); + for (int32 DY = -2; DY <= 2; ++DY) + { + for (int32 DX = -2; DX <= 2; ++DX) + { + OutSites.Add(MakeSite(CenterX + DX, CenterY + DY, + InRegionCount, InPartitionSeed, CellSize)); + } + } + } + + static bool IsEarlierSite(const FVoxelStrateRegionSite& A, + const FVoxelStrateRegionSite& B) + { + if (A.CellX != B.CellX) { return A.CellX < B.CellX; } + return A.CellY < B.CellY; + } + + static FVoxelStrateRegionQuery QuerySites( + const TArray& Sites, int32 InRegionCount, + float BlendWidth, float CellSize, float WorldX, float WorldY) + { + FVoxelStrateRegionQuery Result; + if (InRegionCount <= 1 || Sites.Num() == 0) + { + return Result; + } + Result.NearestDifferentRegionGap = 0.0f; + const int32 CenterCellX = CellAt(WorldX, CellSize); + const int32 CenterCellY = CellAt(WorldY, CellSize); + + int32 PrimaryIndex = INDEX_NONE; + int32 NeighborIndex = INDEX_NONE; + float PrimaryDistanceSq = FLT_MAX; + float NeighborDistanceSq = FLT_MAX; + for (int32 Index = 0; Index < Sites.Num(); ++Index) + { + const FVoxelStrateRegionSite& Site = Sites[Index]; + if (FMath::Abs(Site.CellX - CenterCellX) > 2 + || FMath::Abs(Site.CellY - CenterCellY) > 2) + { + continue; + } + const float DX = WorldX - Site.WorldX; + const float DY = WorldY - Site.WorldY; + const float DistanceSq = DX * DX + DY * DY; + if (PrimaryIndex == INDEX_NONE + || DistanceSq < PrimaryDistanceSq + || (DistanceSq == PrimaryDistanceSq + && IsEarlierSite(Site, Sites[PrimaryIndex]))) + { + PrimaryIndex = Index; + PrimaryDistanceSq = DistanceSq; + } + } + + const int32 PrimaryRegion = Sites[PrimaryIndex].Region; + for (int32 Index = 0; Index < Sites.Num(); ++Index) + { + if (FMath::Abs(Sites[Index].CellX - CenterCellX) > 2 + || FMath::Abs(Sites[Index].CellY - CenterCellY) > 2) + { + continue; + } + if (Sites[Index].Region == PrimaryRegion) { continue; } + const float DX = WorldX - Sites[Index].WorldX; + const float DY = WorldY - Sites[Index].WorldY; + const float DistanceSq = DX * DX + DY * DY; + if (NeighborIndex == INDEX_NONE + || DistanceSq < NeighborDistanceSq + || (DistanceSq == NeighborDistanceSq + && IsEarlierSite(Sites[Index], Sites[NeighborIndex]))) + { + NeighborIndex = Index; + NeighborDistanceSq = DistanceSq; + } + } + + Result.PrimaryRegion = PrimaryRegion; + if (NeighborIndex == INDEX_NONE) + { + return Result; + } + + const float NeighborDistance = FMath::Sqrt(FMath::Max(NeighborDistanceSq, 0.0f)); + const float Gap = FMath::Max( + NeighborDistance + - FMath::Sqrt(FMath::Max(PrimaryDistanceSq, 0.0f)), 0.0f); + Result.NeighborRegion = Sites[NeighborIndex].Region; + Result.NearestDifferentRegionGap = Gap; + + // A site outside the ±2-cell window is at least 2.15 cell lengths from a point in the + // center cell (site jitter is bounded to ±0.35 around the cell midpoint). A candidate + // within 2.0 cell lengths is therefore known to beat every omitted site. Otherwise the + // density path can conservatively use the candidate/zero weight, but box proof must not + // treat the local window as an infinite search. + Result.bNearestDifferentRegionKnown = FMath::IsFinite(CellSize) + && CellSize > 0.0f + && NeighborDistance <= 2.0f * CellSize; + + if (!(BlendWidth > 0.0f)) + { + return Result; + } + + // The vertical transition is linear from 0 at the outer edge to 1 at the boundary. A + // Voronoi boundary is shared by two regions, so the same curve is split symmetrically: + // each side contributes 0.5 at the bisector and 0 outside the band. + const float Curve = FMath::Clamp(1.0f - Gap / BlendWidth, 0.0f, 1.0f); + Result.NeighborWeight = 0.5f * Curve; + Result.bInBlendBand = Result.NeighborWeight > 0.0f; + return Result; + } + + static FVoxelStrateRegionQuery QueryWithGather( + float WorldX, float WorldY, int32 InRegionCount, uint32 InPartitionSeed, + float CellSize, float BlendWidth) + { + TArray Sites; + GatherSites(WorldX, WorldY, InRegionCount, InPartitionSeed, CellSize, Sites); + return QuerySites(Sites, InRegionCount, BlendWidth, CellSize, WorldX, WorldY); + } +} + +#if WITH_EDITOR +int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex) +{ + return 1 + static_cast(VoxelHash::Cell( + StrateIndex, StrateIndex ^ 0x6D, static_cast(Seed) ^ 0x524F4C4Cu) % 3u); +} +#endif + +uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex) +{ + return VoxelHash::Cell(StrateIndex, StrateIndex ^ 0x6D, + static_cast(Seed) ^ 0x52454750u); +} + +void VF_RekeyStrateRegionManifest( + FVoxelStrateRegionManifest& Manifest, int32 InSeed, int32 InStrateIndex) +{ + Manifest.Seed = InSeed; + Manifest.StrateIndex = InStrateIndex; + Manifest.PartitionSeed = VF_GetStrateRegionPartitionSeed(InSeed, InStrateIndex); +} + +FVoxelStrateRegionQuery VF_QueryStrateRegion( + const FVoxelStrateRegionManifest& Manifest, float WorldX, float WorldY) +{ + // The retained seed is an offline artifact, not an alternate identity. Reject a stale or + // hand-edited partition key instead of silently evaluating a different world for the same + // (seed, strate-index) pair. + if (!Manifest.IsValid() + || Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + return FVoxelStrateRegionQuery(); + } + return VoxelStrateRegionPrivate::QueryWithGather( + WorldX, WorldY, Manifest.RegionCount, Manifest.PartitionSeed, + Manifest.LatticeCellSize, Manifest.BlendWidth); +} + +void FVoxelStrateRegionPartitionCache::PrepareForChunk( + const FVoxelStrateRegionManifest& Manifest, const FIntVector& ChunkCoord) +{ + RegionCount = Manifest.RegionCount; + PartitionSeed = Manifest.PartitionSeed; + LatticeCellSize = Manifest.LatticeCellSize; + BlendWidth = Manifest.BlendWidth; + BaseX = ChunkCoord.X * CHUNK_SIZE - 1; + BaseY = ChunkCoord.Y * CHUNK_SIZE - 1; + Dim = CHUNK_SIZE + 3; + Sites.Reset(); + IntegerSamples.Reset(); + + if (!Manifest.IsValid() + || Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + RegionCount = 1; + Dim = 0; + return; + } + + const int32 MinCellX = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseX), LatticeCellSize) - 2; + const int32 MaxCellX = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseX + Dim - 1), LatticeCellSize) + 2; + const int32 MinCellY = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseY), LatticeCellSize) - 2; + const int32 MaxCellY = VoxelStrateRegionPrivate::CellAt( + static_cast(BaseY + Dim - 1), LatticeCellSize) + 2; + Sites.Reserve((MaxCellX - MinCellX + 1) * (MaxCellY - MinCellY + 1)); + for (int32 CY = MinCellY; CY <= MaxCellY; ++CY) + { + for (int32 CX = MinCellX; CX <= MaxCellX; ++CX) + { + Sites.Add(VoxelStrateRegionPrivate::MakeSite( + CX, CY, RegionCount, PartitionSeed, LatticeCellSize)); + } + } + + IntegerSamples.SetNum(Dim * Dim); + for (int32 Y = 0; Y < Dim; ++Y) + { + for (int32 X = 0; X < Dim; ++X) + { + IntegerSamples[Y * Dim + X] = VoxelStrateRegionPrivate::QuerySites( + Sites, RegionCount, BlendWidth, LatticeCellSize, + static_cast(BaseX + X), static_cast(BaseY + Y)); + } + } +} + +FVoxelStrateRegionQuery FVoxelStrateRegionPartitionCache::Query( + float WorldX, float WorldY) const +{ + if (Dim > 0 + && WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY)) + { + const int32 IX = FMath::FloorToInt(WorldX); + const int32 IY = FMath::FloorToInt(WorldY); + const int32 LocalX = IX - BaseX; + const int32 LocalY = IY - BaseY; + if (LocalX >= 0 && LocalX < Dim && LocalY >= 0 && LocalY < Dim) + { + return IntegerSamples[LocalY * Dim + LocalX]; + } + } + + // The production stack is prepared for the current chunk before its first sample. Reuse the + // prepared local site set for the rare fractional probe as well; rebuilding a lattice window + // here would turn a cache miss into a per-voxel allocation/hash walk. The prepared set has a + // one-voxel halo, which covers the mesher's gradient probes. The pure public query remains the + // fallback for an intentionally out-of-window diagnostic call. + const float PreparedMaxX = static_cast(BaseX + Dim - 1); + const float PreparedMaxY = static_cast(BaseY + Dim - 1); + if (Sites.Num() > 0 && Dim > 0 + && WorldX >= static_cast(BaseX) && WorldX <= PreparedMaxX + && WorldY >= static_cast(BaseY) && WorldY <= PreparedMaxY) + { + return VoxelStrateRegionPrivate::QuerySites( + Sites, RegionCount, BlendWidth, LatticeCellSize, WorldX, WorldY); + } + return VoxelStrateRegionPrivate::QueryWithGather( + WorldX, WorldY, RegionCount, PartitionSeed, + LatticeCellSize, BlendWidth); +} + +FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox( + const FVoxelStrateRegionManifest& Manifest, const FBox& VoxelBox) +{ + FVoxelStrateRegionBoxProof Proof; + if (!Manifest.IsValid() + || Manifest.PartitionSeed != VF_GetStrateRegionPartitionSeed( + Manifest.Seed, Manifest.StrateIndex)) + { + return Proof; + } + if (Manifest.RegionCount <= 1) + { + Proof.bProvablySingleRegion = true; + Proof.bTouchesRegionBoundary = false; + Proof.bTouchesBlendBand = false; + return Proof; + } + + const float HalfX = FMath::Max((float)(VoxelBox.Max.X - VoxelBox.Min.X) * 0.5f, 0.0f); + const float HalfY = FMath::Max((float)(VoxelBox.Max.Y - VoxelBox.Min.Y) * 0.5f, 0.0f); + const float Radius = FMath::Sqrt(HalfX * HalfX + HalfY * HalfY); + const float CenterX = ((float)VoxelBox.Min.X + (float)VoxelBox.Max.X) * 0.5f; + const float CenterY = ((float)VoxelBox.Min.Y + (float)VoxelBox.Max.Y) * 0.5f; + const FVoxelStrateRegionQuery Center = VF_QueryStrateRegion(Manifest, CenterX, CenterY); + if (!Center.bNearestDifferentRegionKnown) + { + return Proof; + } + const float GapLowerBound = Center.NearestDifferentRegionGap - 2.0f * Radius; + + // The difference of two Euclidean distances is 2-Lipschitz. Strict inequalities are + // intentional: an equality may put a sample exactly on a bisector or at the blend cutoff, + // and that is not a proof a mesher is allowed to skip. + Proof.bTouchesRegionBoundary = !(GapLowerBound > 0.0f); + Proof.bTouchesBlendBand = !(GapLowerBound > Manifest.BlendWidth); + Proof.bProvablySingleRegion = GapLowerBound > Manifest.BlendWidth; + return Proof; +} + +#if WITH_EDITOR +FVoxelStrateRollInfo VF_RollStrateParamsDetailed(const FVoxelStrateCorpus& Corpus, + int32 Seed, int32 Index) +{ + const FVoxelStrateRollInfo Result = VF_RollInternal(Corpus, Seed, Index); + +#if DO_CHECK + if (Result.bValid) + { + const FVoxelStrateRollInfo Repeat = VF_RollInternal(Corpus, Seed, Index); + checkf(Repeat.bValid && Repeat.Archetype == Result.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Repeat.ArchetypeParams, Result.ArchetypeParams, Result.Archetype) + && Repeat.ParentEntryIndices == Result.ParentEntryIndices + && Repeat.ParentWeights == Result.ParentWeights, + TEXT("VF_RollStrateParamsDetailed lost determinism for the same corpus/seed/index.")); + } +#endif + return Result; +} + +FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype( + const FVoxelStrateCorpus& Corpus, ECaveGeneratorType Archetype, int32 Seed, int32 Index) +{ + const FVoxelStrateRollInfo Result = VF_RollInternal(Corpus, Seed, Index, &Archetype); + +#if DO_CHECK + if (Result.bValid) + { + const FVoxelStrateRollInfo Repeat = VF_RollInternal(Corpus, Seed, Index, &Archetype); + checkf(Repeat.bValid && Repeat.Archetype == Result.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Repeat.ArchetypeParams, Result.ArchetypeParams, Result.Archetype) + && Repeat.ParentEntryIndices == Result.ParentEntryIndices + && Repeat.ParentWeights == Result.ParentWeights, + TEXT("VF_RollStrateParamsDetailedForArchetype lost determinism.")); + } +#endif + return Result; +} + +FVoxelStrateRollInfo VF_RollStrateParamsCorpusFree( + ECaveGeneratorType Archetype, int32 Seed, int32 Index, + EVoxelStrateCorpusFreeSamplingMode Mode, float StrateHeightInVoxels) +{ + const FVoxelStrateRollInfo Result = VF_RollCorpusFreeInternal( + Archetype, Seed, Index, Mode, StrateHeightInVoxels); + +#if DO_CHECK + if (Result.bValid) + { + const FVoxelStrateRollInfo Repeat = VF_RollCorpusFreeInternal( + Archetype, Seed, Index, Mode, StrateHeightInVoxels); + checkf(Repeat.bValid && Repeat.Archetype == Result.Archetype + && VF_AreStrateArchetypeParamsBitIdentical( + Repeat.ArchetypeParams, Result.ArchetypeParams, Archetype), + TEXT("VF_RollStrateParamsCorpusFree lost determinism.")); + } +#endif + return Result; +} + +bool VF_ValidateStrateCorpusFreeConstraints( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + float StrateHeightInVoxels, + FString& OutViolation) +{ + OutViolation.Empty(); + if (!VF_IsSupportedArchetype(Archetype) + || !FMath::IsFinite(StrateHeightInVoxels) || StrateHeightInVoxels <= 0.0f) + { + OutViolation = TEXT("unsupported archetype or non-positive strate height"); + return false; + } + + const float H = FMath::Max(StrateHeightInVoxels, static_cast(CHUNK_SIZE)); + auto Fail = [&OutViolation](const TCHAR* Relation, float Left, float Right) -> bool + { + return VF_FailCorpusFreeConstraint(OutViolation, Relation, Left, Right); + }; + + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + { + const FStrateGenerationParams& P = Params.TunnelNetworkParams; + if (!FMath::IsFinite(P.BaseDensity) || P.BaseDensity <= 0.0f) + { + return Fail(TEXT("BaseDensity must be > 0"), P.BaseDensity, 0.0f); + } + if (!FMath::IsFinite(P.RoomSpacing) || P.RoomSpacing <= 0.0f) + { + return Fail(TEXT("RoomSpacing must be > 0"), P.RoomSpacing, 0.0f); + } + if (!FMath::IsFinite(P.RoomDensity) || P.RoomDensity <= 0.0f + || P.RoomDensity > 1.0f) + { + return Fail(TEXT("RoomDensity must be in (0,1]"), P.RoomDensity, 1.0f); + } + if (!FMath::IsFinite(P.MinRoomRadius) || !FMath::IsFinite(P.MaxRoomRadius) + || P.MinRoomRadius <= 0.0f || P.MaxRoomRadius < P.MinRoomRadius) + { + return Fail(TEXT("room radius interval is ordered and positive"), + P.MinRoomRadius, P.MaxRoomRadius); + } + if (!FMath::IsFinite(P.RoomHeightRatio) || P.RoomHeightRatio <= 0.0f + || P.RoomHeightRatio > 1.0f) + { + return Fail(TEXT("RoomHeightRatio must be in (0,1]"), P.RoomHeightRatio, 1.0f); + } + if (P.RoomFloorCutMin < 0.0f || P.RoomFloorCutMax < P.RoomFloorCutMin + || P.RoomFloorCutMax > 1.0f) + { + return Fail(TEXT("room floor-cut interval is ordered in [0,1]"), + P.RoomFloorCutMin, P.RoomFloorCutMax); + } + if (P.TunnelMinRadius <= 0.0f || P.TunnelMaxRadius < P.TunnelMinRadius) + { + return Fail(TEXT("tunnel radius interval is ordered and positive"), + P.TunnelMinRadius, P.TunnelMaxRadius); + } + if (P.TunnelMaxRadius > P.MinRoomRadius) + { + return Fail(TEXT("TunnelMaxRadius must fit through the smallest room"), + P.TunnelMaxRadius, P.MinRoomRadius); + } + const float RoomCoverage = PI * P.MaxRoomRadius * P.MaxRoomRadius + * P.RoomDensity / (P.RoomSpacing * P.RoomSpacing); + if (RoomCoverage < 0.02f || RoomCoverage > 0.35f) + { + return Fail(TEXT("room area coverage must stay in [0.02,0.35]"), + RoomCoverage, 0.35f); + } + if (P.MaxRoomRadius > P.RoomSpacing * 0.50f) + { + return Fail(TEXT("MaxRoomRadius must be <= half the room-cell spacing"), + P.MaxRoomRadius, P.RoomSpacing * 0.50f); + } + const float LargestRoomRadius = FMath::Max(P.MaxRoomRadius, P.OriginRoomRadius); + const float RoomZBuffer = LargestRoomRadius * P.RoomHeightRatio; + if (P.BoundarySealThickness < 0.0f + || P.BoundarySealThickness + RoomZBuffer >= H * 0.50f) + { + return Fail(TEXT("seal + tallest room half-height must fit in half the strate"), + P.BoundarySealThickness + RoomZBuffer, H * 0.50f); + } + const float HorizontalReach = 1.7f * P.RoomSpacing * FMath::Sqrt(2.0f); + const float VerticalReach = 2.0f * P.MaxRoomRadius * P.RoomHeightRatio; + const float RequiredTunnelReach = FMath::Sqrt( + HorizontalReach * HorizontalReach + VerticalReach * VerticalReach); + if (P.MaxTunnelLength < RequiredTunnelReach) + { + return Fail(TEXT("MaxTunnelLength must reach a jittered neighbouring room"), + P.MaxTunnelLength, RequiredTunnelReach); + } + if (P.WormStrength <= P.BaseDensity) + { + return Fail(TEXT("WormStrength must overcome BaseDensity"), + P.WormStrength, P.BaseDensity); + } + if (P.DomeMaxRadius > P.MaxRoomRadius) + { + return Fail(TEXT("DomeMaxRadius must fit inside MaxRoomRadius"), + P.DomeMaxRadius, P.MaxRoomRadius); + } + if (P.DomeHeightRatio * P.DomeMaxRadius + > P.RoomHeightRatio * P.MaxRoomRadius) + { + return Fail(TEXT("dome height must fit the room vertical radius"), + P.DomeHeightRatio * P.DomeMaxRadius, + P.RoomHeightRatio * P.MaxRoomRadius); + } + return true; + } + + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + { + const FSlabGenerationParams& P = Params.SlabParams; + const float FloorWorst = H * P.FloorRelativeHeight + + VOXEL_NOISE_SCALE * FMath::Max(P.FloorRoughness, 0.0f); + const float CeilingWorst = H * P.CeilingRelativeHeight + - VOXEL_NOISE_SCALE * FMath::Max(P.CeilingRoughness, 0.0f); + if (P.FloorRelativeHeight < 0.0f || P.CeilingRelativeHeight > 1.0f + || P.FloorRelativeHeight >= P.CeilingRelativeHeight) + { + return Fail(TEXT("slab floor must be below slab ceiling"), + P.FloorRelativeHeight, P.CeilingRelativeHeight); + } + if (P.BoundarySealThickness < 0.0f + || FloorWorst <= P.BoundarySealThickness + || CeilingWorst >= H - P.BoundarySealThickness) + { + return Fail(TEXT("slab roughness must stay inside the seal bands"), + FloorWorst, CeilingWorst); + } + if (FloorWorst + 2.0f >= CeilingWorst) + { + return Fail(TEXT("slab worst-case noise must leave two voxels of void"), + FloorWorst + 2.0f, CeilingWorst); + } + if (P.ColumnMinRadius <= 0.0f || P.ColumnMaxRadius < P.ColumnMinRadius) + { + return Fail(TEXT("slab column radius interval is ordered and positive"), + P.ColumnMinRadius, P.ColumnMaxRadius); + } + if (P.ColumnSpacing <= 0.0f || P.ColumnMaxRadius > P.ColumnSpacing * 0.50f) + { + return Fail(TEXT("slab columns must fit within half a column cell"), + P.ColumnMaxRadius, P.ColumnSpacing * 0.50f); + } + if (P.BaseDensity <= 0.0f) + { + return Fail(TEXT("BaseDensity must be > 0"), P.BaseDensity, 0.0f); + } + return true; + } + + case ECaveGeneratorType::Maze: + { + const FMazeGenerationParams& P = Params.MazeParams; + if (P.CellSize <= 0.0f || P.CorridorRadius <= 0.0f) + { + return Fail(TEXT("maze CellSize and CorridorRadius must be > 0"), + P.CellSize, P.CorridorRadius); + } + if (P.CorridorRadius > P.CellSize * 0.25f) + { + return Fail(TEXT("CorridorRadius must leave lattice walls"), + P.CorridorRadius, P.CellSize * 0.25f); + } + if (P.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f + >= P.CorridorRadius + 2.0f) + { + return Fail(TEXT("maze roughness must not erase the corridor centreline"), + P.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f, + P.CorridorRadius + 2.0f); + } + if (P.BoundarySealThickness < 0.0f + || P.BoundarySealThickness * 2.0f >= H) + { + return Fail(TEXT("maze seals must leave an interior"), + P.BoundarySealThickness * 2.0f, H); + } + if (P.BaseDensity <= 0.0f) + { + return Fail(TEXT("BaseDensity must be > 0"), P.BaseDensity, 0.0f); + } + return true; + } + + case ECaveGeneratorType::SurfaceWorld: + { + const FSurfaceGenerationParams& P = Params.SurfaceParams; + const float RoughBound = VOXEL_NOISE_SCALE * FMath::Max(P.SurfaceRoughness, 0.0f); + const float CliffSpread = P.ElevationRange + * (1.0f + FMath::Max(P.MountainStrength, 0.0f)) + + 2.0f * RoughBound; + const float CliffBudget = FMath::Max(P.CliffStrength, 0.0f) + * FMath::Max(P.CliffSharpness, 0.0f) * FMath::Max(CliffSpread, 0.0f); + const float GroundFeatureBudget = CliffBudget + + FMath::Max(P.TerraceHeight, 0.0f) + + FMath::Abs(P.LayerLineDepth) + + FMath::Max(P.BeachWidth, 0.0f); + const float GroundLower = H * P.BaseGroundRelative + - P.ElevationRange * 0.50f - RoughBound - GroundFeatureBudget; + const float GroundUpper = H * P.BaseGroundRelative + + P.ElevationRange * (0.50f + FMath::Max(P.MountainStrength, 0.0f)) + + RoughBound + GroundFeatureBudget; + const float CapLower = H * P.CeilingRelative + - VOXEL_NOISE_SCALE * FMath::Max(P.CeilingUndulation, 0.0f) + - VOXEL_NOISE_SCALE * FMath::Max(P.CeilingRoughness, 0.0f) + - FMath::Max(P.CeilingRidgeStrength, 0.0f); + if (P.BoundarySealThickness < 0.0f + || GroundLower <= P.BoundarySealThickness) + { + return Fail(TEXT("surface ground lower bound must clear the bottom seal"), + GroundLower, P.BoundarySealThickness); + } + if (CapLower >= H - P.BoundarySealThickness) + { + return Fail(TEXT("surface cap lower bound must stay below the top seal"), + CapLower, H - P.BoundarySealThickness); + } + if (GroundUpper + 2.0f >= CapLower) + { + return Fail(TEXT("surface ground and cap worst-case bounds need two voxels"), + GroundUpper + 2.0f, CapLower); + } + if (P.OverhangStrength > 0.0f + && GroundUpper + P.OverhangHeight + 2.0f >= CapLower) + { + return Fail(TEXT("surface overhang height must fit below the cap"), + GroundUpper + P.OverhangHeight + 2.0f, CapLower); + } + if (P.BaseDensity <= 0.0f) + { + return Fail(TEXT("BaseDensity must be > 0"), P.BaseDensity, 0.0f); + } + return true; + } + + case ECaveGeneratorType::VerticalShafts: + { + const FVerticalShaftParams& P = Params.VerticalShaftParams; + const float RoughnessReach = FMath::Max(P.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE * 1.5f; + const float FloorClearance = FMath::Max( + 1.0f, P.SurfaceRoughness * VOXEL_NOISE_SCALE + 1.0f); + if (P.ShaftSpacing <= 0.0f || P.ShaftMinRadius <= 0.0f + || P.ShaftMaxRadius < P.ShaftMinRadius) + { + return Fail(TEXT("shaft spacing/radius values must be ordered and positive"), + P.ShaftMinRadius, P.ShaftMaxRadius); + } + if (P.ShaftMaxRadius > P.ShaftSpacing * 0.50f) + { + return Fail(TEXT("shaft radius must leave the neighbouring cell wall"), + P.ShaftMaxRadius, P.ShaftSpacing * 0.50f); + } + if (P.ShaftMinRadius <= RoughnessReach + 0.25f) + { + return Fail(TEXT("shaft radius must exceed the landing roughness margin"), + P.ShaftMinRadius, RoughnessReach + 0.25f); + } + if (P.ConnectorRadius < RoughnessReach + 1.0f) + { + return Fail(TEXT("ConnectorRadius must exceed the tree roughness margin"), + P.ConnectorRadius, RoughnessReach + 1.0f); + } + if (P.LedgeSpacing > 0.0f && P.LedgeDepth > 0.0f + && P.LedgeSpacing <= 2.0f * (P.LedgeDepth + FloorClearance)) + { + return Fail(TEXT("ledge spacing must leave a safe band on both sides"), + P.LedgeSpacing, 2.0f * (P.LedgeDepth + FloorClearance)); + } + if (P.BoundarySealThickness < 0.0f + || P.BoundarySealThickness * 2.0f >= H) + { + return Fail(TEXT("shaft seals must leave an interior"), + P.BoundarySealThickness * 2.0f, H); + } + if (P.BaseDensity <= 0.0f) + { + return Fail(TEXT("BaseDensity must be > 0"), P.BaseDensity, 0.0f); + } + return true; + } + + case ECaveGeneratorType::FloatingIslands: + { + const FFloatingIslandParams& P = Params.FloatingIslandParams; + const float UnderDepth = P.IslandMaxRadius * FMath::Max(P.ThicknessRatio, 0.25f); + const float TopHalf = P.IslandMaxRadius * 0.20f; + const float MaxIslandHalfHeight = FMath::Max(TopHalf, UnderDepth); + if (P.IslandSpacing <= 0.0f || P.IslandMinRadius <= 0.0f + || P.IslandMaxRadius < P.IslandMinRadius) + { + return Fail(TEXT("island spacing/radius values must be ordered and positive"), + P.IslandMinRadius, P.IslandMaxRadius); + } + if (P.IslandMaxRadius > P.IslandSpacing * 0.50f) + { + return Fail(TEXT("island radius must leave a cell-scale separation budget"), + P.IslandMaxRadius, P.IslandSpacing * 0.50f); + } + if (P.BoundarySealThickness < 0.0f + || P.BoundarySealThickness + MaxIslandHalfHeight >= H * 0.50f) + { + return Fail(TEXT("island thickness + seal must fit in half the strate"), + P.BoundarySealThickness + MaxIslandHalfHeight, H * 0.50f); + } + if (P.IslandMinRadius + <= P.SurfaceRoughness * VOXEL_NOISE_SCALE + P.SDFBlendRadius) + { + return Fail(TEXT("island minimum radius must survive roughness and SDF blend"), + P.IslandMinRadius, + P.SurfaceRoughness * VOXEL_NOISE_SCALE + P.SDFBlendRadius); + } + if (P.BaseDensity <= 0.0f) + { + return Fail(TEXT("BaseDensity must be > 0"), P.BaseDensity, 0.0f); + } + return true; + } + + default: + break; + } + OutViolation = TEXT("no constraint rule for archetype"); + return false; +} + +namespace +{ + const TCHAR* VF_RecipeOpCode(EVoxelStrateOpClass OpClass) + { + switch (OpClass) + { + case EVoxelStrateOpClass::ConstantRockSource: return TEXT("rk"); + case EVoxelStrateOpClass::ConstantVoidSource: return TEXT("vd"); + case EVoxelStrateOpClass::RoomGraphSource: return TEXT("rg"); + case EVoxelStrateOpClass::LatticeCorridorSource: return TEXT("lt"); + case EVoxelStrateOpClass::ShaftFieldSource: return TEXT("sh"); + case EVoxelStrateOpClass::IslandBlobSource: return TEXT("is"); + case EVoxelStrateOpClass::NoiseRibbonSource: return TEXT("nr"); + case EVoxelStrateOpClass::SdfRoughnessMod: return TEXT("sr"); + case EVoxelStrateOpClass::SdfCarve: return TEXT("cv"); + case EVoxelStrateOpClass::SdfFill: return TEXT("fl"); + case EVoxelStrateOpClass::GridColumnMod: return TEXT("gc"); + case EVoxelStrateOpClass::CaveRoughnessMod: return TEXT("cr"); + case EVoxelStrateOpClass::CaveTerraceMod: return TEXT("tr"); + case EVoxelStrateOpClass::LayerLineMod: return TEXT("ll"); + case EVoxelStrateOpClass::RibbingMod: return TEXT("rb"); + case EVoxelStrateOpClass::CaveOverhangMod: return TEXT("co"); + case EVoxelStrateOpClass::CaveCliffMod: return TEXT("cc"); + case EVoxelStrateOpClass::ScallopMod: return TEXT("sc"); + case EVoxelStrateOpClass::CaveArchMod: return TEXT("ar"); + case EVoxelStrateOpClass::RoomColumnMod: return TEXT("rc"); + case EVoxelStrateOpClass::DomeMod: return TEXT("dm"); + case EVoxelStrateOpClass::PinchMod: return TEXT("pn"); + case EVoxelStrateOpClass::FloorBiasMod: return TEXT("fb"); + case EVoxelStrateOpClass::WormFieldSource: return TEXT("wm"); + case EVoxelStrateOpClass::ShaftLedgeMod: return TEXT("sl"); + case EVoxelStrateOpClass::DensityNoiseCarveMod: return TEXT("nc"); + case EVoxelStrateOpClass::DensityNoiseFillMod: return TEXT("nf"); + default: return TEXT("??"); + } + } + + const TCHAR* VF_RecipeBlockCode(EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::TunnelNetwork: return TEXT("T"); + case EVoxelStrateParamBlock::Slab: return TEXT("B"); + case EVoxelStrateParamBlock::Maze: return TEXT("M"); + case EVoxelStrateParamBlock::Surface: return TEXT("S"); + case EVoxelStrateParamBlock::VerticalShaft: return TEXT("V"); + case EVoxelStrateParamBlock::FloatingIsland: return TEXT("I"); + default: return TEXT("-"); + } + } + + FString VF_FormatRecipeEntry(const FVoxelOpRecipeEntry& Entry) + { + return FString::Printf(TEXT("%s/%s"), VF_RecipeOpCode(Entry.OpClass), + VF_RecipeBlockCode(Entry.ParamBlock)); + } +} + +FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index) +{ + const FVoxelOpStackRecipe Recipe = VF_RollStructureInternal(Seed, Index); + +#if DO_CHECK + const FVoxelOpStackRecipe Repeat = VF_RollStructureInternal(Seed, Index); + checkf(VF_AreStrateStructureRecipesIdentical(Recipe, Repeat), + TEXT("VF_RollStrateStructure lost determinism for the same seed/index.")); +#endif + return Recipe; +} + +namespace VoxelStrateRegionPrivate +{ + static ECaveGeneratorType ArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe) + { + switch (Recipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork; + case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain; + case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze; + case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld; + case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts; + case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands; + default: return ECaveGeneratorType::TunnelNetwork; + } + } + + static EVoxelStrateParamBlock ParamBlockForArchetype(ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: return EVoxelStrateParamBlock::Slab; + case ECaveGeneratorType::Maze: return EVoxelStrateParamBlock::Maze; + case ECaveGeneratorType::SurfaceWorld: return EVoxelStrateParamBlock::Surface; + case ECaveGeneratorType::VerticalShafts: return EVoxelStrateParamBlock::VerticalShaft; + case ECaveGeneratorType::FloatingIslands:return EVoxelStrateParamBlock::FloatingIsland; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + default: return EVoxelStrateParamBlock::TunnelNetwork; + } + } + + static void CopyParamBlock(FVoxelStrateArchetypeParams& OutParams, + const FVoxelStrateRollInfo& Roll, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutParams.SlabParams = Roll.ArchetypeParams.SlabParams; + break; + case ECaveGeneratorType::Maze: + OutParams.MazeParams = Roll.ArchetypeParams.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams; + break; + default: + break; + } + } + + static float BoundarySeal(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BoundarySealThickness; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BoundarySealThickness; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BoundarySealThickness; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BoundarySealThickness; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BoundarySealThickness; + } + } + + static float BaseDensity(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.BaseDensity; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.BaseDensity; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.BaseDensity; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.BaseDensity; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.BaseDensity; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.BaseDensity; + } + } + + static float Top(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateTopWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateTopWorldZ; + } + } + + static float Bottom(const FVoxelStrateArchetypeParams& Params, + EVoxelStrateParamBlock Block) + { + switch (Block) + { + case EVoxelStrateParamBlock::Slab: return Params.SlabParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Maze: return Params.MazeParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::Surface: return Params.SurfaceParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::VerticalShaft: return Params.VerticalShaftParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::FloatingIsland: return Params.FloatingIslandParams.StrateBottomWorldZ; + case EVoxelStrateParamBlock::TunnelNetwork: + default: return Params.TunnelNetworkParams.StrateBottomWorldZ; + } + } +} + +bool VF_LateralRegionsAreShippable() +{ + // ⛔ FALSE ON PURPOSE. See the declaration in VoxelStrateComposer.h for the full reasoning. + // + // FR : volontairement désactivé — la loi primordiale ne tient pas encore au travers d'une + // couture entre régions (9/16 graines mesurées le 2026-09-05). + // + // Measured: arrival -> departure across a region seam passes 9/16 seeds. A strate that mixes + // archetypes but strands the player at the boundary is worse than one that does not mix. + // Flipping this without an explicit cross-seam corridor contract turns the suite red by design. + return false; +} + +FVoxelStrateRegionManifest VF_RollStrateRegionManifest( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 StrateIndex, bool bRollStructure) +{ + FVoxelStrateRegionManifest Manifest; + Manifest.RegionCount = VF_RollStrateRegionCount(Seed, StrateIndex); + VF_RekeyStrateRegionManifest(Manifest, Seed, StrateIndex); + Manifest.Regions.Reserve(Manifest.RegionCount); + Manifest.bValid = true; + + static const ECaveGeneratorType StructureBlocks[] = + { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + }; + static const uint32 BlockSalts[] = { 0x1001u, 0x1003u, 0x1005u, + 0x1007u, 0x1009u, 0x100Bu }; + + for (int32 RegionIndex = 0; RegionIndex < Manifest.RegionCount; ++RegionIndex) + { + FVoxelStrateRegion& Region = Manifest.Regions.AddDefaulted_GetRef(); + Region.RegionIndex = RegionIndex; + Region.Seed = RegionIndex == 0 + ? Seed + : static_cast(VoxelHash::Cell( + StrateIndex, RegionIndex, static_cast(Seed) ^ 0x52454753u)); + Region.bUsesRecipe = bRollStructure; + + if (!bRollStructure) + { + const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailed( + Corpus, Region.Seed, StrateIndex); + if (!Roll.bValid) + { + Manifest.bValid = false; + if (Manifest.FailureReason.IsEmpty()) + { + Manifest.FailureReason = Roll.FailureReason; + } + continue; + } + Region.Archetype = Roll.Archetype; + Region.ArchetypeParams = Roll.ArchetypeParams; + } + else + { + Region.Recipe = VF_RollStrateStructure(Region.Seed, StrateIndex); + Region.Archetype = VoxelStrateRegionPrivate::ArchetypeForRecipe(Region.Recipe); + for (int32 BlockIndex = 0; BlockIndex < UE_ARRAY_COUNT(StructureBlocks); ++BlockIndex) + { + const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailedForArchetype( + Corpus, StructureBlocks[BlockIndex], + Region.Seed ^ static_cast(BlockSalts[BlockIndex]), StrateIndex); + if (!Roll.bValid) + { + Manifest.bValid = false; + if (Manifest.FailureReason.IsEmpty()) + { + Manifest.FailureReason = Roll.FailureReason; + } + continue; + } + VoxelStrateRegionPrivate::CopyParamBlock( + Region.ArchetypeParams, Roll, StructureBlocks[BlockIndex]); + } + } + } + + if (Manifest.Regions.Num() == Manifest.RegionCount && Manifest.RegionCount > 0) + { + const FVoxelStrateRegion& First = Manifest.Regions[0]; + Manifest.StructuralParamBlock = bRollStructure + ? First.Recipe.StructuralParamBlock + : VoxelStrateRegionPrivate::ParamBlockForArchetype(First.Archetype); + Manifest.StrateTopWorldZ = VoxelStrateRegionPrivate::Top( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.StrateBottomWorldZ = VoxelStrateRegionPrivate::Bottom( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.BoundarySealThickness = VoxelStrateRegionPrivate::BoundarySeal( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.BaseDensity = VoxelStrateRegionPrivate::BaseDensity( + First.ArchetypeParams, Manifest.StructuralParamBlock); + Manifest.bHasGlobalStructuralParams = FMath::IsFinite(Manifest.StrateTopWorldZ) + && FMath::IsFinite(Manifest.StrateBottomWorldZ) + && Manifest.StrateTopWorldZ > Manifest.StrateBottomWorldZ + && FMath::IsFinite(Manifest.BoundarySealThickness) + && FMath::IsFinite(Manifest.BaseDensity); + } + return Manifest; +} + +#if WITH_EDITOR +namespace +{ + struct FComposerStructureBlockSpec + { + EVoxelStrateParamBlock Block; + ECaveGeneratorType Archetype; + uint32 SeedSalt; + }; + + // Keep these salts in the composer implementation so the editor action and the offline + // structure test cannot silently acquire different block streams. + static const FComposerStructureBlockSpec GComposerStructureBlocks[] = + { + { EVoxelStrateParamBlock::TunnelNetwork, ECaveGeneratorType::TunnelNetwork, 0x1001u }, + { EVoxelStrateParamBlock::Slab, ECaveGeneratorType::FlatPlain, 0x1003u }, + { EVoxelStrateParamBlock::Maze, ECaveGeneratorType::Maze, 0x1005u }, + { EVoxelStrateParamBlock::Surface, ECaveGeneratorType::SurfaceWorld, 0x1007u }, + { EVoxelStrateParamBlock::VerticalShaft, ECaveGeneratorType::VerticalShafts, 0x1009u }, + { EVoxelStrateParamBlock::FloatingIsland, ECaveGeneratorType::FloatingIslands, 0x100Bu }, + }; + + void VF_CopyComposerBlock(FVoxelStrateArchetypeParams& OutParams, + const FVoxelStrateRollInfo& Roll, + ECaveGeneratorType Archetype) + { + switch (Archetype) + { + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + OutParams.TunnelNetworkParams = Roll.ArchetypeParams.TunnelNetworkParams; + break; + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + OutParams.SlabParams = Roll.ArchetypeParams.SlabParams; + break; + case ECaveGeneratorType::Maze: + OutParams.MazeParams = Roll.ArchetypeParams.MazeParams; + break; + case ECaveGeneratorType::SurfaceWorld: + OutParams.SurfaceParams = Roll.ArchetypeParams.SurfaceParams; + break; + case ECaveGeneratorType::VerticalShafts: + OutParams.VerticalShaftParams = Roll.ArchetypeParams.VerticalShaftParams; + break; + case ECaveGeneratorType::FloatingIslands: + OutParams.FloatingIslandParams = Roll.ArchetypeParams.FloatingIslandParams; + break; + default: + break; + } + } + + ECaveGeneratorType VF_ComposerArchetypeForRecipe(const FVoxelOpStackRecipe& Recipe) + { + switch (Recipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: return ECaveGeneratorType::TunnelNetwork; + case EVoxelStrateParamBlock::Slab: return ECaveGeneratorType::FlatPlain; + case EVoxelStrateParamBlock::Maze: return ECaveGeneratorType::Maze; + case EVoxelStrateParamBlock::Surface: return ECaveGeneratorType::SurfaceWorld; + case EVoxelStrateParamBlock::VerticalShaft: return ECaveGeneratorType::VerticalShafts; + case EVoxelStrateParamBlock::FloatingIsland: return ECaveGeneratorType::FloatingIslands; + default: return ECaveGeneratorType::TunnelNetwork; + } + } +} + +FVoxelStrateComposerCandidate VF_RollStrateCandidate( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index, bool bRollStructure) +{ + FVoxelStrateComposerCandidate Candidate; + Candidate.Seed = Seed; + Candidate.Index = Index; + Candidate.bStructureRoll = bRollStructure; + + if (!bRollStructure) + { + // This is the exact Tier 4a call used by the parameter-roll measurement test. + Candidate.ParameterRoll = VF_RollStrateParamsDetailed(Corpus, Seed, Index); + if (!Candidate.ParameterRoll.bValid) + { + Candidate.FailureReason = Candidate.ParameterRoll.FailureReason; + return Candidate; + } + + Candidate.Archetype = Candidate.ParameterRoll.Archetype; + Candidate.ArchetypeParams = Candidate.ParameterRoll.ArchetypeParams; + Candidate.Regions = VF_RollStrateRegionManifest(Corpus, Seed, Index, false); + if (!Candidate.Regions.IsValid()) + { + Candidate.FailureReason = Candidate.Regions.FailureReason.IsEmpty() + ? TEXT("lateral region roll failed") : Candidate.Regions.FailureReason; + return Candidate; + } + Candidate.bValid = true; + return Candidate; + } + + // This is the exact Tier 4b structure call plus the six independent native-family rolls used + // to materialise every structure candidate in the offline test. + Candidate.Recipe = VF_RollStrateStructure(Seed, Index); + Candidate.Archetype = VF_ComposerArchetypeForRecipe(Candidate.Recipe); + Candidate.StructureBlockRolls.Reserve(UE_ARRAY_COUNT(GComposerStructureBlocks)); + + bool bAllBlocksValid = true; + for (const FComposerStructureBlockSpec& Spec : GComposerStructureBlocks) + { + const FVoxelStrateRollInfo Roll = VF_RollStrateParamsDetailedForArchetype( + Corpus, Spec.Archetype, Seed ^ static_cast(Spec.SeedSalt), Index); + Candidate.StructureBlockRolls.Add(Roll); + if (!Roll.bValid) + { + bAllBlocksValid = false; + if (Candidate.FailureReason.IsEmpty()) + { + Candidate.FailureReason = FString::Printf( + TEXT("parameter block %d (%s) failed: %s"), + static_cast(Spec.Block), + VF_GetStrateArchetypeName(Spec.Archetype), + *Roll.FailureReason); + } + continue; + } + VF_CopyComposerBlock(Candidate.ArchetypeParams, Roll, Spec.Archetype); + } + + Candidate.Regions = VF_RollStrateRegionManifest(Corpus, Seed, Index, true); + if (!Candidate.Regions.IsValid() && Candidate.FailureReason.IsEmpty()) + { + Candidate.FailureReason = Candidate.Regions.FailureReason.IsEmpty() + ? TEXT("lateral region roll failed") : Candidate.Regions.FailureReason; + } + Candidate.bValid = bAllBlocksValid && Candidate.Regions.IsValid(); + return Candidate; +} + +FVoxelStratePromotableRecord VF_MakeStratePromotableRecord( + const FVoxelStrateComposerCandidate& Candidate, + const FVoxelStrateMetrics& Metrics, + int32 Season, + uint32 InputCorpusHash, + bool bPassedNonVacuous, + bool bPassedLargestComponent, + bool bPassedPrimordialLaw) +{ + FVoxelStratePromotableRecord Record; + Record.Season = Season; + Record.Seed = Candidate.Seed; + Record.CandidateIndex = Candidate.Index; + Record.InputCorpusHash = InputCorpusHash; + Record.Archetype = Candidate.Archetype; + Record.Params = Candidate.ArchetypeParams; + Record.Recipe = Candidate.Recipe; + Record.MeasuredMetrics = VF_SummarizeStrateMetrics(Metrics); + Record.bPassedNonVacuous = bPassedNonVacuous; + Record.bPassedLargestComponent = bPassedLargestComponent; + Record.bPassedPrimordialLaw = bPassedPrimordialLaw; + Record.RecordId = FString::Printf( + TEXT("season_%04d_seed_%d_candidate_%04d_corpus_%08x_recipe_%08x"), + Season, Candidate.Seed, Candidate.Index, InputCorpusHash, + VF_HashStrateStructureRecipe(Candidate.Recipe)); + return Record; +} +#endif + +FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe) +{ + FString Modifiers; + for (int32 Index = 0; Index < Recipe.Modifiers.Num(); ++Index) + { + if (Index > 0) { Modifiers += TEXT(","); } + Modifiers += VF_FormatRecipeEntry(Recipe.Modifiers[Index]); + } + return FString::Printf(TEXT("%s|r=%s|s=%s|x=%s|m=[%s]|posts=%s"), + Recipe.RootPolarity == EVoxelStrateRootPolarity::VoidFill ? TEXT("VF") : TEXT("RC"), + *VF_FormatRecipeEntry(Recipe.Root), + *VF_FormatRecipeEntry(Recipe.ShapeSource), + *VF_FormatRecipeEntry(Recipe.Conversion), + *Modifiers, + VF_RecipeBlockCode(Recipe.StructuralParamBlock)); +} + +uint32 VF_HashStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe) +{ + uint32 Hash = 2166136261u; + auto AddByte = [&Hash](uint8 Value) + { + Hash ^= static_cast(Value); + Hash *= 16777619u; + }; + auto AddEntry = [&AddByte](const FVoxelOpRecipeEntry& Entry) + { + AddByte(static_cast(Entry.OpClass)); + AddByte(static_cast(Entry.ParamBlock)); + }; + + AddByte(static_cast(Recipe.RootPolarity)); + AddEntry(Recipe.Root); + AddEntry(Recipe.ShapeSource); + AddEntry(Recipe.Conversion); + AddByte(static_cast(Recipe.Modifiers.Num())); + for (const FVoxelOpRecipeEntry& Entry : Recipe.Modifiers) { AddEntry(Entry); } + AddByte(static_cast(Recipe.StructuralParamBlock)); + return Hash; +} + +bool VF_AreStrateStructureRecipesIdentical(const FVoxelOpStackRecipe& A, + const FVoxelOpStackRecipe& B) +{ + if (A.RootPolarity != B.RootPolarity + || A.Root.OpClass != B.Root.OpClass || A.Root.ParamBlock != B.Root.ParamBlock + || A.ShapeSource.OpClass != B.ShapeSource.OpClass + || A.ShapeSource.ParamBlock != B.ShapeSource.ParamBlock + || A.Conversion.OpClass != B.Conversion.OpClass + || A.Conversion.ParamBlock != B.Conversion.ParamBlock + || A.StructuralParamBlock != B.StructuralParamBlock + || A.Modifiers.Num() != B.Modifiers.Num()) + { + return false; + } + for (int32 Index = 0; Index < A.Modifiers.Num(); ++Index) + { + if (A.Modifiers[Index].OpClass != B.Modifiers[Index].OpClass + || A.Modifiers[Index].ParamBlock != B.Modifiers[Index].ParamBlock) + { + return false; + } + } + return true; +} +#endif // WITH_EDITOR — no roller/corpus entry point is compiled into Shipping diff --git a/Standalone/Source/VoxelForge/Private/VoxelStrateDefinition.cpp b/Standalone/Source/VoxelForge/Private/VoxelStrateDefinition.cpp new file mode 100644 index 0000000..e8ee7fe --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelStrateDefinition.cpp @@ -0,0 +1,79 @@ +// Serialized strate-definition migrations. + +#include "VoxelStrateDefinition.h" + +#include "Serialization/CustomVersion.h" +#include "VoxelPassageGeometry.h" + +namespace +{ + // The field name is retained for package compatibility, but version 1 changes its meaning + // from a raw tangent/gradient to authored degrees. This GUID is local to the plugin. + const FGuid GVoxelForgeStrateSerializationVersion( + 0xA76D5E31, 0x4B2C4F9A, 0xB8C1D204, 0x6E9F7738); + constexpr int32 GVoxelForgeStrateGentleSlopeDegreesVersion = 1; + FCustomVersionRegistration GVoxelForgeStrateSerializationRegistration( + GVoxelForgeStrateSerializationVersion, + GVoxelForgeStrateGentleSlopeDegreesVersion, + TEXT("VoxelForgeStrateSerialization")); + + float LegacyGradientToDegrees(float LegacyGradient) + { + if (!VoxelMath::IsFinite(LegacyGradient) || LegacyGradient <= 0.0f) + { + return 0.0f; + } + if (LegacyGradient >= 4096.0f) + { + return VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees; + } + + // Invert the old gradient without atan/tan. The same deterministic sine/cosine helper + // used by generation makes this migration independent of the host CRT. + float LowDegrees = 0.0f; + float HighDegrees = VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees; + for (int32 Iteration = 0; Iteration < 32; ++Iteration) + { + const float MidDegrees = (LowDegrees + HighDegrees) * 0.5f; + float SinValue = 0.0f; + float CosValue = 0.0f; + VoxelMath::DetSinCos( + SinValue, CosValue, MidDegrees * (PI / 180.0f)); + const float MidGradient = FMath::Min( + FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f), + 4096.0f); + if (MidGradient < LegacyGradient) + { + LowDegrees = MidDegrees; + } + else + { + HighDegrees = MidDegrees; + } + } + return FMath::Clamp( + (LowDegrees + HighDegrees) * 0.5f, + 0.0f, + VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees); + } +} + +void UVoxelStrateDefinition::PostLoad() +{ + Super::PostLoad(); + + // The field remains reflected solely so old packages deserialize without shifting the + // following members. Tunnel geometry owns the floor now, so every serialized legacy value + // is normalized to the ignored compatibility default before any runtime copy is made. + GenerationParams.bTunnelFloorEnabled = true; + + if (GetLinkerCustomVersion(GVoxelForgeStrateSerializationVersion) + >= GVoxelForgeStrateGentleSlopeDegreesVersion) + { + return; + } + + const float LegacyGradient = GenerationParams.TunnelFloorGentleSlopeThreshold; + GenerationParams.TunnelFloorGentleSlopeThreshold = + LegacyGradientToDegrees(LegacyGradient); +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp new file mode 100644 index 0000000..e3bc290 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -0,0 +1,6850 @@ +// VoxelStrateManager.cpp +// Runtime strate layout generation and queries. + +#include "VoxelStrateManager.h" +#include "VoxelSettings.h" +#include "VoxelSeasonAsset.h" +#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord +#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash +#include "VoxelDensityPrimitives.h" // Shared passage carve polarity/strength +#include "VoxelDensityAblation.h" // Fingerprinted development-only stage overrides +#include "VoxelDensityProfile.h" // Opt-in targeted per-voxel attribution +#include "VoxelStartupTrace.h" +#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo +#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) +#include "HAL/IConsoleManager.h" +#include "Misc/CommandLine.h" +#include "Misc/Crc.h" +#include "Misc/Parse.h" +#include "UObject/UObjectGlobals.h" + +#include + +namespace +{ + std::atomic 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 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 Nearby; + TArray 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& 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& 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(reinterpret_cast(Owner)); + Hash ^= static_cast(reinterpret_cast(Owner) >> 32); + const auto Combine = [&Hash](uint32 Value) + { + Hash ^= Value + 0x9E3779B9u + (Hash << 6) + (Hash >> 2); + }; + Combine(static_cast(LifetimeId)); + Combine(static_cast(LifetimeId >> 32)); + Combine(Version); + Combine(static_cast(PassageIndex)); + Combine(static_cast(X)); + Combine(static_cast(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(FloorSeed) * 0.00001f; + float Noise = FMath::PerlinNoise2D( + FVector2D( + static_cast(X) * Frequency + SF, + static_cast(Y) * Frequency + SF * 1.7f)) * 0.65f; + Noise += FMath::PerlinNoise2D( + FVector2D( + static_cast(X) * Frequency * 2.3f + SF * 3.1f, + static_cast(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(MIN_int32) && X <= static_cast(MAX_int32) + && Y >= static_cast(MIN_int32) && Y <= static_cast(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(B.X - A.X), + static_cast(B.Y - A.Y)).Size(); + const float EnvelopeDerivativeBound = LandingEnvelopeCount > 0 + ? (1.5f * static_cast(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(static_cast(Position.X)); + const double QueryY = static_cast(static_cast(Position.Y)); + const double AXY_X = static_cast(static_cast(A.X)); + const double AXY_Y = static_cast(static_cast(A.Y)); + const double DeltaX = static_cast(static_cast(B.X - A.X)); + const double DeltaY = static_cast(static_cast(B.Y - A.Y)); + const float HorizontalLengthSquared = static_cast( + 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( + 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(static_cast(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(static_cast(Position.Z)) - SpatialZ; + OutDistanceSquared = static_cast( + 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(Position.X), static_cast(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(B.X - A.X), + static_cast(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& Nearby = VF_GetNearbyPassages(Manager, ChunkCoord); + const TArray& 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& Nearby = VF_GetNearbyPassages(Manager, ChunkCoord); + const TArray& 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 PreviousLayout = StrateLayout; + const TArray PreviousSeasonStrates = SeasonStrates; + const FString PreviousSeasonContentHash = ActiveSeasonContentHash; +#if WITH_EDITOR + const TMap> 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; + TArray ShuffledPool; + if (!bUseSeason) + { + for (const TSoftObjectPtr& 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 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 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(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(this, NAME_None, RF_Transient); + Slot.Definition->StrateName = FText::FromString( + FString::Printf(TEXT("Season %d Strate %d"), SeasonManifest.Season, i)); + Slot.Definition->TransitionType = EVoxelStrateTransition::Hard; + // Passage configuration is not part of schema v2. Inventing the UObject default + // here would add an unreviewed tunnel to every generated boundary and make the + // runtime field differ from the field that passed composition. The origin spine + // remains the guaranteed connection until a later schema explicitly stores these. + Slot.Definition->PassageConfig.Connections = 0; + } + + Slot.Definition->GeneratorType = SeasonStrate.Archetype; + Slot.Definition->StrateHeightInChunks = SeasonStrate.HeightInChunks; + Slot.Definition->GenerationParams = SeasonStrate.Params.TunnelNetworkParams; + Slot.Definition->SlabParams = SeasonStrate.Params.SlabParams; + Slot.Definition->MazeParams = SeasonStrate.Params.MazeParams; + Slot.Definition->SurfaceParams = SeasonStrate.Params.SurfaceParams; + Slot.Definition->VerticalShaftParams = SeasonStrate.Params.VerticalShaftParams; + Slot.Definition->FloatingIslandParams = SeasonStrate.Params.FloatingIslandParams; + + Slot.HeightInChunks = SeasonStrate.HeightInChunks; + Slot.TopChunkZ = SeasonStrate.TopWorldZ / CHUNK_SIZE - 1; + Slot.BottomChunkZ = SeasonStrate.BottomWorldZ / CHUNK_SIZE; + if (SeasonStrate.TopWorldZ % CHUNK_SIZE != 0 + || SeasonStrate.BottomWorldZ % CHUNK_SIZE != 0 + || Slot.TopChunkZ != CurrentTopZ + || Slot.BottomChunkZ != CurrentTopZ - (Slot.HeightInChunks - 1)) + { + UE_LOG(LogTemp, Error, + TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i); + RestorePreviousLayout(); + return false; + } + SeasonStrates.Add(SeasonStrate); + } + else + { + // Pick definition: fixed or from pool + UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i); + if (FixedDef && *FixedDef) + { + Slot.Definition = *FixedDef; + } + else if (ShuffledPool.Num() > 0) + { + // Cycle through the pool (wraps around if more strates than pool entries) + Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()].Value; + PoolCursor++; + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i); + continue; + } + + // Compute Z range from definition's height + Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks; + Slot.TopChunkZ = CurrentTopZ; + Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1); + } + + // Move the cursor down for the next strate, leaving a solid-bedrock gap of + // InterStrateGapChunks chunks between this strate and the next. + CurrentTopZ = Slot.BottomChunkZ - 1 - LayoutGapChunks; + + StrateLayout.Add(Slot); + + if (VoxelForgeStartupTrace::IsActive()) + { + FString DefinitionPath = Slot.Definition ? Slot.Definition->GetPathName() : FString(); + DefinitionPath.ReplaceInline(TEXT("\\"), TEXT("\\\\")); + DefinitionPath.ReplaceInline(TEXT("\""), TEXT("\\\"")); + const UEnum* ArchetypeEnum = StaticEnum(); + const FString ArchetypeName = ArchetypeEnum + ? ArchetypeEnum->GetNameStringByValue(static_cast(Slot.Definition->GeneratorType)) + : FString::Printf(TEXT("Value_%d"), static_cast(Slot.Definition->GeneratorType)); + VoxelForgeStartupTrace::RecordEvent(TEXT("strate_slot"), FString::Printf( + TEXT("\"index\":%d,\"definition\":\"%s\",\"archetype\":\"%s\",\"top_chunk_z\":%d," + "\"bottom_chunk_z\":%d,\"height_chunks\":%d"), + Slot.StrateIndex, *DefinitionPath, *ArchetypeName, + Slot.TopChunkZ, Slot.BottomChunkZ, Slot.HeightInChunks)); + } + + UE_LOG(LogTemp, Log, TEXT("[StrateManager] Strate %d: '%s' | Z chunks [%d to %d] | %d chunks tall"), + i, + *Slot.Definition->StrateName.ToString(), + Slot.TopChunkZ, + Slot.BottomChunkZ, + Slot.HeightInChunks); + } + + CachedSeed = WorldSeed; + bOpenSurfaceEntry = Settings->bOpenSurfaceEntry; + OriginSpineRadius = bUseSeason + ? SeasonManifest.OriginSpineRadius : Settings->OriginSpineRadius; + InterStrateGapChunks = LayoutGapChunks; + if (bUseSeason) + { + ActiveSeasonContentHash = SeasonManifest.ContentHash; + } + // Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig). + + //========================================================================= + // STEP 3: Load terrain operation assets + //========================================================================= + // Each strate definition references terrain ops as soft pointers. + // We load them synchronously here so they're available during generation. + // Without this, BuildParamsFromDefinition's Entry.Operation.Get() would + // return null if the assets haven't been loaded yet. + for (const FStrateSlot& Slot : StrateLayout) + { + if (!Slot.Definition) continue; + if (bUseSeason && SeasonStrates[Slot.StrateIndex].bUsesRecipe) continue; + + for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations) + { + if (!Entry.Operation.IsNull()) + { + Entry.Operation.LoadSynchronous(); + } + } + } + + UE_LOG(LogTemp, Log, TEXT("[StrateManager] Initialized %d strates (seed=%d)"), + StrateLayout.Num(), WorldSeed); + + VoxelForgeStartupTrace::RecordEvent(TEXT("strate_layout_ready"), FString::Printf( + TEXT("\"strates\":%d,\"seed\":%d,\"inter_strate_gap_chunks\":%d,\"season\":%d"), + StrateLayout.Num(), WorldSeed, InterStrateGapChunks, bUseSeason ? 1 : 0)); + + // Generate passages between consecutive strates + GeneratePassages(); + return bUseSeason ? IsUsingSeason() : true; +} + +#if WITH_EDITOR +const FVoxelStrateComposerSlotOverride* UVoxelStrateManager::FindComposerOverride( + int32 StrateIndex) const +{ + const TSharedPtr* 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 Override = + MakeShared(); + 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(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 NoQueryArchetypes; + + const auto ArchetypeName = [](ECaveGeneratorType Archetype) + { + if (const UEnum* ArchetypeEnum = StaticEnum()) + { + return ArchetypeEnum->GetNameStringByValue(static_cast(Archetype)); + } + return FString::Printf(TEXT("Value_%d"), static_cast(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(VoxelCaveMorphology::MakeStrateSeed( + static_cast(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(VoxelCaveMorphology::MakeStrateSeed( + static_cast(CachedSeed), Lower.StrateIndex)) + : CachedSeed; + + const float MaxLateralSnap = MaxLateralSnapFor(*LowerDef); + bAimedLowerAtPlayerFitPoint = VF_SuggestLandingPoint( + LowerDef->GeneratorType, + LowerDef->GenerationParams, + LowerDef->SlabParams, + LowerDef->MazeParams, + LowerDef->VerticalShaftParams, + LowerDef->FloatingIslandParams, + LowerQuerySeed, + LowerTopZ, + (float)(Lower.BottomChunkZ) * CHUNK_SIZE, + PX, + PY, + MaxLateralSnap, + SuggestedLowerPoint, + CachedSeed); + + if (bAimedLowerAtPlayerFitPoint) + { + ++NumAimedAtLowerPlayerFit; + } + else + { + NoQueryArchetypes.Add(ArchetypeName(LowerDef->GeneratorType)); + } + } + + // LENGTH: reach into each strate, capped to the interior. + const float UpperReach = FMath::Min( + PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltUpperReach), UpperMax); + const float LowerReach = FMath::Min( + PassageRandomRange(Cfg.ReachMin, Cfg.ReachMax, PassageSaltLowerReach), LowerMax); + float TopZ = UpperBottomZ + UpperReach; + float BottomZ = LowerTopZ - LowerReach; + float UpperX = PX; + float UpperY = PY; + float LowerX = PX; + float LowerY = PY; + Passage.RequestedUpperPoint = FVector(PX, PY, TopZ); + Passage.RequestedLowerPoint = FVector(PX, PY, BottomZ); + + if (bAimedUpperAtPlayerFitPoint) + { + const float UpperSealThickness = BoundarySealThicknessFor(*UpperDef); + const float InnerBottomZ = UpperBottomZ + UpperSealThickness; + const float InnerTopZ = UpperTopZ - UpperSealThickness; + if (VoxelMath::IsFinite(SuggestedUpperPoint.X) + && VoxelMath::IsFinite(SuggestedUpperPoint.Y) + && VoxelMath::IsFinite(SuggestedUpperPoint.Z) + && VoxelMath::IsFinite(InnerBottomZ) + && VoxelMath::IsFinite(InnerTopZ) + && InnerBottomZ < InnerTopZ) + { + // VF_SuggestLandingPoint already guarantees a strict interior answer. Keep a + // tiny margin in the final clamp so a future source query cannot land on a + // seal boundary through rounding. + UpperX = SuggestedUpperPoint.X; + UpperY = SuggestedUpperPoint.Y; + const float StrictMargin = FMath::Min( + KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f); + TopZ = FMath::Clamp( + SuggestedUpperPoint.Z, + InnerBottomZ + StrictMargin, + InnerTopZ - StrictMargin); + } + } + + if (bAimedLowerAtPlayerFitPoint) + { + const float LowerSealThickness = BoundarySealThicknessFor(*LowerDef); + const float LowerBottomZ = (float)(Lower.BottomChunkZ) * CHUNK_SIZE; + const float InnerBottomZ = LowerBottomZ + LowerSealThickness; + const float InnerTopZ = LowerTopZ - LowerSealThickness; + if (VoxelMath::IsFinite(SuggestedLowerPoint.X) + && VoxelMath::IsFinite(SuggestedLowerPoint.Y) + && VoxelMath::IsFinite(SuggestedLowerPoint.Z) + && VoxelMath::IsFinite(InnerBottomZ) + && VoxelMath::IsFinite(InnerTopZ) + && InnerBottomZ < InnerTopZ) + { + // VF_SuggestLandingPoint already guarantees a strict interior answer. Keep a + // tiny margin in the final clamp so a future source query cannot land on a + // seal boundary through rounding. + LowerX = SuggestedLowerPoint.X; + LowerY = SuggestedLowerPoint.Y; + const float StrictMargin = FMath::Min( + KINDA_SMALL_NUMBER, (InnerTopZ - InnerBottomZ) * 0.25f); + BottomZ = FMath::Clamp( + SuggestedLowerPoint.Z, + InnerBottomZ + StrictMargin, + InnerTopZ - StrictMargin); + } + } + + const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48); + Passage.ControlPoints.Reset(); + Passage.ControlRadii.Reset(); + Passage.ControlPoints.Reserve(Segments + 1); + Passage.ControlRadii.Reserve(Segments + 1); + + // WIDTH profile: mouth radius at the ends, mid radius in the centre (taper/bulge). + auto RadiusAt = [&](float t) { return FMath::Lerp(Cfg.MouthRadius, Cfg.MidRadius, VoxelMath::DetSin(t * PI)); }; + + // Per-passage shape seeds. + const float WormFreq = PassageRandomRange(0.8f, 1.8f, PassageSaltWormFreq); // (vertical wobble only) + const float NSeedX = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseX); + const float NSeedY = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseY); + const float NSeedZ = PassageRandomRange(0.0f, 500.0f, PassageSaltNoiseZ); + const float PhaseA = PassageRandom01(PassageSaltPhase) * (2.0f * PI); + // Base fBM frequency for the worm's wander (octaves add finer detail on top). + const float BendFreq = PassageRandomRange(1.5f, 2.5f, PassageSaltBendFreq); + + for (int32 s = 0; s <= Segments; s++) + { + const float T = (float)s / (float)Segments; + float Z = FMath::Lerp(TopZ, BottomZ, T); + const float Env = VoxelMath::DetSin(T * PI); // 0 at both ends → mouths stay anchored + float OX = 0.0f, OY = 0.0f; + + switch (Cfg.Style) + { + case EVoxelPassageStyle::Straight: + // No authored lateral offset. A snapped lower mouth can still make this + // segment slanted, so the endpoint interpolation below remains intentional. + break; + + case EVoxelPassageStyle::Spiral: + { + const float Ang = PhaseA + T * Cfg.SpiralTurns * 2.0f * PI; + float SinAng = 0.0f, CosAng = 0.0f; + VoxelMath::DetSinCos(SinAng, CosAng, Ang); + OX = CosAng * Cfg.SpiralRadius * Env; + OY = SinAng * Cfg.SpiralRadius * Env; + break; + } + + case EVoxelPassageStyle::Cascading: + { + // Switchback staircase: each tread offsets in a new deterministic direction. + const int32 Steps = FMath::Clamp(Cfg.CascadeSteps, 1, 16); + const int32 Idx = FMath::Min((int32)(T * Steps), Steps - 1); + const float SA = PhaseA + (float)Idx * 2.39996f; // golden-angle spread + float SinSA = 0.0f, CosSA = 0.0f; + VoxelMath::DetSinCos(SinSA, CosSA, SA); + OX = CosSA * Cfg.CascadeLedge * Env; + OY = SinSA * Cfg.CascadeLedge * Env; + break; + } + + case EVoxelPassageStyle::Worm: + default: + { + // SQUIRM: displace the descent independently on X and Y with multi-octave + // fBM (different seeds → uncorrelated). Independent fBM per axis is a true + // 2D organic wander — it curls and meanders "here and there" rather than + // oscillating along one line (zig-zag) or orbiting the axis (spiral). + // Flat-top envelope keeps full motion along the length but anchors the mouths. + const float WormEnv = FMath::Clamp(VoxelMath::DetSin(T * PI) * 3.0f, 0.0f, 1.0f); + OX = PassageFBM(T * BendFreq, NSeedX) * VOXEL_NOISE_SCALE * Cfg.Wander * WormEnv; + OY = PassageFBM(T * BendFreq, NSeedY + 53.0f) * VOXEL_NOISE_SCALE * Cfg.Wander * WormEnv; + break; + } + } + + // Vertical wobble (all styles): dips/rises along the descent, anchored at ends. + if (Cfg.VerticalWobble > 0.0f) + { + const float NZ = FMath::PerlinNoise3D(FVector(T * WormFreq * 1.3f + NSeedZ, NSeedZ * 0.5f, 27.0f)); + Z += NZ * VOXEL_NOISE_SCALE * Cfg.VerticalWobble * Env; + } + + const float BaseX = FMath::Lerp(UpperX, LowerX, T); + const float BaseY = FMath::Lerp(UpperY, LowerY, T); + FVector ControlPoint(BaseX + OX, BaseY + OY, Z); + // The style envelope is mathematically zero at a mouth, but evaluating sin(PI) + // leaves a tiny float residue. Pin both endpoints so the full query result is the + // actual aimed mouth, including any lateral snap, and never merely an approximation. + if (s == 0) + { + ControlPoint = FVector(UpperX, UpperY, TopZ); + } + else if (s == Segments) + { + ControlPoint = FVector(LowerX, LowerY, BottomZ); + } + Passage.ControlPoints.Add(ControlPoint); + Passage.ControlRadii.Add(RadiusAt(T)); + } + + // The old control-point endpoints were also the player's standing points. That made + // a sloped/vertical tube open directly under the capsule. Keep the queried points as + // explicit standing anchors, and move only the tube's first/last point to a doorway + // tangent to the landing floor. The endpoint-to-anchor distinction is the geometry + // that turns a mouth into a place. + auto DoorDirectionFrom = [](const FVector& Delta, float Angle) -> FVector + { + FVector Direction(Delta.X, Delta.Y, 0.0f); + if (!Direction.Normalize()) + { + float SinAngle = 0.0f, CosAngle = 0.0f; + VoxelMath::DetSinCos(SinAngle, CosAngle, Angle); + Direction = FVector(CosAngle, SinAngle, 0.0f); + if (!Direction.Normalize()) + { + Direction = FVector(1.0f, 0.0f, 0.0f); + } + } + return Direction; + }; + + const FVector UpperDoorDirection = DoorDirectionFrom( + Passage.ControlPoints.Num() > 1 + ? Passage.ControlPoints[1] - Passage.ControlPoints[0] + : FVector::ZeroVector, + PassageRandom01(PassageSaltUpperDoor) * 2.0f * PI); + const FVector LowerDoorDirection = DoorDirectionFrom( + Passage.ControlPoints.Num() > 1 + ? Passage.ControlPoints[Passage.ControlPoints.Num() - 2] + - Passage.ControlPoints.Last() + : FVector::ZeroVector, + PassageRandom01(PassageSaltLowerDoor) * 2.0f * PI); + + // A source-fit answer is the landing itself. Keep it local to the requested room: the + // (0,0) landing is reserved for the future straight shaft, so no radial connector is + // synthesized here. + Passage.UpperLanding = VF_BuildPassageLanding( + FVector(UpperX, UpperY, TopZ), + Cfg.MouthRadius, + UpperDoorDirection, + UpperTopZ, + UpperBottomZ, + UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f, + bAimedUpperAtPlayerFitPoint); + Passage.LowerLanding = VF_BuildPassageLanding( + FVector(LowerX, LowerY, BottomZ), + Cfg.MouthRadius, + LowerDoorDirection, + LowerTopZ, + (float)(Lower.BottomChunkZ) * CHUNK_SIZE, + LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f, + bAimedLowerAtPlayerFitPoint); + Passage.UpperPoint = Passage.UpperLanding.StandingPoint; + Passage.LowerPoint = Passage.LowerLanding.StandingPoint; + + if (Cfg.Style == EVoxelPassageStyle::Straight + && Passage.UpperLanding.HalfWidth > 0.0f + && Passage.LowerLanding.HalfWidth > 0.0f) + { + // BASE CONNECTION: a walkable two-leg switchback. A direct line between the + // mouths would often be nearly vertical because both requests are placed at the + // same XY radius. Put the turn on the perpendicular bisector and give each leg + // at least half of the horizontal run required by the named 15-degree law. + // This is deliberately a construction rule, not a seed-dependent observation. + const float SwitchbackAngle = + PassageRandom01(PassageSaltUpperDoor) * 2.0f * PI; + float SinSwitchback = 0.0f, CosSwitchback = 0.0f; + VoxelMath::DetSinCos(SinSwitchback, CosSwitchback, SwitchbackAngle); + FVector2D SwitchbackDirection(CosSwitchback, SinSwitchback); + if (!SwitchbackDirection.Normalize()) + { + SwitchbackDirection = FVector2D(1.0f, 0.0f); + } + + const FVector DoorDirection( + SwitchbackDirection.X, SwitchbackDirection.Y, 0.0f); + Passage.UpperLanding.DoorDirection = DoorDirection; + // Keep both door approaches on the same outward side so each sloped leg reaches + // its own apron before entering the room. Separate their turn-side floor branches + // below so the two projections do not retrace one XY line at different heights. + const FVector TunnelLowerDoorDirection = DoorDirection; + Passage.LowerLanding.DoorDirection = TunnelLowerDoorDirection; + const float UpperDoorOffset = FMath::Max( + Passage.UpperLanding.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f); + const float LowerDoorOffset = FMath::Max( + Passage.LowerLanding.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f); + const float SafeMouthRadius = FMath::Max( + FMath::Abs(Cfg.MouthRadius), + VoxelPassageGeometry::MetersToVoxels(0.25f)); + Passage.UpperLanding.DoorPoint = + Passage.UpperLanding.StandingPoint + DoorDirection * UpperDoorOffset; + Passage.UpperLanding.DoorPoint.Z = + Passage.UpperLanding.FloorZ + SafeMouthRadius; + Passage.LowerLanding.DoorPoint = + Passage.LowerLanding.StandingPoint + + TunnelLowerDoorDirection * LowerDoorOffset; + Passage.LowerLanding.DoorPoint.Z = + Passage.LowerLanding.FloorZ + SafeMouthRadius; + + const FVector UpperDoor = Passage.UpperLanding.DoorPoint; + const FVector LowerDoor = Passage.LowerLanding.DoorPoint; + // Keep a short level apron beyond each room floor. The door anchor is one voxel + // inside the room's support square; a diagonal door direction can therefore need + // several voxels before it leaves that square and its four-voxel SDF blend. + // The level apron must clear the room half-width, the tube radius, and the + // smooth carve band before the sloped leg begins. The final six-voxel transition + // is measured from that clear point back toward the turn, so include it too. For + // the default 8-voxel half-width / 6-voxel mouth this is 17 voxels (4.25 m), not + // the old 6-voxel minimum that left the sloped capsule inside the landing room. + const float LandingApron = FMath::Max( + VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, + VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels + + SafeMouthRadius + + VoxelPassageGeometry::LandingCarveBlendVoxels + + 1.0f); + const FVector UpperApronEnd = UpperDoor + + DoorDirection * LandingApron; + const FVector LowerApronBegin = LowerDoor + + TunnelLowerDoorDirection * LandingApron; + const float VerticalDrop = FMath::Abs(UpperDoor.Z - LowerDoor.Z); + const float UpperFloorZ = VoxelPassageGeometry::TunnelFloorZ( + UpperDoor, Cfg.MouthRadius); + const float LowerFloorZ = VoxelPassageGeometry::TunnelFloorZ( + LowerDoor, Cfg.MouthRadius); + const float MidFloorZ = 0.5f * (UpperFloorZ + LowerFloorZ); + // Each ramp must leave its own landing on the outward side. The two doors face + // that same direction; orient the shared level turn along it, then split the lower + // floor branch laterally so the ramp projections remain unambiguous. + FVector2D UpperOutward( + UpperApronEnd.X - Passage.UpperLanding.StandingPoint.X, + UpperApronEnd.Y - Passage.UpperLanding.StandingPoint.Y); + FVector2D LowerOutward( + LowerApronBegin.X - Passage.LowerLanding.StandingPoint.X, + LowerApronBegin.Y - Passage.LowerLanding.StandingPoint.Y); + UpperOutward.Normalize(); + LowerOutward.Normalize(); + const float UpperFloorDrop = UpperFloorZ - MidFloorZ; + const float LowerFloorDrop = MidFloorZ - LowerFloorZ; + const float RequiredHorizontalRun = + VoxelPassageGeometry::RequiredHorizontalRunForFloorDrop( + UpperFloorDrop, LowerFloorDrop); + // Move the level turn farther than half the required run. Each slope then loses + // only the named transition length to its level segment and still retains the + // full horizontal run required by the 15-degree floor law. + const float TurnTransition = + VoxelPassageGeometry::WalkableTunnelTurnTransitionVoxels; + const float TurnOffset = + 0.5f * RequiredHorizontalRun + TurnTransition; + FVector2D TurnDirection( + UpperOutward.X + LowerOutward.X, + UpperOutward.Y + LowerOutward.Y); + if (!TurnDirection.Normalize()) + { + TurnDirection = UpperOutward; + } + const FVector2D ApronChord( + LowerApronBegin.X - UpperApronEnd.X, + LowerApronBegin.Y - UpperApronEnd.Y); + const float LowerApronAlong = FVector2D::DotProduct( + ApronChord, TurnDirection); + const float SharedTurnOffset = TurnOffset + + FMath::Max(LowerApronAlong, 0.0f); + const FVector2D SharedTurnMid( + UpperApronEnd.X + TurnDirection.X * SharedTurnOffset, + UpperApronEnd.Y + TurnDirection.Y * SharedTurnOffset); + const float TurnLegSeparation = + VoxelPassageGeometry::WalkableTunnelTurnLegSeparation( + Cfg.MidRadius, Cfg.MouthRadius); + FVector2D TurnLegSideDirection(-TurnDirection.Y, TurnDirection.X); + if (FVector2D::DotProduct(ApronChord, TurnLegSideDirection) < 0.0f) + { + TurnLegSideDirection *= -1.0f; + } + const FVector2D UpperTurnMid = SharedTurnMid; + const FVector2D LowerTurnMid( + SharedTurnMid.X + TurnLegSideDirection.X * TurnLegSeparation, + SharedTurnMid.Y + TurnLegSideDirection.Y * TurnLegSeparation); + FVector2D UpperSlopeDirection( + UpperTurnMid.X - UpperApronEnd.X, + UpperTurnMid.Y - UpperApronEnd.Y); + if (!UpperSlopeDirection.Normalize()) + { + UpperSlopeDirection = FVector2D( + DoorDirection.X, DoorDirection.Y); + } + FVector2D LowerSlopeDirection( + LowerApronBegin.X - LowerTurnMid.X, + LowerApronBegin.Y - LowerTurnMid.Y); + if (!LowerSlopeDirection.Normalize()) + { + LowerSlopeDirection = -UpperSlopeDirection; + } + const FVector2D UpperSlopeStartXY( + UpperApronEnd.X + UpperSlopeDirection.X * TurnTransition, + UpperApronEnd.Y + UpperSlopeDirection.Y * TurnTransition); + const FVector2D LowerSlopeEndXY( + LowerApronBegin.X - LowerSlopeDirection.X * TurnTransition, + LowerApronBegin.Y - LowerSlopeDirection.Y * TurnTransition); + const FVector UpperApronEndPoint( + UpperApronEnd.X, UpperApronEnd.Y, + UpperFloorZ + FMath::Abs(Cfg.MouthRadius)); + const FVector UpperSlopeStart( + UpperSlopeStartXY.X, UpperSlopeStartXY.Y, + UpperFloorZ + FMath::Abs(Cfg.MouthRadius)); + const FVector MidPoint( + UpperTurnMid.X, UpperTurnMid.Y, + MidFloorZ + FMath::Abs(Cfg.MidRadius)); + const FVector LowerTurnPoint( + LowerTurnMid.X, LowerTurnMid.Y, + MidFloorZ + FMath::Abs(Cfg.MidRadius)); + const FVector LowerSlopeEnd( + LowerSlopeEndXY.X, LowerSlopeEndXY.Y, + LowerFloorZ + FMath::Abs(Cfg.MouthRadius)); + const FVector LowerApronBeginPoint( + LowerApronBegin.X, LowerApronBegin.Y, + LowerFloorZ + FMath::Abs(Cfg.MouthRadius)); + + Passage.ControlPoints.Reset(8); + Passage.ControlRadii.Reset(8); + Passage.ControlPoints.Add(UpperDoor); + Passage.ControlPoints.Add(UpperApronEndPoint); + Passage.ControlPoints.Add(UpperSlopeStart); + Passage.ControlPoints.Add(MidPoint); + Passage.ControlPoints.Add(LowerTurnPoint); + Passage.ControlPoints.Add(LowerSlopeEnd); + Passage.ControlPoints.Add(LowerApronBeginPoint); + Passage.ControlPoints.Add(LowerDoor); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MidRadius); + Passage.ControlRadii.Add(Cfg.MidRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + Passage.ControlRadii.Add(Cfg.MouthRadius); + + Passage.PassageType = EVoxelPassageType::SlopedTunnel; + Passage.bWalkableTunnelContract = true; + Passage.TunnelMaxGradientDegrees = + VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees; + Passage.TunnelVerticalDropVoxels = VerticalDrop; + Passage.TunnelRequiredHorizontalRunVoxels = RequiredHorizontalRun; + Passage.TunnelHorizontalPathLengthVoxels = 0.0f; + for (int32 ControlIndex = 0; + ControlIndex + 1 < Passage.ControlPoints.Num(); + ++ControlIndex) + { + Passage.TunnelHorizontalPathLengthVoxels += FVector2D( + Passage.ControlPoints[ControlIndex + 1].X + - Passage.ControlPoints[ControlIndex].X, + Passage.ControlPoints[ControlIndex + 1].Y + - Passage.ControlPoints[ControlIndex].Y).Size(); + } + Passage.TunnelMinimumClearWidthVoxels = 2.0f * FMath::Max( + FMath::Min(FMath::Abs(Cfg.MouthRadius), FMath::Abs(Cfg.MidRadius)), + 0.0f); + Passage.TunnelClearHeightVoxels = Passage.TunnelMinimumClearWidthVoxels; + } + else if (Passage.ControlPoints.Num() > 0) + { + Passage.ControlPoints[0] = Passage.UpperLanding.DoorPoint; + Passage.ControlPoints.Last() = Passage.LowerLanding.DoorPoint; + } + Passage.Radius = 0.0f; + for (const float ControlRadius : Passage.ControlRadii) + { + Passage.Radius = FMath::Max(Passage.Radius, ControlRadius); + } + // Fallback / bounds if an invalid authored width produced no positive profile. + Passage.Radius = FMath::Max(Passage.Radius, FMath::Max(Cfg.MouthRadius, Cfg.MidRadius)); + + // The passage now owns its D-section at construction time. Both landing floors, + // the complete control chain, and the room-derived relief source are known here; + // no voxel query needs to decide whether this particular passage deserves a floor. + VF_AuthorNativePassageFloor( + Passage, UpperDef, LowerDef, + PassageSeed ^ VoxelHash::Cell(i, c, PassageSeed)); + + // Bounding sphere over the tube, both rooms, and both floor slabs (+ widest radius + + // blend) for culling. Under-sizing this sphere would cull a real landing and leave a + // sealed pocket, so the room's full box diagonal is included rather than treating the + // standing anchor as a point. + { + FVector BoundsMin(FLT_MAX, FLT_MAX, FLT_MAX); + FVector BoundsMax(-FLT_MAX, -FLT_MAX, -FLT_MAX); + auto IncludePoint = [&BoundsMin, &BoundsMax](const FVector& Point, float Pad) + { + BoundsMin.X = FMath::Min(BoundsMin.X, Point.X - Pad); + BoundsMin.Y = FMath::Min(BoundsMin.Y, Point.Y - Pad); + BoundsMin.Z = FMath::Min(BoundsMin.Z, Point.Z - Pad); + BoundsMax.X = FMath::Max(BoundsMax.X, Point.X + Pad); + BoundsMax.Y = FMath::Max(BoundsMax.Y, Point.Y + Pad); + BoundsMax.Z = FMath::Max(BoundsMax.Z, Point.Z + Pad); + }; + auto IncludeBounds = [&BoundsMin, &BoundsMax]( + const FVector& Min, const FVector& Max) + { + BoundsMin.X = FMath::Min(BoundsMin.X, Min.X); + BoundsMin.Y = FMath::Min(BoundsMin.Y, Min.Y); + BoundsMin.Z = FMath::Min(BoundsMin.Z, Min.Z); + BoundsMax.X = FMath::Max(BoundsMax.X, Max.X); + BoundsMax.Y = FMath::Max(BoundsMax.Y, Max.Y); + BoundsMax.Z = FMath::Max(BoundsMax.Z, Max.Z); + }; + for (const FVector& CP : Passage.ControlPoints) + { + IncludePoint(CP, Passage.Radius + 4.0f); + } + const auto IncludeLanding = [&IncludePoint](const FVoxelPassageLanding& Landing) + { + const float Height = FMath::Max(Landing.CeilingZ - Landing.FloorZ, 0.0f); + const FVector RoomCenter( + Landing.StandingPoint.X, + Landing.StandingPoint.Y, + (Landing.FloorZ + Landing.CeilingZ) * 0.5f); + const float RoomRadius = FMath::Sqrt( + 2.0f * FMath::Square(Landing.HalfWidth) + + 0.25f * FMath::Square(Height)) + + Landing.FloorThickness + 4.0f; + IncludePoint(RoomCenter, RoomRadius); + }; + IncludeLanding(Passage.UpperLanding); + IncludeLanding(Passage.LowerLanding); + + // These two MC writers run after the tunnel SDF: the native support slab uses + // the relieved floor profile, and the final corridor air envelope uses each + // segment's own relief branch plus the door-to-standing approaches. Include the + // actual write suprema so the chunk shortlist and all sphere-based proofs remain + // conservative even when the authored relief exceeds the old one-voxel reserve. + if (Passage.bWalkableTunnelContract && Passage.bNativeFloorEnabled) + { + constexpr float CorridorHalfWidth = + 0.5f * VoxelPassageGeometry::WalkCorridorPlayerWidthVoxels + + VoxelPassageGeometry::WalkCorridorLatticeMarginVoxels; + constexpr float CorridorBottom = + VoxelPassageGeometry::MaxStepHeightVoxels; + constexpr float CorridorTop = CorridorBottom + + VoxelPassageGeometry::WalkCorridorPlayerHeightVoxels; + float ReliefBound = 0.0f; + if (VF_GetNativePassageFloorReliefBound(Passage, ReliefBound) + && Passage.ControlPoints.Num() >= 2 + && Passage.ControlRadii.Num() == Passage.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float RadiusA = + FMath::Abs(Passage.ControlRadii[SegmentIndex]); + const float RadiusB = + FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]); + const float FloorA = + Passage.NativeFloorProfileZ[SegmentIndex]; + const float FloorB = + Passage.NativeFloorProfileZ[SegmentIndex + 1]; + if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y) + || !VoxelMath::IsFinite(B.X) || !VoxelMath::IsFinite(B.Y) + || !VoxelMath::IsFinite(FloorA) + || !VoxelMath::IsFinite(FloorB)) + { + continue; + } + const float SupportRadius = + VoxelPassageGeometry::NativePassageFloorSupportRadius( + RadiusA, RadiusB); + IncludeBounds( + FVector( + FMath::Min(A.X, B.X) - SupportRadius, + FMath::Min(A.Y, B.Y) - SupportRadius, + FMath::Min(FloorA, FloorB) - ReliefBound + - VoxelPassageGeometry::LandingFloorThicknessVoxels), + FVector( + FMath::Max(A.X, B.X) + SupportRadius, + FMath::Max(A.Y, B.Y) + SupportRadius, + FMath::Max(FloorA, FloorB) + ReliefBound + + KINDA_SMALL_NUMBER)); + IncludeBounds( + FVector( + FMath::Min(A.X, B.X) - CorridorHalfWidth, + FMath::Min(A.Y, B.Y) - CorridorHalfWidth, + FMath::Min(FloorA, FloorB) + CorridorBottom + - ReliefBound), + FVector( + FMath::Max(A.X, B.X) + CorridorHalfWidth, + FMath::Max(A.Y, B.Y) + CorridorHalfWidth, + FMath::Max(FloorA, FloorB) + CorridorTop + + ReliefBound)); + } + } + + for (const FVoxelPassageLanding* Landing : { + &Passage.UpperLanding, &Passage.LowerLanding }) + { + if (!VoxelMath::IsFinite(Landing->DoorPoint.X) + || !VoxelMath::IsFinite(Landing->DoorPoint.Y) + || !VoxelMath::IsFinite(Landing->StandingPoint.X) + || !VoxelMath::IsFinite(Landing->StandingPoint.Y) + || !VoxelMath::IsFinite(Landing->FloorZ)) + { + continue; + } + const float LengthSquared = FVector2D::DistSquared( + FVector2D(Landing->DoorPoint.X, Landing->DoorPoint.Y), + FVector2D(Landing->StandingPoint.X, Landing->StandingPoint.Y)); + if (!(LengthSquared > KINDA_SMALL_NUMBER) + || !VoxelMath::IsFinite(LengthSquared)) + { + continue; + } + IncludeBounds( + FVector( + FMath::Min(Landing->DoorPoint.X, Landing->StandingPoint.X) + - CorridorHalfWidth, + FMath::Min(Landing->DoorPoint.Y, Landing->StandingPoint.Y) + - CorridorHalfWidth, + Landing->FloorZ + CorridorBottom), + FVector( + FMath::Max(Landing->DoorPoint.X, Landing->StandingPoint.X) + + CorridorHalfWidth, + FMath::Max(Landing->DoorPoint.Y, Landing->StandingPoint.Y) + + CorridorHalfWidth, + Landing->FloorZ + CorridorTop)); + } + } + + const FVector Center = (BoundsMin + BoundsMax) * 0.5f; + float MaxDistSq = 0.0f; + MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, BoundsMin)); + MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, BoundsMax)); + const float R = FMath::Sqrt(MaxDistSq); + Passage.BoundCenter = Center; + Passage.BoundRadius = R; + Passage.BoundRadiusSq = R * R; + } + + Passages.Add(Passage); + } + } + + TArray SortedNoQueryArchetypes; + for (const FString& Archetype : NoQueryArchetypes) + { + SortedNoQueryArchetypes.Add(Archetype); + } + SortedNoQueryArchetypes.Sort(); + FString NoQueryList = TEXT("none"); + if (SortedNoQueryArchetypes.Num() > 0) + { + NoQueryList = FString::Join(SortedNoQueryArchetypes, TEXT(", ")); + } + + UE_LOG(LogTemp, Log, + TEXT("[StrateManager] Passage landings: upper %d/%d and lower %d/%d source-fit; %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), + NumAimedAtUpperPlayerFit, + TotalPassages, + NumAimedAtLowerPlayerFit, + TotalPassages, + (TotalPassages * 2) - NumAimedAtUpperPlayerFit - NumAimedAtLowerPlayerFit, + TotalPassages * 2, + *NoQueryList); + + //========================================================================= + // SURFACE ENTRY — the one optional above-ground opening. + // It opens the top seal and stops at the ceiling of strate 0's origin landing room. It does + // not continue down the room or into lower strates: every lower seal remains closed until the + // corresponding progression passage is opened. + //========================================================================= + if (bOpenSurfaceEntry && OriginSpineRadius > 0.0f && StrateLayout.Num() > 0) + { + const FStrateSlot& Top = StrateLayout[0]; + const float TopZ = (float)(Top.TopChunkZ + 1) * CHUNK_SIZE; + const float BottomZ = (float)Top.BottomChunkZ * CHUNK_SIZE; + const float TopSeal = Top.Definition + ? BoundarySealThicknessFor(*Top.Definition) : 0.0f; + const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding = + VoxelPassageGeometry::BuildOriginLandingGeometry( + TopZ, BottomZ, TopSeal, OriginSpineRadius); + if (!OriginLanding.bValid) + { + UE_LOG(LogTemp, Warning, + TEXT("[StrateManager] Surface entry skipped: top origin landing has no seal-safe room interval.")); + } + else + { + FVoxelPassage Entry; + Entry.UpperStrateIndex = 0; + Entry.LowerStrateIndex = 0; + // Keep the legacy type: this is the explicitly opted-in above-ground vertical opening, + // not the default inter-strate descent style. + Entry.PassageType = EVoxelPassageType::VerticalShaft; + // Match the reserved future shaft envelope exactly. The entry is only opened through + // the top seal into the finite landing room; lower strate seals remain untouched. + Entry.Radius = FMath::Max(OriginSpineRadius, + VoxelPassageGeometry::MetersToVoxels(0.25f)); + Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE); + // The capsule's lower tangent meets the room ceiling; it never bores through the + // room's floor or creates an origin column in strate 0. + Entry.LowerPoint = FVector( + 0.0f, 0.0f, OriginLanding.CeilingZ + Entry.Radius); + Entry.ControlPoints.Reset(2); + Entry.ControlPoints.Add(Entry.UpperPoint); + Entry.ControlPoints.Add(Entry.LowerPoint); + Entry.ControlRadii.Reset(2); + Entry.ControlRadii.Add(Entry.Radius); + Entry.ControlRadii.Add(Entry.Radius); + Entry.TunnelClearHeightVoxels = 2.0f * Entry.Radius; + Entry.TunnelMinimumClearWidthVoxels = 2.0f * Entry.Radius; + { + const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f; + const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f; + Entry.BoundCenter = C; + Entry.BoundRadius = R; + Entry.BoundRadiusSq = R * R; + } + Passages.Add(Entry); + + UE_LOG(LogTemp, Log, + TEXT("[StrateManager] Surface entry opening at (0,0) topZ=%.0f roomFloor=%.1f roomCeiling=%.1f R=%.1f"), + TopZ, OriginLanding.FloorZ, OriginLanding.CeilingZ, Entry.Radius); + } + } + + VoxelForgeStartupTrace::RecordEvent(TEXT("passages_ready"), FString::Printf( + TEXT("\"passages\":%d,\"passages_version\":%u,\"upper_source_fit\":%d,\"lower_source_fit\":%d"), + Passages.Num(), PassagesVersion, NumAimedAtUpperPlayerFit, NumAimedAtLowerPlayerFit)); +} + +//============================================================================= +// MODIFIER SDF (inter-strate passages) +//============================================================================= + +float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const +{ + VoxelGenLOD::FScopedReachCost ReachCost( + VoxelGenLOD::ETileReachCostKind::PassageCarving); + if (VoxelDensityAblation::IsPassageCarvingOff()) + { + return FLT_MAX; + } + // GenerateMesh installs this once per tile/block. Outside the mesher the TLS defaults to + // the canonical all-reachable state, and debug canonical calls set the bypass bit explicitly. + // A miss is an identity for VF_ApplyPassageCarving, so do not even enter the spatial shortlist + // or capsule chain on a proven-far sample. + if (!VoxelGenLOD::IsTilePostReachBypassActive() + && !VoxelGenLOD::IsPassageCarvingReachable()) + { + return FLT_MAX; + } + + // The landing terms are part of the passage modifier, so their reach proof is deliberately + // wider than the direct post's four-voxel blend: SmoothMin can lower a finite term by K/6 at + // each later combine. Outside that padded room domain, omitting the two landing evaluations + // is an identity for VF_ApplyPassageCarving. + const bool bEvaluateLandingSDF = VoxelGenLOD::IsPassageLandingReachable() + || VoxelGenLOD::IsTilePostReachBypassActive(); + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + + // The shortlist is rebuilt once for a (manager, version, chunk) and is shared by the tube, + // landing, and floor paths. No source-fit stencil or topology search is allowed below it. + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::PassageCandidates, + static_cast(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(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& 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& 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& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + const FVoxelPassage* PassageData = Passages.GetData(); + const FVector Position(WorldX, WorldY, WorldZ); + for (const int32 PassageIndex : Nearby) + { + const FVoxelPassage& Passage = PassageData[PassageIndex]; + const bool bLandingFloor = bLandingReachable + && !VoxelDensityAblation::IsLandingPostsOff() + && (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding)); + const bool bLegacySupportFloor = bStructuralReachable + && !VoxelDensityAblation::IsPassageStructuralPostsOff() + && bPassageSupportFloorWritesEnabled + && !Passage.bNativeFloorEnabled + && VF_IsWalkableTunnelFloor(Passage, Position, this, PassageIndex); + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker() + && (bLandingFloor || bLegacySupportFloor)) + { + // Result is in MC convention here (negative = solid). This reassertion is the + // structural floor backstop after the optional MC-space disturbance layer. + Density = FMath::Min(Density, -BaseDensity); + break; + } + } + if (bCompareLanding) + { + VoxelGenLOD::RecordSkippedPostComparison( + VoxelGenLOD::ETilePostComparisonKind::PassageLandingFloor, + !VoxelGenLOD::SameFloatBits(Density, CanonicalDensity)); + } +} + +static bool VF_IsPassageRoomFloor( + const FVector& Position, const FVoxelPassageLanding& Landing) +{ + if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y) + || !VoxelMath::IsFinite(Position.Z) || !VoxelMath::IsFinite(Landing.FloorZ) + || !VoxelMath::IsFinite(Landing.FloorThickness) + || Landing.FloorThickness <= 0.0f || Landing.HalfWidth <= 0.0f) + { + return false; + } + return Position.Z <= Landing.FloorZ + KINDA_SMALL_NUMBER + && Position.Z > Landing.FloorZ - Landing.FloorThickness + && FMath::Abs(Position.X - Landing.StandingPoint.X) + <= FMath::Max(Landing.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f) + && FMath::Abs(Position.Y - Landing.StandingPoint.Y) + <= FMath::Max(Landing.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f); +} + +void UVoxelStrateManager::ApplyPassageStructuralPostsMC( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + const bool bDisableStructuralPosts = VoxelDensityAblation::IsPassageStructuralPostsOff(); + const bool bDisableLandingPosts = VoxelDensityAblation::IsLandingPostsOff(); + if (bDisableStructuralPosts && bDisableLandingPosts) + { + return; + } + + const bool bBypass = VoxelGenLOD::IsTilePostReachBypassActive(); + const bool bLandingReachable = VoxelGenLOD::IsPassageLandingReachable() || bBypass; + const bool bStructuralReachable = VoxelGenLOD::IsPassageStructuralReachable() || bBypass; + const bool bCompareLanding = VoxelGenLOD::bTilePostReachDebug + && !bLandingReachable && !bBypass; + float CanonicalDensity = Density; + if (bCompareLanding) + { + const bool PreviousBypass = VoxelGenLOD::bTilePostReachBypass; + VoxelGenLOD::bTilePostReachBypass = true; + ApplyPassageStructuralPostsMC( + CanonicalDensity, WorldX, WorldY, WorldZ, + BaseDensity, SealThickness); + VoxelGenLOD::bTilePostReachBypass = PreviousBypass; + } + if (!bLandingReachable && !bStructuralReachable) + { + if (bCompareLanding) + { + VoxelGenLOD::RecordSkippedPostComparison( + VoxelGenLOD::ETilePostComparisonKind::PassageStructuralPosts, + !VoxelGenLOD::SameFloatBits(Density, CanonicalDensity)); + } + return; + } + + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageStructuralPosts); + + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + const TArray& 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& 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& 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(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& Passages, + TArray>& Domains) + { + constexpr int32 MaxControlPointsForBound = 4096; + Domains.Reset(); + + auto AddDomain = [&](const FVector& Min, const FVector& Max) -> bool + { + if (!VoxelMath::IsFinite(Min.X) || !VoxelMath::IsFinite(Min.Y) + || !VoxelMath::IsFinite(Min.Z) || !VoxelMath::IsFinite(Max.X) + || !VoxelMath::IsFinite(Max.Y) || !VoxelMath::IsFinite(Max.Z) + || Min.X > Max.X || Min.Y > Max.Y || Min.Z > Max.Z) + { + return false; + } + Domains.Add({Min, Max}); + return true; + }; + + for (const FVoxelPassage& Passage : Passages) + { + if (!VoxelMath::IsFinite(Passage.BoundCenter.X) + || !VoxelMath::IsFinite(Passage.BoundCenter.Y) + || !VoxelMath::IsFinite(Passage.BoundCenter.Z) + || !VoxelMath::IsFinite(Passage.BoundRadius) + || !VoxelMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + // Invalid generated geometry is an unknown, not an empty passage set. + return false; + } + + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > MaxControlPointsForBound) + { + return false; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !VoxelMath::IsFinite(Passage.Radius)) + { + return false; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z)) + { + return false; + } + if (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex])) + { + return false; + } + } + + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float RadiusA = bTaper + ? Passage.ControlRadii[SegmentIndex] : Passage.Radius; + const float RadiusB = bTaper + ? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius; + const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + if (!VoxelMath::IsFinite(Radius)) + { + return false; + } + if (!AddDomain( + FVector(FMath::Min(A.X, B.X) - Radius, + FMath::Min(A.Y, B.Y) - Radius, + FMath::Min(A.Z, B.Z) - Radius), + FVector(FMath::Max(A.X, B.X) + Radius, + FMath::Max(A.Y, B.Y) + Radius, + FMath::Max(A.Z, B.Z) + Radius))) + { + return false; + } + } + } + else + { + if (!VoxelMath::IsFinite(Passage.UpperPoint.X) + || !VoxelMath::IsFinite(Passage.UpperPoint.Y) + || !VoxelMath::IsFinite(Passage.UpperPoint.Z) + || !VoxelMath::IsFinite(Passage.LowerPoint.X) + || !VoxelMath::IsFinite(Passage.LowerPoint.Y) + || !VoxelMath::IsFinite(Passage.LowerPoint.Z) + || !VoxelMath::IsFinite(Passage.Radius)) + { + return false; + } + const float Radius = FMath::Abs(Passage.Radius); + if (!AddDomain( + FVector(FMath::Min(Passage.UpperPoint.X, Passage.LowerPoint.X) - Radius, + FMath::Min(Passage.UpperPoint.Y, Passage.LowerPoint.Y) - Radius, + FMath::Min(Passage.UpperPoint.Z, Passage.LowerPoint.Z) - Radius), + FVector(FMath::Max(Passage.UpperPoint.X, Passage.LowerPoint.X) + Radius, + FMath::Max(Passage.UpperPoint.Y, Passage.LowerPoint.Y) + Radius, + FMath::Max(Passage.UpperPoint.Z, Passage.LowerPoint.Z) + Radius))) + { + return false; + } + } + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!VoxelMath::IsFinite(Landing->StandingPoint.X) + || !VoxelMath::IsFinite(Landing->StandingPoint.Y) + || !VoxelMath::IsFinite(Landing->FloorZ) + || !VoxelMath::IsFinite(Landing->CeilingZ) + || !VoxelMath::IsFinite(Landing->HalfWidth)) + { + // The canonical evaluator returns FLT_MAX for this landing. Treating it as + // unknown is safer for future evaluator changes and only gives up a skip. + return false; + } + if (Landing->HalfWidth <= 0.0f + || Landing->CeilingZ <= Landing->FloorZ) + { + continue; + } + constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels; + const float Height = Landing->CeilingZ - Landing->FloorZ; + const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)); + const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)); + const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f; + if (!AddDomain( + FVector(Landing->StandingPoint.X - HalfX - RoomRounding, + Landing->StandingPoint.Y - HalfX - RoomRounding, + FMath::Min(Landing->FloorZ, + CenterZ - HalfZ - RoomRounding)), + FVector(Landing->StandingPoint.X + HalfX + RoomRounding, + Landing->StandingPoint.Y + HalfX + RoomRounding, + FMath::Max(Landing->CeilingZ, + CenterZ + HalfZ + RoomRounding)))) + { + return false; + } + } + } + return true; + } + + static bool VF_AnyPassageModifierDomainTouchesLattice( + const TArray& 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> Domains; + if (!VF_BuildPassageModifierDomains(Passages, Domains)) + { + return true; + } + if (Domains.Num() == 0) + { + return false; + } + const int64 DomainCount = Domains.Num(); + const float SmoothDip = static_cast(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& Passages, + const FVector& Position) + { + if (!VoxelMath::IsFinite(Position.X) || !VoxelMath::IsFinite(Position.Y) + || !VoxelMath::IsFinite(Position.Z)) + { + return true; + } + + constexpr float BlendK = 3.0f; + constexpr float CarveThreshold = 4.0f; + constexpr float NumericalSafetyMargin = 1.0e-3f; + float ModifierLowerBound = FLT_MAX; + for (const FVoxelPassage& Passage : Passages) + { + if (!VoxelMath::IsFinite(Passage.BoundCenter.X) + || !VoxelMath::IsFinite(Passage.BoundCenter.Y) + || !VoxelMath::IsFinite(Passage.BoundCenter.Z) + || !VoxelMath::IsFinite(Passage.BoundRadius) + || !VoxelMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return true; + } + if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq) + { + continue; + } + + float PassageLowerBound = FLT_MAX; + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > 4096) + { + return true; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !VoxelMath::IsFinite(Passage.Radius)) + { + return true; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z)) + { + return true; + } + if (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex])) + { + return true; + } + } + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float RadiusA = bTaper + ? Passage.ControlRadii[SegmentIndex] : Passage.Radius; + const float RadiusB = bTaper + ? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius; + const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + const float SegmentLowerBound = VF_DistanceToSegmentLowerBound( + Position, A, B, Radius); + if (!VoxelMath::IsFinite(SegmentLowerBound)) + { + return true; + } + PassageLowerBound = VoxelSDF::SmoothMin( + PassageLowerBound, SegmentLowerBound, BlendK); + } + } + else + { + if (!VoxelMath::IsFinite(Passage.UpperPoint.X) + || !VoxelMath::IsFinite(Passage.UpperPoint.Y) + || !VoxelMath::IsFinite(Passage.UpperPoint.Z) + || !VoxelMath::IsFinite(Passage.LowerPoint.X) + || !VoxelMath::IsFinite(Passage.LowerPoint.Y) + || !VoxelMath::IsFinite(Passage.LowerPoint.Z) + || !VoxelMath::IsFinite(Passage.Radius)) + { + return true; + } + PassageLowerBound = VF_DistanceToSegmentLowerBound( + Position, Passage.UpperPoint, Passage.LowerPoint, + FMath::Abs(Passage.Radius)); + if (!VoxelMath::IsFinite(PassageLowerBound)) + { + return true; + } + } + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!VoxelMath::IsFinite(Landing->StandingPoint.X) + || !VoxelMath::IsFinite(Landing->StandingPoint.Y) + || !VoxelMath::IsFinite(Landing->FloorZ) + || !VoxelMath::IsFinite(Landing->CeilingZ) + || !VoxelMath::IsFinite(Landing->HalfWidth)) + { + return true; + } + if (Landing->HalfWidth <= 0.0f + || Landing->CeilingZ <= Landing->FloorZ) + { + continue; + } + const float LandingSDF = VF_EvaluatePassageLandingSDF(Position, *Landing); + if (!VoxelMath::IsFinite(LandingSDF)) + { + return true; + } + PassageLowerBound = VoxelSDF::SmoothMin( + PassageLowerBound, LandingSDF, BlendK); + } + + ModifierLowerBound = VoxelSDF::SmoothMin( + ModifierLowerBound, PassageLowerBound, BlendK); + } + return !VoxelMath::IsFinite(ModifierLowerBound) + || ModifierLowerBound < CarveThreshold + NumericalSafetyMargin; + } + + static float VF_DistanceBetweenAABBs( + const FVector& MinA, const FVector& MaxA, + const FVector& MinB, const FVector& MaxB) + { + const float DX = MaxA.X < MinB.X ? MinB.X - MaxA.X + : MaxB.X < MinA.X ? MinA.X - MaxB.X : 0.0f; + const float DY = MaxA.Y < MinB.Y ? MinB.Y - MaxA.Y + : MaxB.Y < MinA.Y ? MinA.Y - MaxB.Y : 0.0f; + const float DZ = MaxA.Z < MinB.Z ? MinB.Z - MaxA.Z + : MaxB.Z < MinA.Z ? MinA.Z - MaxB.Z : 0.0f; + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + } + + // Exact minimum distance between a finite segment and an axis-aligned box. The squared + // distance to a box is piecewise quadratic along the segment; the only breakpoints are where + // one segment coordinate crosses one of the six box planes. This is a small, allocation-free + // lower-bound primitive for the passage proof, not a change to the runtime SDF evaluator. + static bool VF_SegmentDistanceToBox( + const FVector& A, const FVector& B, const FBox& Box, float& OutDistance) + { + if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y) || !VoxelMath::IsFinite(A.Z) + || !VoxelMath::IsFinite(B.X) || !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z) + || !Box.IsValid) + { + return false; + } + + const FVector D = B - A; + float Breaks[8] = { 0.0f, 1.0f }; + int32 BreakCount = 2; + const float AValues[3] = { A.X, A.Y, A.Z }; + const float DValues[3] = { D.X, D.Y, D.Z }; + const float BoxMinValues[3] = { + (float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z }; + const float BoxMaxValues[3] = { + (float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z }; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + // This is a lower-bound proof. A nonzero direction, however small, can cross a + // box plane and reduce the segment distance; treating it as stationary would make + // the bound too high and could incorrectly skip geometry. + if (DValues[Axis] == 0.0f) + { + continue; + } + const float TMin = (BoxMinValues[Axis] - AValues[Axis]) / DValues[Axis]; + const float TMax = (BoxMaxValues[Axis] - AValues[Axis]) / DValues[Axis]; + if (TMin > 0.0f && TMin < 1.0f && BreakCount < UE_ARRAY_COUNT(Breaks)) + { + Breaks[BreakCount++] = TMin; + } + if (TMax > 0.0f && TMax < 1.0f && BreakCount < UE_ARRAY_COUNT(Breaks)) + { + Breaks[BreakCount++] = TMax; + } + } + for (int32 I = 1; I < BreakCount; ++I) + { + const float Value = Breaks[I]; + int32 J = I - 1; + while (J >= 0 && Breaks[J] > Value) + { + Breaks[J + 1] = Breaks[J]; + --J; + } + Breaks[J + 1] = Value; + } + + float MinimumDistanceSquared = FLT_MAX; + for (int32 Interval = 0; Interval + 1 < BreakCount; ++Interval) + { + const float T0 = Breaks[Interval]; + const float T1 = Breaks[Interval + 1]; + const float TM = 0.5f * (T0 + T1); + float QuadraticA = 0.0f; + float QuadraticB = 0.0f; + float QuadraticC = 0.0f; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + const float Mid = AValues[Axis] + DValues[Axis] * TM; + float Offset = 0.0f; + if (Mid < BoxMinValues[Axis]) + { + Offset = AValues[Axis] - BoxMinValues[Axis]; + } + else if (Mid > BoxMaxValues[Axis]) + { + Offset = AValues[Axis] - BoxMaxValues[Axis]; + } + else + { + continue; + } + QuadraticA += DValues[Axis] * DValues[Axis]; + QuadraticB += 2.0f * Offset * DValues[Axis]; + QuadraticC += Offset * Offset; + } + float T = T0; + if (QuadraticA > 0.0f) + { + T = FMath::Clamp( + -QuadraticB / (2.0f * QuadraticA), T0, T1); + } + const float DistanceSquared = FMath::Max( + 0.0f, + (QuadraticA * T + QuadraticB) * T + QuadraticC); + MinimumDistanceSquared = FMath::Min( + MinimumDistanceSquared, DistanceSquared); + } + if (!VoxelMath::IsFinite(MinimumDistanceSquared)) + { + return false; + } + OutDistance = FMath::Sqrt(MinimumDistanceSquared); + return VoxelMath::IsFinite(OutDistance); + } + + static bool VF_LandingLowerBoundOverBox( + const FVoxelPassageLanding& Landing, const FBox& Box, float& OutLowerBound) + { + if (!VoxelMath::IsFinite(Landing.StandingPoint.X) + || !VoxelMath::IsFinite(Landing.StandingPoint.Y) + || !VoxelMath::IsFinite(Landing.FloorZ) + || !VoxelMath::IsFinite(Landing.CeilingZ) + || !VoxelMath::IsFinite(Landing.HalfWidth) + || !Box.IsValid) + { + return false; + } + if (Landing.HalfWidth <= 0.0f || Landing.CeilingZ <= Landing.FloorZ) + { + OutLowerBound = FLT_MAX; + return true; + } + + constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels; + const float Height = Landing.CeilingZ - Landing.FloorZ; + const FVector Center( + Landing.StandingPoint.X, Landing.StandingPoint.Y, + (Landing.FloorZ + Landing.CeilingZ) * 0.5f); + const FVector HalfExtent( + FMath::Max(Landing.HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)), + FMath::Max(Landing.HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)), + FMath::Max(Height * 0.5f - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f))); + const FVector OuterMin = Center - HalfExtent - FVector(RoomRounding); + const FVector OuterMax = Center + HalfExtent + FVector(RoomRounding); + const FVector BoxMin((float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z); + const FVector BoxMax((float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z); + const float OuterDistance = VF_DistanceBetweenAABBs( + BoxMin, BoxMax, OuterMin, OuterMax); + const float RoundedBoxLower = OuterDistance > 0.0f + ? OuterDistance + : -FMath::Max(HalfExtent.X, + FMath::Max(HalfExtent.Y, HalfExtent.Z)) - RoomRounding; + OutLowerBound = FMath::Max( + RoundedBoxLower, Landing.FloorZ - (float)Box.Max.Z); + return VoxelMath::IsFinite(OutLowerBound); + } + + static bool VF_PassageModifierLowerBoundOverBox( + const TArray& 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 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& 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> 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> 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> 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(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(LastX) - FirstX + 1) + * (static_cast(LastY) - FirstY + 1) + * (static_cast(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(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(IX1) - IX0 + 1) + * (static_cast(IY1) - IY0 + 1) + * (static_cast(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(LastX) - FirstX + 1; + const int64 DimY = static_cast(LastY) - FirstY + 1; + const int64 DimZ = static_cast(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(SampleCount)); + } + else + { + Cache.Factors.SetNumUninitialized(static_cast(SampleCount)); + } + + float MaxCarveFactor = 0.0f; + for (int32 IZ = EvaluationFirstZ; IZ <= EvaluationLastZ; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = EvaluationFirstY; IY <= EvaluationLastY; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = EvaluationFirstX; IX <= EvaluationLastX; ++IX) + { + const float X = (float)LatticeOrigin.X + (float)(IX * Step); + const FVector Position(X, Y, Z); + float CarveFactor = 0.0f; + if (MayEvaluatePoint(Position)) + { + const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); + CarveFactor = VoxelMath::IsFinite(ModifierSDF) + ? CarveFactorFromSDF(ModifierSDF) : 1.0f; + } + Cache.Factors[Cache.Index(IX, IY, IZ)] = CarveFactor; + MaxCarveFactor = FMath::Max(MaxCarveFactor, CarveFactor); + if (MaxCarveFactor >= 1.0f) + { + // The remaining values are still filled below. Descendant queries need + // the complete exact lattice, not merely this query's maximum. + for (int32 RemainingZ = IZ; RemainingZ <= LastZ; ++RemainingZ) + { + const float RemainingWorldZ = + (float)LatticeOrigin.Z + (float)(RemainingZ * Step); + const int32 StartY = RemainingZ == IZ ? IY : FirstY; + for (int32 RemainingY = StartY; RemainingY <= LastY; ++RemainingY) + { + const float RemainingWorldY = + (float)LatticeOrigin.Y + (float)(RemainingY * Step); + const int32 StartX = + (RemainingZ == IZ && RemainingY == IY) ? IX + 1 : FirstX; + for (int32 RemainingX = StartX; RemainingX <= LastX; ++RemainingX) + { + const float RemainingWorldX = + (float)LatticeOrigin.X + (float)(RemainingX * Step); + const FVector RemainingPosition( + RemainingWorldX, RemainingWorldY, RemainingWorldZ); + float RemainingFactor = 0.0f; + if (MayEvaluatePoint(RemainingPosition)) + { + const float RemainingSDF = EvaluateModifierSDF( + RemainingWorldX, RemainingWorldY, RemainingWorldZ); + RemainingFactor = VoxelMath::IsFinite(RemainingSDF) + ? CarveFactorFromSDF(RemainingSDF) : 1.0f; + } + Cache.Factors[Cache.Index(RemainingX, RemainingY, RemainingZ)] = + RemainingFactor; + } + } + } + return MaxCarveFactor; + } + } + } + } + return MaxCarveFactor; + } + + // Very large or unusual external queries do not enter the bounded worker memo. Keep the + // original exact walk for them; refusing the cache must never change the proof. + Cache.Reset(); + float MaxCarveFactor = 0.0f; + for (int32 IZ = EvaluationFirstZ; IZ <= EvaluationLastZ; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = EvaluationFirstY; IY <= EvaluationLastY; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = EvaluationFirstX; IX <= EvaluationLastX; ++IX) + { + const float X = (float)LatticeOrigin.X + (float)(IX * Step); + const FVector Position(X, Y, Z); + if (!MayEvaluatePoint(Position)) + { + continue; + } + const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); + if (!VoxelMath::IsFinite(ModifierSDF)) + { + return 1.0f; + } + MaxCarveFactor = FMath::Max( + MaxCarveFactor, CarveFactorFromSDF(ModifierSDF)); + if (MaxCarveFactor >= 1.0f) + { + return MaxCarveFactor; + } + } + } + } + return MaxCarveFactor; +} + +bool UVoxelStrateManager::AnyPassageLandingFloorNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + if (Step <= 0 || !VoxelBox.IsValid) + { + return true; + } + for (const FVoxelPassage& Passage : Passages) + { + float ReliefBound = 0.0f; + if (!VF_GetNativePassageFloorReliefBound(Passage, ReliefBound)) + { + return true; + } + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!VoxelMath::IsFinite(Landing->StandingPoint.X) + || !VoxelMath::IsFinite(Landing->StandingPoint.Y) + || !VoxelMath::IsFinite(Landing->FloorZ) + || !VoxelMath::IsFinite(Landing->HalfWidth) + || !VoxelMath::IsFinite(Landing->FloorThickness)) + { + return true; + } + if (Landing->HalfWidth <= 0.0f || Landing->FloorThickness <= 0.0f) + { + continue; + } + constexpr float Pad = 1.0f; + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Landing->StandingPoint.X - Landing->HalfWidth - Pad, + Landing->StandingPoint.X + Landing->HalfWidth + Pad, + Landing->StandingPoint.Y - Landing->HalfWidth - Pad, + Landing->StandingPoint.Y + Landing->HalfWidth + Pad, + Landing->FloorZ - Landing->FloorThickness - Pad, + Landing->FloorZ + Pad)) + { + return true; + } + } + + if (Passage.bWalkableTunnelContract) + { + if (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + return true; + } + constexpr float Pad = 1.0f; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y) + || !VoxelMath::IsFinite(A.Z) || !VoxelMath::IsFinite(B.X) + || !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z) + || !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex]) + || !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1])) + { + return true; + } + const float RadiusA = FMath::Abs(Passage.ControlRadii[SegmentIndex]); + const float RadiusB = FMath::Abs(Passage.ControlRadii[SegmentIndex + 1]); + const float SupportRadius = + VoxelPassageGeometry::NativePassageFloorSupportRadius(RadiusA, RadiusB); + const float FloorA = Passage.bNativeFloorEnabled + ? Passage.NativeFloorProfileZ[SegmentIndex] + : VoxelPassageGeometry::TunnelFloorZ(A, RadiusA); + const float FloorB = Passage.bNativeFloorEnabled + ? Passage.NativeFloorProfileZ[SegmentIndex + 1] + : VoxelPassageGeometry::TunnelFloorZ(B, RadiusB); + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + FMath::Min(A.X, B.X) - SupportRadius - Pad, + FMath::Max(A.X, B.X) + SupportRadius + Pad, + FMath::Min(A.Y, B.Y) - SupportRadius - Pad, + FMath::Max(A.Y, B.Y) + SupportRadius + Pad, + FMath::Min(FloorA, FloorB) + - VoxelPassageGeometry::LandingFloorThicknessVoxels + - Pad - ReliefBound, + FMath::Max(FloorA, FloorB) + Pad + ReliefBound)) + { + return true; + } + } + } + } + return false; +} + +bool UVoxelStrateManager::AnyPassageLandingNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float ReachScale) const +{ + if (Step <= 0 || !VoxelBox.IsValid + || !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f) + { + return true; + } + + // VF_ApplyPassageCarving has a hard no-op band at ModSDF >= 4. A landing term outside the + // rounded-room AABB by 4 + K (K=3) is either saturated out of SmoothMin when the tube is + // inside the carve band, or leaves the folded result >= 4 when the tube is outside it. That + // proves the landing term cannot change the field; the direct landing/floor posts use their + // smaller, operation-specific pads below. + for (const FVoxelPassage& Passage : Passages) + { + if (!VoxelMath::IsFinite(Passage.BoundCenter.X) + || !VoxelMath::IsFinite(Passage.BoundCenter.Y) + || !VoxelMath::IsFinite(Passage.BoundCenter.Z) + || !VoxelMath::IsFinite(Passage.BoundRadius) + || !VoxelMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return true; + } + + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > 4096) + { + return true; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !VoxelMath::IsFinite(Passage.Radius)) + { + return true; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z) + || (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[PointIndex]))) + { + return true; + } + } + } + else + { + if (!VoxelMath::IsFinite(Passage.UpperPoint.X) + || !VoxelMath::IsFinite(Passage.UpperPoint.Y) + || !VoxelMath::IsFinite(Passage.UpperPoint.Z) + || !VoxelMath::IsFinite(Passage.LowerPoint.X) + || !VoxelMath::IsFinite(Passage.LowerPoint.Y) + || !VoxelMath::IsFinite(Passage.LowerPoint.Z) + || !VoxelMath::IsFinite(Passage.Radius)) + { + return true; + } + } + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!VoxelMath::IsFinite(Landing->StandingPoint.X) + || !VoxelMath::IsFinite(Landing->StandingPoint.Y) + || !VoxelMath::IsFinite(Landing->FloorZ) + || !VoxelMath::IsFinite(Landing->CeilingZ) + || !VoxelMath::IsFinite(Landing->HalfWidth) + || !VoxelMath::IsFinite(Landing->FloorThickness)) + { + return true; + } + + if (Landing->HalfWidth > 0.0f + && Landing->FloorThickness > 0.0f + && VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Landing->StandingPoint.X - FMath::Max(Landing->HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f) + - ReachScale, + Landing->StandingPoint.X + FMath::Max(Landing->HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f) + + ReachScale, + Landing->StandingPoint.Y - FMath::Max(Landing->HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f) + - ReachScale, + Landing->StandingPoint.Y + FMath::Max(Landing->HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f) + + ReachScale, + Landing->FloorZ - Landing->FloorThickness - ReachScale, + Landing->FloorZ + ReachScale)) + { + return true; + } + + if (Landing->HalfWidth <= 0.0f || Landing->CeilingZ <= Landing->FloorZ) + { + // VF_EvaluatePassageLandingSDF returns FLT_MAX for this descriptor. The floor + // test above has already handled the only remaining possible writer. + continue; + } + + } + } + + constexpr float SmoothMinK = 3.0f; + const float RoomPad = (VoxelPassageGeometry::LandingCarveBlendVoxels + SmoothMinK) + * ReachScale; + if (!VoxelMath::IsFinite(RoomPad) || RoomPad < 0.0f) + { + return true; + } + + for (const FVoxelPassage& Passage : Passages) + { + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (Landing->HalfWidth <= 0.0f || Landing->CeilingZ <= Landing->FloorZ) + { + continue; + } + constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels; + const float Height = Landing->CeilingZ - Landing->FloorZ; + const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)); + const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)); + const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f; + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Landing->StandingPoint.X - HalfX - RoomRounding - RoomPad, + Landing->StandingPoint.X + HalfX + RoomRounding + RoomPad, + Landing->StandingPoint.Y - HalfX - RoomRounding - RoomPad, + Landing->StandingPoint.Y + HalfX + RoomRounding + RoomPad, + FMath::Min(Landing->FloorZ, + CenterZ - HalfZ - RoomRounding) - RoomPad, + FMath::Max(Landing->CeilingZ, + CenterZ + HalfZ + RoomRounding) + RoomPad)) + { + return true; + } + } + } + return false; +} + +bool UVoxelStrateManager::AnyPassageCarvingNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float ReachScale) const +{ + if (VoxelDensityAblation::IsPassageCarvingOff()) + { + return false; + } + if (Step <= 0 || !VoxelBox.IsValid + || !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f) + { + return true; + } + + // BoundRadius is built around every capsule control point and both landing rooms. The + // construction already adds the ModSDF<4 carve band. SmoothMin can lower a chain of + // otherwise-far finite terms by at most K/6 per combine, so include that finite morphology + // loss as well. A miss of this sphere proves every capsule/landing term folds to the hard + // no-op band before VF_ApplyPassageCarving; malformed descriptors remain candidates. + int32 SmoothMinCombines = 0; + for (const FVoxelPassage& Passage : Passages) + { + if (!VoxelMath::IsFinite(Passage.BoundCenter.X) + || !VoxelMath::IsFinite(Passage.BoundCenter.Y) + || !VoxelMath::IsFinite(Passage.BoundCenter.Z) + || !VoxelMath::IsFinite(Passage.BoundRadius) + || !VoxelMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return true; + } + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > 4096) + { + return true; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !VoxelMath::IsFinite(Passage.Radius)) + { + return true; + } + for (int32 Index = 0; Index < Passage.ControlPoints.Num(); ++Index) + { + const FVector& Point = Passage.ControlPoints[Index]; + if (!VoxelMath::IsFinite(Point.X) || !VoxelMath::IsFinite(Point.Y) + || !VoxelMath::IsFinite(Point.Z) + || (bTaper && !VoxelMath::IsFinite(Passage.ControlRadii[Index]))) + { + return true; + } + } + SmoothMinCombines += FMath::Max(Passage.ControlPoints.Num() - 2, 0); + } + else + { + if (!VoxelMath::IsFinite(Passage.UpperPoint.X) + || !VoxelMath::IsFinite(Passage.UpperPoint.Y) + || !VoxelMath::IsFinite(Passage.UpperPoint.Z) + || !VoxelMath::IsFinite(Passage.LowerPoint.X) + || !VoxelMath::IsFinite(Passage.LowerPoint.Y) + || !VoxelMath::IsFinite(Passage.LowerPoint.Z) + || !VoxelMath::IsFinite(Passage.Radius)) + { + return true; + } + } + + if (Passage.bNativeFloorEnabled) + { + ++SmoothMinCombines; // SmoothMax is the same K/6 polynomial bound. + } + SmoothMinCombines += 3; // passage union plus its two landing terms + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!VoxelMath::IsFinite(Landing->StandingPoint.X) + || !VoxelMath::IsFinite(Landing->StandingPoint.Y) + || !VoxelMath::IsFinite(Landing->FloorZ) + || !VoxelMath::IsFinite(Landing->CeilingZ) + || !VoxelMath::IsFinite(Landing->HalfWidth) + || !VoxelMath::IsFinite(Landing->FloorThickness)) + { + return true; + } + } + } + + constexpr float CarveThreshold = 4.0f; + constexpr float BlendK = 3.0f; + const float MorphologyPad = CarveThreshold + + BlendK * static_cast(FMath::Max(SmoothMinCombines, 0)) / 6.0f; + if (!VoxelMath::IsFinite(MorphologyPad) || MorphologyPad < 0.0f) + { + return true; + } + for (const FVoxelPassage& Passage : Passages) + { + bool bHasTightGeometry = false; + bool bTightTouch = false; + const float PassagePad = MorphologyPad * ReachScale; + if (!VoxelMath::IsFinite(PassagePad) || PassagePad < 0.0f) + { + return true; + } + if (Passage.ControlPoints.Num() >= 2) + { + bHasTightGeometry = Passage.ControlPoints.Num() <= 4096; + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + const float MaxRadius = bTaper + ? [&Passage]() + { + float Result = 0.0f; + for (const float Radius : Passage.ControlRadii) + { + Result = FMath::Max(Result, FMath::Abs(Radius)); + } + return Result; + }() + : FMath::Abs(Passage.Radius); + if (!VoxelMath::IsFinite(MaxRadius)) + { + return true; + } + const float SegmentPad = (MaxRadius + + (Passage.bNativeFloorEnabled + ? VoxelPassageGeometry::LandingFloorThicknessVoxels : 0.0f) + + PassagePad); + if (!VoxelMath::IsFinite(SegmentPad)) + { + return true; + } + for (int32 SegmentIndex = 0; + bHasTightGeometry && SegmentIndex + 1 < Passage.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + FMath::Min(A.X, B.X) - SegmentPad, + FMath::Max(A.X, B.X) + SegmentPad, + FMath::Min(A.Y, B.Y) - SegmentPad, + FMath::Max(A.Y, B.Y) + SegmentPad, + FMath::Min(A.Z, B.Z) - SegmentPad, + FMath::Max(A.Z, B.Z) + SegmentPad)) + { + bTightTouch = true; + } + } + } + else + { + bHasTightGeometry = true; + const float SegmentPad = FMath::Abs(Passage.Radius) + + (Passage.bNativeFloorEnabled + ? VoxelPassageGeometry::LandingFloorThicknessVoxels : 0.0f) + + PassagePad; + if (!VoxelMath::IsFinite(SegmentPad)) + { + return true; + } + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + FMath::Min(Passage.UpperPoint.X, Passage.LowerPoint.X) - SegmentPad, + FMath::Max(Passage.UpperPoint.X, Passage.LowerPoint.X) + SegmentPad, + FMath::Min(Passage.UpperPoint.Y, Passage.LowerPoint.Y) - SegmentPad, + FMath::Max(Passage.UpperPoint.Y, Passage.LowerPoint.Y) + SegmentPad, + FMath::Min(Passage.UpperPoint.Z, Passage.LowerPoint.Z) - SegmentPad, + FMath::Max(Passage.UpperPoint.Z, Passage.LowerPoint.Z) + SegmentPad)) + { + bTightTouch = true; + } + } + + const float RoomPad = PassagePad; + for (const FVoxelPassageLanding* Landing : { + &Passage.UpperLanding, &Passage.LowerLanding }) + { + if (Landing->HalfWidth <= 0.0f || Landing->CeilingZ <= Landing->FloorZ) + { + continue; + } + constexpr float RoomRounding = VoxelPassageGeometry::RoomRoundingVoxels; + const float Height = Landing->CeilingZ - Landing->FloorZ; + const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)); + const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, + VoxelPassageGeometry::MetersToVoxels(0.0625f)); + const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f; + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Landing->StandingPoint.X - HalfX - RoomRounding - RoomPad, + Landing->StandingPoint.X + HalfX + RoomRounding + RoomPad, + Landing->StandingPoint.Y - HalfX - RoomRounding - RoomPad, + Landing->StandingPoint.Y + HalfX + RoomRounding + RoomPad, + FMath::Min(Landing->FloorZ, + CenterZ - HalfZ - RoomRounding) - RoomPad, + FMath::Max(Landing->CeilingZ, + CenterZ + HalfZ + RoomRounding) + RoomPad)) + { + bTightTouch = true; + } + } + + if (bHasTightGeometry && !bTightTouch) + { + continue; + } + const float Reach = (Passage.BoundRadius + MorphologyPad) * ReachScale; + if (!VoxelMath::IsFinite(Reach) + || VF_LatticeBoxTouchesSphere(VoxelBox, Passage.BoundCenter, Reach)) + { + return true; + } + } + return false; +} + +bool UVoxelStrateManager::AnyPassageStructuralPostNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float ReachScale) const +{ + (void)LatticeOrigin; + (void)Step; + if (Step <= 0 || !VoxelBox.IsValid + || !VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f) + { + return true; + } + + // The construction-time BoundRadius encloses every tube, landing room, floor band, and its + // four-voxel carve margin. All structural writers below are subsets of that sphere, so a + // sphere/AABB miss proves identity without doing a per-sample chunk-shortlist lookup. + for (const FVoxelPassage& Passage : Passages) + { + if (!VoxelMath::IsFinite(Passage.BoundCenter.X) + || !VoxelMath::IsFinite(Passage.BoundCenter.Y) + || !VoxelMath::IsFinite(Passage.BoundCenter.Z) + || !VoxelMath::IsFinite(Passage.BoundRadius) + || !VoxelMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return true; + } + const float Reach = (Passage.BoundRadius + 1.0f) * ReachScale; + if (!VoxelMath::IsFinite(Reach) + || VF_LatticeBoxTouchesSphere(VoxelBox, Passage.BoundCenter, Reach)) + { + return true; + } + } + return false; +} + +bool UVoxelStrateManager::AnyLandingFloorAtLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + // This query is used only after the stack has proved AllAir. The broad floor guards above + // are intentionally padded for sound interval folding, but that padding turns a long tunnel + // or landing AABB into a false final-field candidate. Check the actual writer predicates on + // the same MC lattice before asking GetDensityAt for a certificate. Unknown inputs remain + // candidates; this function is never allowed to manufacture an empty proof. + if (Step <= 0 || !VoxelBox.IsValid + || VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + return true; + } + + const bool bBroadPassageFloor = AnyPassageLandingFloorNearLattice( + VoxelBox, LatticeOrigin, Step); + const bool bBroadOriginFloor = AnyOriginLandingFloorNearLattice( + VoxelBox, LatticeOrigin, Step); + if (!bBroadPassageFloor && !bBroadOriginFloor) + { + return false; + } + + auto GetRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool + { + return VF_LatticeAxisRange( + Min, Max, static_cast(LatticeOrigin.X), Step, OutFirst, OutLast); + }; + auto GetYRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool + { + return VF_LatticeAxisRange( + Min, Max, static_cast(LatticeOrigin.Y), Step, OutFirst, OutLast); + }; + auto GetZRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool + { + return VF_LatticeAxisRange( + Min, Max, static_cast(LatticeOrigin.Z), Step, OutFirst, OutLast); + }; + + auto HasAnyLandingSample = [&](const FVoxelPassageLanding& Landing) -> bool + { + if (!VoxelMath::IsFinite(Landing.StandingPoint.X) + || !VoxelMath::IsFinite(Landing.StandingPoint.Y) + || !VoxelMath::IsFinite(Landing.FloorZ) + || !VoxelMath::IsFinite(Landing.HalfWidth) + || !VoxelMath::IsFinite(Landing.FloorThickness) + || Landing.HalfWidth <= 0.0f || Landing.FloorThickness <= 0.0f) + { + // This function gates an AllAir proof. Invalid authored floor data is an unknown + // structural writer, not evidence that the writer is absent. + return true; + } + const float HalfWidth = FMath::Max(Landing.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f); + int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1; + return VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.X, + Landing.StandingPoint.X - HalfWidth), + FMath::Min((float)VoxelBox.Max.X, + Landing.StandingPoint.X + HalfWidth), + (float)LatticeOrigin.X, Step, IX0, IX1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Y, + Landing.StandingPoint.Y - HalfWidth), + FMath::Min((float)VoxelBox.Max.Y, + Landing.StandingPoint.Y + HalfWidth), + (float)LatticeOrigin.Y, Step, IY0, IY1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Z, + Landing.FloorZ - Landing.FloorThickness), + FMath::Min((float)VoxelBox.Max.Z, + Landing.FloorZ + KINDA_SMALL_NUMBER), + (float)LatticeOrigin.Z, Step, IZ0, IZ1); + }; + + if (bBroadPassageFloor) + { + for (const FVoxelPassage& Passage : Passages) + { + if (HasAnyLandingSample(Passage.UpperLanding) + || HasAnyLandingSample(Passage.LowerLanding)) + { + return true; + } + + if (!Passage.bWalkableTunnelContract) + { + continue; + } + if (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + return true; + } + + constexpr float Pad = 1.0f; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + if (!VoxelMath::IsFinite(A.X) || !VoxelMath::IsFinite(A.Y) + || !VoxelMath::IsFinite(A.Z) || !VoxelMath::IsFinite(B.X) + || !VoxelMath::IsFinite(B.Y) || !VoxelMath::IsFinite(B.Z) + || !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex]) + || !VoxelMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1])) + { + return true; + } + const float SupportRadius = FMath::Max( + FMath::Min( + FMath::Abs(Passage.ControlRadii[SegmentIndex]), + FMath::Abs(Passage.ControlRadii[SegmentIndex + 1])) - 0.5f, + VoxelPassageGeometry::PlayerRadiusVoxels); + const float FloorA = VoxelPassageGeometry::TunnelFloorZ( + A, Passage.ControlRadii[SegmentIndex]); + const float FloorB = VoxelPassageGeometry::TunnelFloorZ( + B, Passage.ControlRadii[SegmentIndex + 1]); + const float MinX = FMath::Min(A.X, B.X) - SupportRadius - Pad; + const float MaxX = FMath::Max(A.X, B.X) + SupportRadius + Pad; + const float MinY = FMath::Min(A.Y, B.Y) - SupportRadius - Pad; + const float MaxY = FMath::Max(A.Y, B.Y) + SupportRadius + Pad; + const float MinZ = FMath::Min(FloorA, FloorB) + - VoxelPassageGeometry::LandingFloorThicknessVoxels - Pad; + const float MaxZ = FMath::Max(FloorA, FloorB) + Pad; + if (!VoxelMath::IsFinite(MinX) || !VoxelMath::IsFinite(MaxX) + || !VoxelMath::IsFinite(MinY) || !VoxelMath::IsFinite(MaxY) + || !VoxelMath::IsFinite(MinZ) || !VoxelMath::IsFinite(MaxZ)) + { + return true; + } + + int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1; + if (!GetRange(FMath::Max((float)VoxelBox.Min.X, MinX), + FMath::Min((float)VoxelBox.Max.X, MaxX), IX0, IX1) + || !GetYRange(FMath::Max((float)VoxelBox.Min.Y, MinY), + FMath::Min((float)VoxelBox.Max.Y, MaxY), IY0, IY1) + || !GetZRange(FMath::Max((float)VoxelBox.Min.Z, MinZ), + FMath::Min((float)VoxelBox.Max.Z, MaxZ), IZ0, IZ1)) + { + continue; + } + + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = IY0; IY <= IY1; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const FVector Position( + (float)LatticeOrigin.X + (float)(IX * Step), Y, Z); + if (VF_IsWalkableTunnelFloor(Passage, Position)) + { + return true; + } + } + } + } + } + } + } + + if (bBroadOriginFloor) + { + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition); + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + TopZ, BottomZ, Seal, OriginSpineRadius); + if (!Geometry.bValid) + { + continue; + } + const float HalfWidth = FMath::Max(Geometry.HalfWidth + - VoxelPassageGeometry::MetersToVoxels(0.25f), 0.0f); + int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1; + if (VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.X, -HalfWidth - 0.0f), + FMath::Min((float)VoxelBox.Max.X, HalfWidth + 0.0f), + (float)LatticeOrigin.X, Step, IX0, IX1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Y, -HalfWidth - 0.0f), + FMath::Min((float)VoxelBox.Max.Y, HalfWidth + 0.0f), + (float)LatticeOrigin.Y, Step, IY0, IY1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Z, + Geometry.FloorZ - Geometry.FloorThickness), + FMath::Min((float)VoxelBox.Max.Z, + Geometry.FloorZ + KINDA_SMALL_NUMBER), + (float)LatticeOrigin.Z, Step, IZ0, IZ1)) + { + return true; + } + } + } + return false; +} + +bool UVoxelStrateManager::AnyOriginLandingNearBox( + const FVector& MinVoxel, const FVector& MaxVoxel) const +{ + if (OriginSpineRadius <= 0.0f) return false; + const FBox VoxelBox(MinVoxel, MaxVoxel); + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(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(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(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(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(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(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(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(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition); + if (VoxelPassageGeometry::OriginLandingFloorTouchesLattice( + VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius)) + { + return true; + } + } + return false; +} + +//============================================================================= +// QUERIES +//============================================================================= + +int32 UVoxelStrateManager::FindSlotIndexForChunkZ(int32 ChunkZ) const +{ + // Linear search through strate layout. + // With ~10-20 strates this is fine. If we ever have hundreds, + // switch to binary search (layout is sorted by Z). + for (int32 i = 0; i < StrateLayout.Num(); i++) + { + const FStrateSlot& Slot = StrateLayout[i]; + if (ChunkZ <= Slot.TopChunkZ && ChunkZ >= Slot.BottomChunkZ) + { + return i; + } + } + return -1; +} + +UVoxelStrateDefinition* UVoxelStrateManager::GetStrateAt(float WorldZ) const +{ + // Convert world Z to chunk Z coordinate + int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx >= 0) + { + return StrateLayout[SlotIdx].Definition; + } + return nullptr; +} + +int32 UVoxelStrateManager::GetStrateIndex(float WorldZ) const +{ + int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx >= 0) + { + return StrateLayout[SlotIdx].StrateIndex; + } + return -1; +} + +UVoxelStrateDefinition* UVoxelStrateManager::GetStrateForChunk(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx >= 0) + { + return StrateLayout[SlotIdx].Definition; + } + return nullptr; +} + +bool UVoxelStrateManager::GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const +{ + // Strate-aware vertical streaming. Returns the chunk-Z span of the strate containing + // ChunkZ; false if ChunkZ is in the inter-strate gap (or outside the layout) — there the + // caller leaves the vertical view UNCLAMPED, since the gap is a brief see-both-sides + // descent transition. TopChunkZ > BottomChunkZ (Z decreases downward). + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx < 0) + { + return false; + } + OutTopChunkZ = StrateLayout[SlotIdx].TopChunkZ; + OutBottomChunkZ = StrateLayout[SlotIdx].BottomChunkZ; + return true; +} + +ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const +{ + // Look up which slot this chunk falls into. + // If outside all strates (above or below), default to TunnelNetwork — + // the fallback density path will produce solid rock anyway. + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + return ECaveGeneratorType::TunnelNetwork; + } + +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(StrateLayout[SlotIdx].StrateIndex)) + { + return Override->Archetype; + } +#endif + + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + return SeasonStrates[SlotIdx].Archetype; + } + + return StrateLayout[SlotIdx].Definition->GeneratorType; +} + +bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; } + + // Every strate is generated by the operator stack: authored definitions, cooked-season entries + // (recipe or native) and editor composer candidates alike. The slot definition carries the + // archetype in every case. + const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition; + + // LA LISTE DES ARCHÉTYPES — le seul endroit où elle est écrite. + // THE ARCHETYPE LIST, written down exactly once. + switch (Def->GeneratorType) + { + case ECaveGeneratorType::Maze: return true; + case ECaveGeneratorType::FlatPlain: // les deux partagent + case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack) + + case ECaveGeneratorType::SurfaceWorld: + // Pas de garde « pas de biomes » : le combiner `Mask` existe, donc une strate à biomes + // mélange bien ses hauteurs comme le chemin d'origine. + // No no-biome guard: the Mask combiner exists, so a biome strate blends its heights + // exactly as the original path does. + return true; + + case ECaveGeneratorType::VerticalShafts: return true; // 3 ops repris de Maze tels quels + + case ECaveGeneratorType::FloatingIslands: + // La pile qui tourne à l'ENVERS : source de VIDE + fill, au lieu de source de ROC + // + carve, avec les MÊMES opérateurs au signe près. + return true; + + case ECaveGeneratorType::Underwater: + // ⚠️ AUCUNE PILE À ELLE : `Underwater` EST `TunnelNetwork` plus un drapeau d'eau consommé + // côté rendu. `VF_BuildOpStackForChunk` les met dans le même `case`, et `WaterLevelRelative` n'est lu + // que par `GetWaterLevel*` de ce manager — jamais par la densité (vérifié, pas supposé). + case ECaveGeneratorType::TunnelNetwork: + // Le plus gros : squelette SDF → douze modificateurs de détail → override d'op par salle, + // 19 opérateurs, dont `FRoomGraphSource` qui **APPELLE** `BuildChunkCache`/`EvaluateSDFCached` + // au lieu de les transcrire — c'est là que vit la discipline d'invariance de fenêtre + // d'ARCHITECTURE §8.4 ; en forker une copie la casserait. + return true; + + default: return false; + } +} + +bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const +{ + if (StrateLayout.Num() == 0) return false; + + // Above the top strate or below the bottom strate = open air, NOT a gap. + const int32 StackTop = StrateLayout[0].TopChunkZ; + const int32 StackBottom = StrateLayout.Last().BottomChunkZ; + if (ChunkCoord.Z > StackTop || ChunkCoord.Z < StackBottom) return false; + + // Inside the stack's Z span but not in any strate slot → it's a bedrock gap. + return FindSlotIndexForChunkZ(ChunkCoord.Z) < 0; +} + +FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVector& ChunkCoord) const +{ + // Fallback: empty params with BaseDensity < 0 → all-air outside strate range. + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FSlabGenerationParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + + // Copy the designer-authored slab params from the strate definition, unless an editor + // composer candidate owns this slot. + FSlabGenerationParams Result; + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + Result = SeasonStrates[SlotIdx].Params.SlabParams; + } +#if WITH_EDITOR + else if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(Slot.StrateIndex)) + { + Result = (Override->Archetype == ECaveGeneratorType::FlatPlain + || Override->Archetype == ECaveGeneratorType::CrystalChamber) + ? Override->Params.SlabParams + : Slot.Definition->SlabParams; + } + else +#endif + { + Result = Slot.Definition->SlabParams; + } + + // Fill in the runtime Z bounds (voxel coordinates, same convention as + // FStrateGenerationParams::StrateTopWorldZ / StrateBottomWorldZ). + // TopChunkZ+1 because the top chunk's CEILING is at (TopChunkZ+1)*CHUNK_SIZE. + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + + return Result; +} + +//============================================================================= +// PER-ARCHETYPE PARAM GETTERS +//============================================================================= +// Each mirrors GetSlabParamsForChunk: copy designer params, fill runtime Z bounds. +// No cross-boundary blending — archetypes meet at Hard boundaries. + +FMazeGenerationParams UVoxelStrateManager::GetMazeParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FMazeGenerationParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FMazeGenerationParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.MazeParams : Slot.Definition->MazeParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::Maze) + { + Result = Override->Params.MazeParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} + +FSurfaceGenerationParams UVoxelStrateManager::GetSurfaceParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FSurfaceGenerationParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FSurfaceGenerationParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.SurfaceParams : Slot.Definition->SurfaceParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::SurfaceWorld) + { + Result = Override->Params.SurfaceParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} + +FVerticalShaftParams UVoxelStrateManager::GetVerticalShaftParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FVerticalShaftParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FVerticalShaftParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.VerticalShaftParams : Slot.Definition->VerticalShaftParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::VerticalShafts) + { + Result = Override->Params.VerticalShaftParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} + +FFloatingIslandParams UVoxelStrateManager::GetFloatingIslandParamsForChunk(const FIntVector& ChunkCoord) const +{ + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FFloatingIslandParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FFloatingIslandParams Result = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.FloatingIslandParams : Slot.Definition->FloatingIslandParams; +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = FindComposerOverride(Slot.StrateIndex)) + { + if (Override->Archetype == ECaveGeneratorType::FloatingIslands) + { + Result = Override->Params.FloatingIslandParams; + } + } +#endif + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + return Result; +} + +FBiomeContext UVoxelStrateManager::GetBiomeContextForChunk(const FIntVector& ChunkCoord) const +{ + FBiomeContext Out; + + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) return Out; + + const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition; + if (Def->Biomes.Num() == 0) return Out; // biomes disabled for this strate + + Out.Map = Def->BiomeMapParams; + Out.Biomes.Reserve(Def->Biomes.Num()); + for (int32 i = 0; i < Def->Biomes.Num(); ++i) + { + const UVoxelBiomeDefinition* B = Def->Biomes[i]; + if (!B) continue; // skip null entries (keep original index for content lookup) + + FBiomeResolved R; + R.Index = i; + R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax; + R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax; + R.DebugColor = B->DebugColor.ToFColor(true); + R.MaterialPaletteIndex = B->MaterialPaletteIndex; + Out.Biomes.Add(R); + } + return Out; +} + +bool UVoxelStrateManager::GetStrateUnrealZRange(float WorldZ, float& OutTopZ, float& OutBottomZ) const +{ + const int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx < 0) return false; + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + // Voxel-space Z bounds → Unreal units. Ceiling = top chunk's upper edge. + OutTopZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE * VOXEL_SIZE; + OutBottomZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE * VOXEL_SIZE; + return true; +} + +FStrateDisturbanceParams UVoxelStrateManager::GetDisturbanceParamsForChunk(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + return FStrateDisturbanceParams(); // all features disabled + } + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FStrateDisturbanceParams Result = Slot.Definition->Disturbances; + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + return Result; +} + +float UVoxelStrateManager::GetWaterLevelWorldZForChunk(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) return -FLT_MAX; + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + const UVoxelStrateDefinition* Def = Slot.Definition; + if (!Def->bHasWater) return -FLT_MAX; + + // Pull the relative level from whichever archetype owns water. + float Rel = 0.0f; + const ECaveGeneratorType Archetype = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Archetype : Def->GeneratorType; + switch (Archetype) + { + case ECaveGeneratorType::SurfaceWorld: + Rel = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.SurfaceParams.WaterLevelRelative + : Def->SurfaceParams.WaterLevelRelative; + break; + default: + Rel = SeasonStrates.IsValidIndex(SlotIdx) + ? SeasonStrates[SlotIdx].Params.TunnelNetworkParams.WaterLevelRelative + : Def->GenerationParams.WaterLevelRelative; + break; + } + if (Rel <= 0.0f) return -FLT_MAX; + + const float BottomZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + const float TopZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + return FMath::Lerp(BottomZ, TopZ, FMath::Clamp(Rel, 0.0f, 1.0f)); +} + +FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + + // If outside all strates, return negative density → guaranteed air. + // BaseDensity must be < 0 because IsoLevel is 0.0 and density >= IsoLevel = solid. + if (SlotIdx < 0) + { + FStrateGenerationParams Empty; + Empty.BaseDensity = -1.0f; // Negative → air after negation + Empty.WormStrength = 0.0f; + Empty.RoomDensity = 0.0f; // No rooms outside strates + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + + // This is the one final resolution gate for the asset-owned worm switch. It is deliberately + // applied after composer/season replacements and after boundary blending: a disabled current + // strate can never be re-enabled by a rolled candidate or by an enabled neighbour. + const auto ResolveFinalParams = [&Slot](FStrateGenerationParams Result) + { + if (VF_WormsForceOff() + || (Slot.Definition != nullptr && !Slot.Definition->bEnableWorms)) + { + Result.WormStrength = 0.0f; + } + Result = VF_ApplyRuntimeRoughnessOverrides(Result); + if (VoxelDensityAblation::IsCaveWarpOff()) + { + Result.CaveWarpStrength = 0.0f; + } + return Result; + }; + +#if WITH_EDITOR + if (const FVoxelStrateComposerSlotOverride* Override = + FindComposerOverride(Slot.StrateIndex)) + { + FStrateGenerationParams Result = Override->Params.TunnelNetworkParams; + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + // A candidate is a hard replacement of this slot. Do not blend its rolled vector with an + // authored neighbour at a boundary; this is also how the offline fixture measures it. + return ResolveFinalParams(Result); + } +#endif + + if (SeasonStrates.IsValidIndex(SlotIdx)) + { + FStrateGenerationParams Result = SeasonStrates[SlotIdx].Params.TunnelNetworkParams; + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)Slot.BottomChunkZ * CHUNK_SIZE; + // Season vectors are already final, measured slot records. Vertical structural posts own + // their boundary; blending them with another selected recipe would describe neither one. + return ResolveFinalParams(Result); + } + + FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition); + + //========================================================================= + // SET STRATE BOUNDARY Z VALUES + //========================================================================= + // The density function needs to know the strate's Z range (in voxel coords) + // to seal the top and bottom with solid rock. This prevents caves from + // carving through strate boundaries. + // + // TopChunkZ=0, CHUNK_SIZE=32: top of the strate = chunk 0's top edge = voxel Z=32 + // BottomChunkZ=-3: bottom of the strate = chunk -3's bottom edge = voxel Z=-3*32 = -96 + BaseParams.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + BaseParams.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + + //========================================================================= + // BOUNDARY BLENDING (transition-type aware) + //========================================================================= + // If this chunk is near a strate boundary, apply the appropriate transition + // based on the UPPER strate's TransitionType setting. + // + // Three transition styles: + // + // GRADIENT (default): + // Classic linear lerp of all params across TransitionBlendChunks. Smooth, + // invisible boundary. Cave shape morphs gradually from one strate + // to the next over several chunks. + // + // HARD: + // No blending at all — params switch instantly at the boundary. + // The abrupt change in density, room size, roughness, etc. creates + // a natural cliff, ledge, or visible material discontinuity. + // TransitionBlendChunks is ignored (effectively 0). + // + // INTERLEAVED: + // 3D Perlin noise warps the effective boundary Z position per XY column. + // Some columns transition early (fingers of the lower strate reach UP), + // others late (fingers of the upper strate reach DOWN). The Z frequency + // is intentionally low so the fingers are horizontal — wide, flat + // intrusions rather than vertical spikes. + // + // WHICH STRATE'S TRANSITION TYPE IS USED: + // At the bottom boundary of strate N (between N and N+1), we use + // strate N's (the upper strate's) TransitionType. This is consistent: + // each strate definition controls what happens at its lower edge. + // At the top boundary of strate N (between N-1 and N), we use + // strate N-1's TransitionType (the strate above controls its lower edge). + + //--------------------------------------------------------------------- + // CHECK BOTTOM BOUNDARY (transitioning to strate below) + //--------------------------------------------------------------------- + // DistFromBottom = how many chunks above the bottom edge of this strate. + // When 0, we're right at the boundary. When == TransitionBlendChunks, we're at + // the outer edge of the transition zone. + int32 DistFromBottom = ChunkCoord.Z - Slot.BottomChunkZ; + + if (SlotIdx + 1 < StrateLayout.Num()) + { + // The upper strate (this one) controls the transition type at its lower edge + const EVoxelStrateTransition TransType = Slot.Definition->TransitionType; + + // Per-definition blend distance. + const int32 EffectiveBlend = Slot.Definition->TransitionBlendChunks; + + // Prepare the neighbor's params (only used for Gradient and Interleaved) + const FStrateSlot& BelowSlot = StrateLayout[SlotIdx + 1]; + + switch (TransType) + { + case EVoxelStrateTransition::Hard: + { + // HARD TRANSITION: No blending. The current strate's params apply + // all the way to the boundary with zero transition zone. + // The abrupt param change (different densities, room sizes, etc.) + // creates a natural cliff or ledge — no special density boost needed. + // We simply skip blending and fall through to the "return BaseParams" below. + break; + } + + case EVoxelStrateTransition::Gradient: + { + // GRADIENT TRANSITION: Classic smooth lerp across the blend zone. + // Alpha goes from 0 (at the outer edge of the zone) to 1 (right at boundary). + if (EffectiveBlend > 0 && DistFromBottom < EffectiveBlend) + { + FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition); + BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE; + BelowParams.StrateBottomWorldZ = (float)(BelowSlot.BottomChunkZ) * CHUNK_SIZE; + + // Linear alpha: 0 at EffectiveBlend chunks away, 1 at the boundary + float Alpha = 1.0f - ((float)DistFromBottom / (float)EffectiveBlend); + Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f); + + return ResolveFinalParams( + FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha)); + } + break; + } + + case EVoxelStrateTransition::Interleaved: + { + if (EffectiveBlend <= 0) { break; } + + // INTERLEAVED TRANSITION: 3D noise warps the effective boundary Z. + // + // Instead of a flat boundary plane, the boundary becomes a wavy 3D surface. + // For each XY position, a Perlin noise sample offsets the boundary Z by + // up to ±2 chunks. Where the noise pushes the boundary UP, the lower strate's + // params appear earlier (its "fingers" reach into the upper strate). Where + // the noise pushes DOWN, the upper strate's params persist longer. + // + // The Z frequency is intentionally 3x lower than XY frequency so the fingers + // are horizontal slabs rather than vertical spikes — this matches how real + // geological intrusions look (wide, flat, layered). + // + // WarpAmplitude of 2.0 means the boundary can shift ±2 chunks from its + // true position. Combined with EffectiveBlend for the transition width, + // we need to check a wider zone: EffectiveBlend + WarpAmplitude. + const float WarpAmplitude = 2.0f; // Max boundary offset in chunks + const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude); + + if (DistFromBottom < CheckRange) + { + FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition); + BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE; + BelowParams.StrateBottomWorldZ = (float)(BelowSlot.BottomChunkZ) * CHUNK_SIZE; + + // Sample 3D Perlin noise to warp the boundary position. + // XY frequency 0.15 gives medium-scale variation (~6-7 chunks per cycle). + // Z frequency 0.05 gives slow vertical change — horizontal finger shapes. + // CachedSeed offsets ensure each world has unique finger patterns. + float WarpNoise = FMath::PerlinNoise3D(FVector( + ChunkCoord.X * 0.15f + CachedSeed * 0.01f, + ChunkCoord.Y * 0.15f + CachedSeed * 0.017f, + ChunkCoord.Z * 0.05f // Lower Z frequency for horizontal "fingers" + )) * VOXEL_NOISE_SCALE; + + // Offset the distance from boundary by the noise * amplitude. + // Positive noise → boundary pushed up → lower strate appears earlier. + // Negative noise → boundary pushed down → upper strate persists longer. + float WarpedDist = (float)DistFromBottom + WarpNoise * WarpAmplitude; + + // Compute alpha from the warped distance (same formula as Gradient, + // but using the noise-displaced distance instead of the true distance) + float Alpha = 1.0f - FMath::Clamp(WarpedDist / (float)EffectiveBlend, 0.0f, 1.0f); + + // Only blend if alpha > 0 (we're inside the warped transition zone) + if (Alpha > 0.0f) + { + return ResolveFinalParams( + FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha)); + } + } + break; + } + } + } + + //--------------------------------------------------------------------- + // CHECK TOP BOUNDARY (transitioning to strate above) + //--------------------------------------------------------------------- + // Mirror logic: the ABOVE strate's TransitionType controls its lower edge, + // which is this strate's upper edge. So we read from StrateLayout[SlotIdx-1]. + int32 DistFromTop = Slot.TopChunkZ - ChunkCoord.Z; + + if (SlotIdx > 0) + { + // The strate ABOVE controls the transition at its lower edge (= our upper edge) + const FStrateSlot& AboveSlot = StrateLayout[SlotIdx - 1]; + const EVoxelStrateTransition TransType = AboveSlot.Definition->TransitionType; + const int32 EffectiveBlend = AboveSlot.Definition->TransitionBlendChunks; + + switch (TransType) + { + case EVoxelStrateTransition::Hard: + { + // No blending — fall through to return BaseParams + break; + } + + case EVoxelStrateTransition::Gradient: + { + if (EffectiveBlend > 0 && DistFromTop < EffectiveBlend) + { + FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition); + AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE; + AboveParams.StrateBottomWorldZ = (float)(AboveSlot.BottomChunkZ) * CHUNK_SIZE; + + float Alpha = 1.0f - ((float)DistFromTop / (float)EffectiveBlend); + Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f); + + return ResolveFinalParams( + FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha)); + } + break; + } + + case EVoxelStrateTransition::Interleaved: + { + if (EffectiveBlend <= 0) { break; } + + const float WarpAmplitude = 2.0f; + const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude); + + if (DistFromTop < CheckRange) + { + FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition); + AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE; + AboveParams.StrateBottomWorldZ = (float)(AboveSlot.BottomChunkZ) * CHUNK_SIZE; + + // Same noise function but with a different seed offset to avoid + // symmetry between top and bottom boundaries of adjacent strates + float WarpNoise = FMath::PerlinNoise3D(FVector( + ChunkCoord.X * 0.15f + CachedSeed * 0.013f, + ChunkCoord.Y * 0.15f + CachedSeed * 0.023f, + ChunkCoord.Z * 0.05f + )) * VOXEL_NOISE_SCALE; + + float WarpedDist = (float)DistFromTop + WarpNoise * WarpAmplitude; + float Alpha = 1.0f - FMath::Clamp(WarpedDist / (float)EffectiveBlend, 0.0f, 1.0f); + + if (Alpha > 0.0f) + { + return ResolveFinalParams( + FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha)); + } + } + break; + } + } + } + + // Not near any boundary (or Hard transition) — use this strate's params directly + return ResolveFinalParams(BaseParams); +} + +//============================================================================= +// BUILD PARAMS FROM DEFINITION +//============================================================================= +// Returns the definition's base GenerationParams (cave shape, SDF, roughness, etc.). +// +// NOTE: Terrain op fields (TerraceStepHeight, ColumnDensity, etc.) are not +// merged here. They default to 0 (disabled) in the base params, and are applied +// per-room during BuildChunkCache() via FCachedRoom::RoomOp — each room hash-rolls +// one op from the strate's probability pool (FStrateTerrainOpEntry::Probability). +// +// Assets are still pre-loaded in Initialize() so BuildChunkCache can resolve +// soft pointers (Entry.Operation.Get()) without a disk read during generation. + +FStrateGenerationParams UVoxelStrateManager::BuildParamsFromDefinition(const UVoxelStrateDefinition* Definition) +{ + if (!Definition) return FStrateGenerationParams(); + + // Base params only — terrain op fields stay 0 until per-room assignment. Resolve the asset + // switch here as well as at the final return so a disabled neighbour cannot leak worm strength + // back through a boundary blend. + FStrateGenerationParams Result = Definition->GenerationParams; + if (VF_WormsForceOff() || !Definition->bEnableWorms) + { + Result.WormStrength = 0.0f; + } + return Result; +} + diff --git a/Standalone/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Standalone/Source/VoxelForge/Private/VoxelStrateMeasure.cpp new file mode 100644 index 0000000..3474b9a --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -0,0 +1,3842 @@ +// Read-only, deterministic strate measurement and connectivity checks. + +#include "VoxelStrateMeasure.h" + +#if WITH_EDITOR + +#include "VoxelGenerator.h" +#include "VoxelStrateManager.h" +#include "VoxelTypes.h" +#include "Async/ParallelFor.h" + +namespace VoxelStrateMeasurePrivate +{ + struct FSampleGrid + { + int32 NumX = 0; + int32 NumY = 0; + int32 NumZ = 0; + int32 SampleStep = 1; + int32 CellCount = 0; + + float MinX = 0.0f; + float MinY = 0.0f; + float MinZ = 0.0f; + float MaxX = 0.0f; + float MaxY = 0.0f; + float MaxZ = 0.0f; + float ExtentX = 0.0f; + float ExtentY = 0.0f; + float ExtentZ = 0.0f; + + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + + // 1 = air, 0 = solid. The polarity is deliberately explicit at the sampling site below. + TArray Air; + // Exact scalar samples retained when the player-fit stencil needs a bounded local + // surface/clearance interpolation, or when an explicit preview capture requests them. + TArray Density; + + FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const + { + return static_cast( + static_cast(X) + + static_cast(NumX) * (static_cast(Y) + + static_cast(NumY) * static_cast(Z))); + } + + FVector CellCenter(int32 X, int32 Y, int32 Z) const + { + const float Step = static_cast(SampleStep); + const float XOffset = FMath::Min( + static_cast(X) * Step + 0.5f * Step, ExtentX - 0.5f); + const float YOffset = FMath::Min( + static_cast(Y) * Step + 0.5f * Step, ExtentY - 0.5f); + const float ZOffset = FMath::Min( + static_cast(Z) * Step + 0.5f * Step, ExtentZ - 0.5f); + return FVector(MinX + XOffset, MinY + YOffset, MinZ + ZOffset); + } + + bool ContainsPoint(const FVector& Point) const + { + // The upper bounds are exclusive: a point on the box edge belongs to the next box, + // not to the last coarse cell in this measurement. + return Point.X >= MinX && Point.X < MaxX + && Point.Y >= MinY && Point.Y < MaxY + && Point.Z >= MinZ && Point.Z < MaxZ; + } + }; + + int64 CeilDivPositive(int64 Numerator, int64 Denominator) + { + return (Numerator + Denominator - 1) / Denominator; + } + + bool Refuse(FString& OutReason, const TCHAR* Reason) + { + OutReason = Reason; + return false; + } + + bool BuildSampleGrid( + const UVoxelGenerator* Generator, + const UVoxelStrateManager* Manager, + const IVoxelStrateDensitySampler* Sampler, + int32 StrateIndex, + int32 ExplicitBottomWorldZ, + int32 ExplicitTopWorldZ, + float ExplicitBoundarySealThickness, + bool bUseExplicitBounds, + const FVoxelStrateMeasureSettings& Settings, + bool bCaptureDensity, + FSampleGrid& OutGrid, + FString& OutReason) + { + if (bUseExplicitBounds) + { + if (Sampler == nullptr) + { + return Refuse(OutReason, TEXT("An explicit measurement window requires a density sampler.")); + } + } + else if (Generator == nullptr || Manager == nullptr) + { + return Refuse(OutReason, TEXT("A generator and manager are required for a layout measurement.")); + } + if (Settings.SampleStep <= 0) + { + return Refuse(OutReason, TEXT("SampleStep must be greater than zero.")); + } + const bool bHasCoverPointA = Settings.CoverPointA.IsSet(); + const bool bHasCoverPointB = Settings.CoverPointB.IsSet(); + if (bHasCoverPointA != bHasCoverPointB) + { + return Refuse(OutReason, TEXT("CoverPointA and CoverPointB must be set together.")); + } + const bool bUseFittedWindow = bHasCoverPointA && bHasCoverPointB; + if (!bUseFittedWindow && Settings.RadiusInVoxels <= 0) + { + return Refuse(OutReason, TEXT("RadiusInVoxels must be greater than zero.")); + } + if (Settings.MaxCells <= 0) + { + return Refuse(OutReason, TEXT("MaxCells must be greater than zero.")); + } + if (Settings.HeadroomCells < 0) + { + return Refuse(OutReason, TEXT("HeadroomCells cannot be negative.")); + } + if (!bUseFittedWindow + && (!FMath::IsFinite(Settings.CenterXY.X) || !FMath::IsFinite(Settings.CenterXY.Y))) + { + return Refuse(OutReason, TEXT("CenterXY must contain finite voxel coordinates.")); + } + if (bUseFittedWindow) + { + const FVector2D& PointA = Settings.CoverPointA.GetValue(); + const FVector2D& PointB = Settings.CoverPointB.GetValue(); + if (!FMath::IsFinite(PointA.X) || !FMath::IsFinite(PointA.Y) + || !FMath::IsFinite(PointB.X) || !FMath::IsFinite(PointB.Y)) + { + return Refuse(OutReason, TEXT("Cover points must contain finite voxel coordinates.")); + } + if (!FMath::IsFinite(Settings.CoverMarginVoxels) + || Settings.CoverMarginVoxels < 0.0f) + { + return Refuse(OutReason, TEXT("CoverMarginVoxels must be finite and non-negative.")); + } + } + int64 StrateBottomZ = 0; + int64 StrateTopZ = 0; + if (bUseExplicitBounds) + { + StrateBottomZ = static_cast(ExplicitBottomWorldZ); + StrateTopZ = static_cast(ExplicitTopWorldZ); + if (StrateTopZ <= StrateBottomZ) + { + return Refuse(OutReason, TEXT("The explicit strate has no positive vertical extent.")); + } + } + else + { + const TArray& Layout = Manager->GetLayout(); + if (!Layout.IsValidIndex(StrateIndex)) + { + return Refuse(OutReason, TEXT("StrateIndex is outside the manager layout.")); + } + + const FStrateSlot& Slot = Layout[StrateIndex]; + if (Slot.Definition == nullptr) + { + return Refuse(OutReason, TEXT("The requested strate has no definition.")); + } + if (Slot.TopChunkZ <= Slot.BottomChunkZ) + { + return Refuse(OutReason, TEXT("The requested strate has no positive vertical extent.")); + } + StrateBottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + StrateTopZ = (static_cast(Slot.TopChunkZ) + 1) * CHUNK_SIZE; + } + const int64 StrateHeight = StrateTopZ - StrateBottomZ; + if (StrateHeight <= 0) + { + return Refuse(OutReason, TEXT("The requested strate has no positive voxel height.")); + } + + // Resolve the margin from the same blended parameter query used by generation. The + // representative chunk is deliberately in the middle of this slot and uses the sample + // window centre's XY chunk, so a future non-Hard transition resolves through the manager + // exactly as generation does at that representative location. + FVector2D WindowCenter = Settings.CenterXY; + double MinX64 = 0.0; + double MaxX64 = 0.0; + double MinY64 = 0.0; + double MaxY64 = 0.0; + if (bUseFittedWindow) + { + const FVector2D& PointA = Settings.CoverPointA.GetValue(); + const FVector2D& PointB = Settings.CoverPointB.GetValue(); + const double Margin = static_cast(Settings.CoverMarginVoxels); + MinX64 = FMath::Min(static_cast(PointA.X), static_cast(PointB.X)) - Margin; + MaxX64 = FMath::Max(static_cast(PointA.X), static_cast(PointB.X)) + Margin; + MinY64 = FMath::Min(static_cast(PointA.Y), static_cast(PointB.Y)) - Margin; + MaxY64 = FMath::Max(static_cast(PointA.Y), static_cast(PointB.Y)) + Margin; + if (Settings.bIncludeOriginInCoverWindow) + { + // The fitted box is a route-coverage contract, not merely an endpoint box. An + // origin-rooted graph may have its only mouth-to-mouth path through this spine. + // Expand the requested bounds before the MaxCells check below; never crop the + // route after discovering that the full box is too large. + MinX64 = FMath::Min(MinX64, 0.0); + MaxX64 = FMath::Max(MaxX64, 0.0); + MinY64 = FMath::Min(MinY64, 0.0); + MaxY64 = FMath::Max(MaxY64, 0.0); + } + if (Settings.bForceOriginColumnInCoverWindow) + { + // Upper bounds are exclusive. The ordinary origin policy can therefore stop at + // exactly X=0/Y=0 when both mouths are on one side; a diagnostic column probe + // needs one real sampled column straddling the origin instead. + MinX64 = FMath::Min(MinX64, -1.0); + MaxX64 = FMath::Max(MaxX64, 1.0); + MinY64 = FMath::Min(MinY64, -1.0); + MaxY64 = FMath::Max(MaxY64, 1.0); + } + WindowCenter = FVector2D( + static_cast(0.5 * (MinX64 + MaxX64)), + static_cast(0.5 * (MinY64 + MaxY64))); + } + else + { + MinX64 = static_cast(Settings.CenterXY.X) + - static_cast(Settings.RadiusInVoxels); + MaxX64 = static_cast(Settings.CenterXY.X) + + static_cast(Settings.RadiusInVoxels); + MinY64 = static_cast(Settings.CenterXY.Y) + - static_cast(Settings.RadiusInVoxels); + MaxY64 = static_cast(Settings.CenterXY.Y) + + static_cast(Settings.RadiusInVoxels); + } + const double XYExtentX = MaxX64 - MinX64; + const double XYExtentY = MaxY64 - MinY64; + if (!FMath::IsFinite(MinX64) || !FMath::IsFinite(MaxX64) + || !FMath::IsFinite(MinY64) || !FMath::IsFinite(MaxY64) + || !FMath::IsFinite(XYExtentX) || !FMath::IsFinite(XYExtentY) + || XYExtentX < 1.0 || XYExtentY < 1.0) + { + return Refuse(OutReason, TEXT("The requested XY window is not a finite positive voxel box.")); + } + + FStrateGenerationParams RepresentativeParams; + if (bUseExplicitBounds) + { + RepresentativeParams.BoundarySealThickness = ExplicitBoundarySealThickness; + } + else + { + const TArray& Layout = Manager->GetLayout(); + const FStrateSlot& Slot = Layout[StrateIndex]; + const int32 MidChunkZ = Slot.BottomChunkZ + + (Slot.TopChunkZ - Slot.BottomChunkZ) / 2; + const FIntVector RepresentativeChunk( + FMath::FloorToInt(WindowCenter.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(WindowCenter.Y / static_cast(CHUNK_SIZE)), + MidChunkZ); + RepresentativeParams = Manager->GetGenerationParams(RepresentativeChunk); + } + + const int64 MaxMargin64 = FMath::Max(1, StrateHeight / 4); + const int32 MaxMargin = static_cast( + FMath::Min(MaxMargin64, static_cast(INT32_MAX))); + int32 RequestedMargin = Settings.InteriorMarginVoxels; + if (RequestedMargin < 0) + { + const float DoubleSealThickness = 2.0f * RepresentativeParams.BoundarySealThickness; + if (FMath::IsNaN(RepresentativeParams.BoundarySealThickness)) + { + return Refuse(OutReason, TEXT("BoundarySealThickness is NaN; the interior margin cannot be derived.")); + } + + // Avoid converting an infinite value to int. A positive overflow is still an + // intentionally absurd seal request and therefore resolves to the safe maximum; + // negative infinity resolves to the minimum. + if (!FMath::IsFinite(DoubleSealThickness)) + { + RequestedMargin = DoubleSealThickness > 0.0f ? MaxMargin : 1; + } + else if (DoubleSealThickness >= static_cast(INT32_MAX)) + { + RequestedMargin = MaxMargin; + } + else if (DoubleSealThickness <= static_cast(INT32_MIN)) + { + RequestedMargin = 0; + } + else + { + RequestedMargin = FMath::CeilToInt(DoubleSealThickness); + } + } + const int32 ResolvedMargin = FMath::Clamp(RequestedMargin, 0, MaxMargin); + + const int64 InteriorMinZ = StrateBottomZ + static_cast(ResolvedMargin); + const int64 InteriorMaxZ = StrateTopZ - static_cast(ResolvedMargin); + const int64 InteriorHeight = InteriorMaxZ - InteriorMinZ; + if (InteriorHeight <= 0) + { + return Refuse(OutReason, TEXT("The strate has no measurable height after applying its interior margin.")); + } + if (InteriorMinZ < static_cast(INT32_MIN) + || InteriorMinZ > static_cast(INT32_MAX) + || InteriorMaxZ < static_cast(INT32_MIN) + || InteriorMaxZ > static_cast(INT32_MAX)) + { + return Refuse(OutReason, TEXT("The sampled Z window is outside the metrics struct's int32 range.")); + } + + const int64 Step = static_cast(Settings.SampleStep); + const double NumXReal = XYExtentX / static_cast(Step); + const double NumYReal = XYExtentY / static_cast(Step); + if (!FMath::IsFinite(NumXReal) || !FMath::IsFinite(NumYReal) + || NumXReal <= 0.0 || NumYReal <= 0.0 + || NumXReal > static_cast(INT32_MAX) + || NumYReal > static_cast(INT32_MAX)) + { + return Refuse(OutReason, TEXT("A sample-grid dimension exceeds the supported array size.")); + } + const int64 NumX64 = FMath::CeilToInt64(NumXReal); + const int64 NumY64 = FMath::CeilToInt64(NumYReal); + const int64 NumZ64 = CeilDivPositive(InteriorHeight, Step); + const int64 MaxCells = static_cast(Settings.MaxCells); + + if (NumX64 > INT32_MAX || NumY64 > INT32_MAX || NumZ64 > INT32_MAX) + { + return Refuse(OutReason, TEXT("A sample-grid dimension exceeds the supported array size.")); + } + if (NumX64 > MaxCells || NumY64 > MaxCells || NumZ64 > MaxCells + || NumX64 > MaxCells / NumY64) + { + return Refuse(OutReason, TEXT("The requested sample grid exceeds MaxCells; measurement refused.")); + } + + const int64 XYCells = NumX64 * NumY64; + if (XYCells > MaxCells / NumZ64) + { + return Refuse(OutReason, TEXT("The requested sample grid exceeds MaxCells; measurement refused.")); + } + const int64 CellCount64 = XYCells * NumZ64; + if (CellCount64 <= 0 || CellCount64 > MaxCells || CellCount64 > INT32_MAX) + { + return Refuse(OutReason, TEXT("The requested sample grid exceeds the bounded cell count.")); + } + + const float MinX = static_cast(MinX64); + const float MaxX = static_cast(MaxX64); + const float MinY = static_cast(MinY64); + const float MaxY = static_cast(MaxY64); + const float MinZ = static_cast(InteriorMinZ); + const float MaxZ = static_cast(InteriorMaxZ); + if (!FMath::IsFinite(MinX) || !FMath::IsFinite(MaxX) + || !FMath::IsFinite(MinY) || !FMath::IsFinite(MaxY) + || !FMath::IsFinite(MinZ) || !FMath::IsFinite(MaxZ) + || MaxX <= MinX || MaxY <= MinY || MaxZ <= MinZ) + { + return Refuse(OutReason, TEXT("The requested measurement bounds are not representable as finite voxel coordinates.")); + } + + OutGrid.NumX = static_cast(NumX64); + OutGrid.NumY = static_cast(NumY64); + OutGrid.NumZ = static_cast(NumZ64); + OutGrid.SampleStep = Settings.SampleStep; + OutGrid.CellCount = static_cast(CellCount64); + OutGrid.MinX = MinX; + OutGrid.MinY = MinY; + OutGrid.MinZ = MinZ; + OutGrid.MaxX = MaxX; + OutGrid.MaxY = MaxY; + OutGrid.MaxZ = MaxZ; + OutGrid.ExtentX = MaxX - MinX; + OutGrid.ExtentY = MaxY - MinY; + OutGrid.ExtentZ = MaxZ - MinZ; + OutGrid.ResolvedMarginVoxels = ResolvedMargin; + OutGrid.SampledMinZ = static_cast(InteriorMinZ); + OutGrid.SampledMaxZ = static_cast(InteriorMaxZ); + OutGrid.Air.SetNumUninitialized(OutGrid.CellCount); + if (bCaptureDensity) + { + OutGrid.Density.SetNumUninitialized(OutGrid.CellCount); + } + + // Each Z slab writes a disjoint contiguous region. GetDensityAt is a const, read-only + // query used concurrently by the mesher, and custom samplers are required to provide + // the same const/thread-safe contract. The non-finite flag is also per-cell so the + // diagnostic reduction below never depends on task completion order. + TArray NonFiniteFlags; + NonFiniteFlags.Init(0u, OutGrid.CellCount); + ParallelFor(OutGrid.NumZ, [&](int32 Z) + { + for (int32 Y = 0; Y < OutGrid.NumY; ++Y) + { + for (int32 X = 0; X < OutGrid.NumX; ++X) + { + const int32 CellIndex = OutGrid.Index(X, Y, Z); + const FVector Sample = OutGrid.CellCenter(X, Y, Z); + const float Density = Sampler != nullptr + ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) + : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); + if (!FMath::IsFinite(Density)) + { + NonFiniteFlags[CellIndex] = 1u; + } + + // MC convention in this codebase: negative is solid, positive is air. + if (bCaptureDensity) + { + OutGrid.Density[CellIndex] = Density; + } + OutGrid.Air[CellIndex] = Density > 0.0f ? 1u : 0u; + } + } + }); + + bool bSawNonFiniteDensity = false; + for (int32 CellIndex = 0; CellIndex < OutGrid.CellCount; ++CellIndex) + { + bSawNonFiniteDensity |= NonFiniteFlags[CellIndex] != 0u; + } + if (bSawNonFiniteDensity) + { + return Refuse(OutReason, TEXT("At least one density sample was non-finite.")); + } + return true; + } + + void ExportSampleGrid(FSampleGrid& Source, FVoxelStrateSampleGrid& Destination) + { + Destination = FVoxelStrateSampleGrid(); + Destination.bValid = true; + Destination.NumX = Source.NumX; + Destination.NumY = Source.NumY; + Destination.NumZ = Source.NumZ; + Destination.SampleStep = Source.SampleStep; + Destination.CellCount = Source.CellCount; + Destination.MinX = Source.MinX; + Destination.MinY = Source.MinY; + Destination.MinZ = Source.MinZ; + Destination.MaxX = Source.MaxX; + Destination.MaxY = Source.MaxY; + Destination.MaxZ = Source.MaxZ; + Destination.ResolvedMarginVoxels = Source.ResolvedMarginVoxels; + Destination.SampledMinZ = Source.SampledMinZ; + Destination.SampledMaxZ = Source.SampledMaxZ; + // Move, rather than copy, the already-built polarity grid. The caller owns this buffer + // only when it explicitly requested a capture. + Destination.Air = MoveTemp(Source.Air); + Destination.Density = MoveTemp(Source.Density); + Destination.PlayerFitMask.Reset(); + Destination.PlayerFitCellCount = 0; + } + + void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ) + { + const int32 Plane = Grid.NumX * Grid.NumY; + OutZ = Index / Plane; + const int32 InPlane = Index - OutZ * Plane; + OutY = InPlane / Grid.NumX; + OutX = InPlane - OutY * Grid.NumX; + } + + struct FPlayerTraversalNeighbour + { + int32 Cell = INDEX_NONE; + bool bClimb = false; + }; + + // Walk is the hard capability contract. The climb policy is used only for the diagnostic + // tier; keeping it explicit at every graph operation prevents a future caller from silently + // turning a climbable ledge into a walk-only connectivity pass. + enum class EPlayerTraversalPolicy : uint8 + { + WalkOnly, + WalkAndClimb, + }; + + bool IsEligiblePlayerFitCell( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + int32 Cell) + { + return Cell >= 0 + && Cell < Grid.CellCount + && Grid.Air[Cell] != 0u + && PlayerFitMask.IsValidIndex(Cell) + && PlayerFitMask[Cell] != 0u; + } + + bool IsClimbableSurfaceEdge( + const FSampleGrid& Grid, + int32 First, + int32 Second, + float FirstSupportHeight, + float SecondSupportHeight) + { + int32 FirstX = 0; + int32 FirstY = 0; + int32 FirstZ = 0; + DecodeIndex(Grid, First, FirstX, FirstY, FirstZ); + int32 SecondX = 0; + int32 SecondY = 0; + int32 SecondZ = 0; + DecodeIndex(Grid, Second, SecondX, SecondY, SecondZ); + if (FMath::Abs(FirstX - SecondX) + FMath::Abs(FirstY - SecondY) != 1) + { + return false; + } + + const bool bFirstIsHigher = FirstSupportHeight > SecondSupportHeight; + const int32 HigherColumnX = bFirstIsHigher ? FirstX : SecondX; + const int32 HigherColumnY = bFirstIsHigher ? FirstY : SecondY; + const float LowerHeight = FMath::Min(FirstSupportHeight, SecondSupportHeight); + const float HigherHeight = FMath::Max(FirstSupportHeight, SecondSupportHeight); + const float Step = static_cast(Grid.SampleStep); + if (!FMath::IsFinite(Step) || Step <= 0.0f + || !FMath::IsFinite(LowerHeight) || !FMath::IsFinite(HigherHeight)) + { + return false; + } + + // A climb edge must meet a solid riser, not connect two independently supported + // floating poses across an empty gap. Test the actual vertical interval between the two + // support surfaces in the higher column. The old floor/ceil/-2 indexing skipped the only + // solid sample for short quantised ledges and was especially brittle when the support + // heights came from scalar interpolation rather than an integer cell boundary. + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + const float SampleZ = Grid.MinZ + (static_cast(Z) + 0.5f) * Step; + if (SampleZ + KINDA_SMALL_NUMBER < LowerHeight + || SampleZ >= HigherHeight - KINDA_SMALL_NUMBER) + { + continue; + } + if (Grid.Air[Grid.Index(HigherColumnX, HigherColumnY, Z)] == 0u) + { + return true; + } + } + return false; + } + + bool IsPlayerTraversalEdge( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* Settings, + int32 First, + int32 Second, + bool& bOutClimb, + EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) + { + bOutClimb = false; + if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, First) + || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, Second)) + { + return false; + } + if (PlayerFitSupportHeights == nullptr || Settings == nullptr) + { + // Callers that do not carry support data retain the historical six-neighbour graph. + return true; + } + if (!PlayerFitSupportHeights->IsValidIndex(First) + || !PlayerFitSupportHeights->IsValidIndex(Second)) + { + return false; + } + + int32 FirstX = 0; + int32 FirstY = 0; + int32 FirstZ = 0; + DecodeIndex(Grid, First, FirstX, FirstY, FirstZ); + int32 SecondX = 0; + int32 SecondY = 0; + int32 SecondZ = 0; + DecodeIndex(Grid, Second, SecondX, SecondY, SecondZ); + const int32 DeltaX = FMath::Abs(FirstX - SecondX); + const int32 DeltaY = FMath::Abs(FirstY - SecondY); + const int32 DeltaZ = FMath::Abs(FirstZ - SecondZ); + if (DeltaX + DeltaY == 0 && DeltaZ == 1) + { + return true; + } + if (DeltaX + DeltaY != 1) + { + return false; + } + + const float FirstHeight = (*PlayerFitSupportHeights)[First]; + const float SecondHeight = (*PlayerFitSupportHeights)[Second]; + if (!FMath::IsFinite(FirstHeight) || !FMath::IsFinite(SecondHeight)) + { + return false; + } + const float VoxelSize = FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float StepHeightVoxels = Settings->PlayerMaxStepHeightMeters / VoxelSize; + const float ClimbHeightVoxels = Settings->PlayerMaxClimbHeightMeters / VoxelSize; + if (!FMath::IsFinite(StepHeightVoxels) || StepHeightVoxels < 0.0f + || !FMath::IsFinite(ClimbHeightVoxels) || ClimbHeightVoxels < 0.0f) + { + return false; + } + const float HeightDelta = FMath::Abs(FirstHeight - SecondHeight); + if (HeightDelta <= StepHeightVoxels + KINDA_SMALL_NUMBER) + { + return true; + } + if (Policy == EPlayerTraversalPolicy::WalkOnly + || !Settings->bPlayerClimbingEnabled + || HeightDelta > ClimbHeightVoxels + KINDA_SMALL_NUMBER) + { + return false; + } + + bOutClimb = IsClimbableSurfaceEdge( + Grid, First, Second, FirstHeight, SecondHeight); + return bOutClimb; + } + + void AppendPlayerTraversalNeighbours( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* Settings, + int32 Cell, + TArray>& OutNeighbours, + EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) + { + OutNeighbours.Reset(); + if (Cell < 0 || Cell >= Grid.CellCount) + { + return; + } + + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Cell, X, Y, Z); + auto TryAdd = [&](int32 Neighbor) + { + if (Neighbor == INDEX_NONE || Neighbor == Cell) + { + return; + } + bool bClimb = false; + if (IsPlayerTraversalEdge( + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, + Cell, Neighbor, bClimb, Policy)) + { + OutNeighbours.Add({ Neighbor, bClimb }); + } + }; + + // Preserve the old direction order for ordinary movement. Extra climb edges are appended + // in a fixed direction/height order, so the route and all derived counts are stable. + if (X + 1 < Grid.NumX) TryAdd(Grid.Index(X + 1, Y, Z)); + if (X > 0) TryAdd(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) TryAdd(Grid.Index(X, Y + 1, Z)); + if (Y > 0) TryAdd(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) TryAdd(Grid.Index(X, Y, Z + 1)); + if (Z > 0) TryAdd(Grid.Index(X, Y, Z - 1)); + + if (Policy == EPlayerTraversalPolicy::WalkOnly + || PlayerFitSupportHeights == nullptr || Settings == nullptr + || !Settings->bPlayerClimbingEnabled) + { + return; + } + const float VoxelSize = FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float ClimbHeightVoxels = Settings->PlayerMaxClimbHeightMeters / VoxelSize; + if (!FMath::IsFinite(ClimbHeightVoxels) || ClimbHeightVoxels <= 0.0f) + { + return; + } + const int32 SearchCells = FMath::Min( + 4096, + FMath::Max(1, FMath::CeilToInt(ClimbHeightVoxels))); + const int32 DirectionX[4] = { 1, -1, 0, 0 }; + const int32 DirectionY[4] = { 0, 0, 1, -1 }; + for (int32 Direction = 0; Direction < 4; ++Direction) + { + const int32 TargetX = X + DirectionX[Direction]; + const int32 TargetY = Y + DirectionY[Direction]; + if (TargetX < 0 || TargetX >= Grid.NumX + || TargetY < 0 || TargetY >= Grid.NumY) + { + continue; + } + for (int32 OffsetZ = -SearchCells; OffsetZ <= SearchCells; ++OffsetZ) + { + if (OffsetZ == 0) + { + continue; + } + const int32 TargetZ = Z + OffsetZ; + if (TargetZ >= 0 && TargetZ < Grid.NumZ) + { + const int32 Neighbor = Grid.Index(TargetX, TargetY, TargetZ); + bool bClimb = false; + if (IsPlayerTraversalEdge( + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, + Cell, Neighbor, bClimb, Policy) + && bClimb) + { + OutNeighbours.Add({ Neighbor, true }); + } + } + } + } + } + + void FloodFillAir( + const FSampleGrid& Grid, + TArray& OutComponents, + int32& OutNumComponents, + int64 NumAir, + int64& OutLargestComponentCells, + int32& OutLargestComponentLowestCell, + int32& OutNumComponentsAtLeast1Pct, + TArray* OutComponentCells, + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) + { + OutComponents.SetNumUninitialized(Grid.CellCount); + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + OutComponents[Index] = -1; + } + + TArray Queue; + Queue.SetNumUninitialized(Grid.CellCount); + OutNumComponents = 0; + OutLargestComponentCells = 0; + OutLargestComponentLowestCell = INDEX_NONE; + OutNumComponentsAtLeast1Pct = 0; + if (OutComponentCells != nullptr) + { + OutComponentCells->Reset(); + } + + for (int32 Start = 0; Start < Grid.CellCount; ++Start) + { + if (Grid.Air[Start] == 0u + || (EligibilityMask != nullptr && (*EligibilityMask)[Start] == 0u) + || OutComponents[Start] >= 0) + { + continue; + } + + const int32 ComponentId = OutNumComponents++; + int32 Head = 0; + int32 Tail = 0; + int32 ComponentCells = 0; + OutComponents[Start] = ComponentId; + Queue[Tail++] = Start; + + while (Head < Tail) + { + const int32 Current = Queue[Head++]; + ++ComponentCells; + + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Current, X, Y, Z); + + auto Visit = [&](int32 Neighbor) + { + if (Grid.Air[Neighbor] != 0u + && (EligibilityMask == nullptr || (*EligibilityMask)[Neighbor] != 0u) + && OutComponents[Neighbor] < 0) + { + OutComponents[Neighbor] = ComponentId; + Queue[Tail++] = Neighbor; + } + }; + + if (EligibilityMask != nullptr + && PlayerFitSupportHeights != nullptr + && TraversalSettings != nullptr) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, *EligibilityMask, PlayerFitSupportHeights, + TraversalSettings, Current, Neighbours, TraversalPolicy); + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + Visit(Neighbour.Cell); + } + } + else + { + // Fixed order keeps every derived value independent of container iteration order. + if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); + if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); + if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); + if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + } + } + + const int64 ComponentCells64 = static_cast(ComponentCells); + if (OutComponentCells != nullptr) + { + OutComponentCells->Add(ComponentCells64); + } + if (NumAir > 0 && ComponentCells64 * 100 >= NumAir) + { + ++OutNumComponentsAtLeast1Pct; + } + + if (ComponentCells64 > OutLargestComponentCells + || (ComponentCells64 == OutLargestComponentCells + && (OutLargestComponentLowestCell == INDEX_NONE + || Start < OutLargestComponentLowestCell))) + { + OutLargestComponentCells = ComponentCells64; + OutLargestComponentLowestCell = Start; + } + } + } + + void BuildManhattanDistanceToSolid(const FSampleGrid& Grid, TArray& OutDistances) + { + // Two wavefront passes with 6-neighbour Manhattan relaxation. A cell depends only on the + // immediately preceding diagonal in the forward pass (or following diagonal in the + // reverse pass), so each diagonal is safe to process in parallel while the diagonal + // barriers preserve the exact raster result. Each coarse grid unit is SampleStep VOXELS. + constexpr int32 Infinity = INT32_MAX; + OutDistances.SetNumUninitialized(Grid.CellCount); + ParallelFor(Grid.CellCount, [&](int32 Index) + { + OutDistances[Index] = Grid.Air[Index] == 0u ? 0 : Infinity; + }); + + const int32 LastDiagonal = Grid.NumX + Grid.NumY + Grid.NumZ - 3; + const auto ProcessDiagonal = [&](int32 Diagonal, bool bForward) + { + const int32 MinZ = FMath::Max( + 0, Diagonal - (Grid.NumX - 1) - (Grid.NumY - 1)); + const int32 MaxZ = FMath::Min(Grid.NumZ - 1, Diagonal); + if (MinZ > MaxZ) + { + return; + } + + int32 DiagonalCellCount = 0; + for (int32 Z = MinZ; Z <= MaxZ; ++Z) + { + const int32 MinY = FMath::Max( + 0, Diagonal - Z - (Grid.NumX - 1)); + const int32 MaxY = FMath::Min(Grid.NumY - 1, Diagonal - Z); + DiagonalCellCount += FMath::Max(0, MaxY - MinY + 1); + } + + const auto ProcessZ = [&](int32 Z) + { + const int32 MinY = FMath::Max( + 0, Diagonal - Z - (Grid.NumX - 1)); + const int32 MaxY = FMath::Min(Grid.NumY - 1, Diagonal - Z); + for (int32 Y = MinY; Y <= MaxY; ++Y) + { + const int32 X = Diagonal - Z - Y; + const int32 Current = Grid.Index(X, Y, Z); + if (Grid.Air[Current] == 0u) + { + continue; + } + + const auto Relax = [&](int32 Neighbor) + { + if (OutDistances[Neighbor] == Infinity) + { + return; + } + const int32 Candidate = OutDistances[Neighbor] == INT32_MAX - 1 + ? Infinity : OutDistances[Neighbor] + 1; + OutDistances[Current] = FMath::Min( + OutDistances[Current], Candidate); + }; + + if (bForward) + { + if (X > 0) Relax(Grid.Index(X - 1, Y, Z)); + if (Y > 0) Relax(Grid.Index(X, Y - 1, Z)); + if (Z > 0) Relax(Grid.Index(X, Y, Z - 1)); + } + else + { + if (X + 1 < Grid.NumX) Relax(Grid.Index(X + 1, Y, Z)); + if (Y + 1 < Grid.NumY) Relax(Grid.Index(X, Y + 1, Z)); + if (Z + 1 < Grid.NumZ) Relax(Grid.Index(X, Y, Z + 1)); + } + } + }; + + // Tiny edge diagonals are cheaper to execute directly; substantial diagonals get + // one task per Z row, with every row writing a disjoint set of cells. + const int32 ZCount = MaxZ - MinZ + 1; + if (ZCount > 1 && DiagonalCellCount >= 256) + { + ParallelFor(ZCount, [&](int32 ZOffset) + { + ProcessZ(MinZ + ZOffset); + }); + } + else + { + for (int32 Z = MinZ; Z <= MaxZ; ++Z) + { + ProcessZ(Z); + } + } + }; + + for (int32 Diagonal = 0; Diagonal <= LastDiagonal; ++Diagonal) + { + ProcessDiagonal(Diagonal, true); + } + for (int32 Diagonal = LastDiagonal; Diagonal >= 0; --Diagonal) + { + ProcessDiagonal(Diagonal, false); + } + } + + struct FPlayerCapsuleStencilRow + { + int32 RelativeZ = 0; + TArray HorizontalOffsets; + }; + + bool HasScalarDensity(const FSampleGrid& Grid) + { + return Grid.Density.Num() == Grid.CellCount; + } + + float CellDensity(const FSampleGrid& Grid, int32 Cell) + { + return HasScalarDensity(Grid) + ? Grid.Density[Cell] + : (Grid.Air[Cell] != 0u ? 1.0f : -1.0f); + } + + bool SampleGridDensity( + const FSampleGrid& Grid, + const FVector& Point, + float& OutDensity) + { + if (!Grid.ContainsPoint(Point)) + { + return false; + } + + const float Step = static_cast(Grid.SampleStep); + const float LocalX = (Point.X - Grid.MinX) / Step - 0.5f; + const float LocalY = (Point.Y - Grid.MinY) / Step - 0.5f; + const float LocalZ = (Point.Z - Grid.MinZ) / Step - 0.5f; + + if (!HasScalarDensity(Grid)) + { + const int32 X = FMath::Clamp( + FMath::FloorToInt((Point.X - Grid.MinX) / Step), 0, Grid.NumX - 1); + const int32 Y = FMath::Clamp( + FMath::FloorToInt((Point.Y - Grid.MinY) / Step), 0, Grid.NumY - 1); + const int32 Z = FMath::Clamp( + FMath::FloorToInt((Point.Z - Grid.MinZ) / Step), 0, Grid.NumZ - 1); + OutDensity = CellDensity(Grid, Grid.Index(X, Y, Z)); + return FMath::IsFinite(OutDensity); + } + + const int32 X0 = FMath::Clamp(FMath::FloorToInt(LocalX), 0, Grid.NumX - 1); + const int32 Y0 = FMath::Clamp(FMath::FloorToInt(LocalY), 0, Grid.NumY - 1); + const int32 Z0 = FMath::Clamp(FMath::FloorToInt(LocalZ), 0, Grid.NumZ - 1); + const int32 X1 = FMath::Min(X0 + 1, Grid.NumX - 1); + const int32 Y1 = FMath::Min(Y0 + 1, Grid.NumY - 1); + const int32 Z1 = FMath::Min(Z0 + 1, Grid.NumZ - 1); + const float AlphaX = FMath::Clamp(LocalX - static_cast(X0), 0.0f, 1.0f); + const float AlphaY = FMath::Clamp(LocalY - static_cast(Y0), 0.0f, 1.0f); + const float AlphaZ = FMath::Clamp(LocalZ - static_cast(Z0), 0.0f, 1.0f); + + const float D000 = CellDensity(Grid, Grid.Index(X0, Y0, Z0)); + const float D100 = CellDensity(Grid, Grid.Index(X1, Y0, Z0)); + const float D010 = CellDensity(Grid, Grid.Index(X0, Y1, Z0)); + const float D110 = CellDensity(Grid, Grid.Index(X1, Y1, Z0)); + const float D001 = CellDensity(Grid, Grid.Index(X0, Y0, Z1)); + const float D101 = CellDensity(Grid, Grid.Index(X1, Y0, Z1)); + const float D011 = CellDensity(Grid, Grid.Index(X0, Y1, Z1)); + const float D111 = CellDensity(Grid, Grid.Index(X1, Y1, Z1)); + const float D00 = FMath::Lerp(D000, D100, AlphaX); + const float D10 = FMath::Lerp(D010, D110, AlphaX); + const float D01 = FMath::Lerp(D001, D101, AlphaX); + const float D11 = FMath::Lerp(D011, D111, AlphaX); + const float D0 = FMath::Lerp(D00, D10, AlphaY); + const float D1 = FMath::Lerp(D01, D11, AlphaY); + OutDensity = FMath::Lerp(D0, D1, AlphaZ); + return FMath::IsFinite(OutDensity); + } + + bool FindSupportSurfaceBelow( + const FSampleGrid& Grid, + int32 X, + int32 Y, + int32 CandidateAnchorZ, + int32 MaxDownwardSearchCells, + float MaxStepHeightVoxels, + float& OutSurfaceHeight, + int32& OutSurfaceAnchorZ) + { + const float Step = static_cast(Grid.SampleStep); + const float CandidateBottom = Grid.MinZ + + static_cast(CandidateAnchorZ) * Step; + const float LowestAllowedSurface = CandidateBottom - MaxStepHeightVoxels; + const int32 LowestAnchorZ = FMath::Max( + 1, CandidateAnchorZ - MaxDownwardSearchCells); + + // Search from the free pose downwards. The first transition in a column is the nearest + // supporting surface; lower transitions are relevant only after the candidate has been + // lifted by the bounded step-height tolerance. + for (int32 SurfaceAnchorZ = CandidateAnchorZ; + SurfaceAnchorZ >= LowestAnchorZ; + --SurfaceAnchorZ) + { + const int32 SolidZ = SurfaceAnchorZ - 1; + if (SolidZ < 0 || SurfaceAnchorZ >= Grid.NumZ) + { + continue; + } + const int32 SolidCell = Grid.Index(X, Y, SolidZ); + const int32 AirCell = Grid.Index(X, Y, SurfaceAnchorZ); + if (Grid.Air[SolidCell] != 0u || Grid.Air[AirCell] == 0u) + { + continue; + } + + float SurfaceHeight = Grid.MinZ + static_cast(SurfaceAnchorZ) * Step; + if (HasScalarDensity(Grid)) + { + const float LowerDensity = CellDensity(Grid, SolidCell); + const float UpperDensity = CellDensity(Grid, AirCell); + const float Denominator = UpperDensity - LowerDensity; + if (!FMath::IsFinite(LowerDensity) || !FMath::IsFinite(UpperDensity) + || !FMath::IsFinite(Denominator) || Denominator <= 0.0f) + { + continue; + } + const float Fraction = FMath::Clamp( + -LowerDensity / Denominator, 0.0f, 1.0f); + SurfaceHeight = Grid.CellCenter(X, Y, SolidZ).Z + Fraction * Step; + } + + if (FMath::IsFinite(SurfaceHeight) + && SurfaceHeight <= CandidateBottom + KINDA_SMALL_NUMBER + && SurfaceHeight >= LowestAllowedSurface - KINDA_SMALL_NUMBER) + { + OutSurfaceHeight = SurfaceHeight; + OutSurfaceAnchorZ = SurfaceAnchorZ; + return true; + } + } + return false; + } + + bool BuildPlayerSupportPatchOffsets( + const FVoxelStrateMeasureSettings& Settings, + TArray& OutOffsets, + int32& OutCentreOffsetIndex, + FString& OutReason) + { + OutOffsets.Reset(); + OutCentreOffsetIndex = INDEX_NONE; + const double Radius = static_cast(Settings.PlayerCapsuleRadiusVoxels); + if (!FMath::IsFinite(Radius) || Radius <= 0.0) + { + return Refuse(OutReason, + TEXT("Player capsule radius must be finite and greater than zero.")); + } + + const double MaxOffsetReal = FMath::CeilToDouble(Radius); + constexpr double MaxSupportPatchOffsets = static_cast(1ll << 20); + if (!FMath::IsFinite(MaxOffsetReal) || MaxOffsetReal > 4096.0) + { + return Refuse(OutReason, + TEXT("The player support patch radius exceeds the bounded fit stencil.")); + } + const int32 MaxOffset = static_cast(MaxOffsetReal); + int64 NumOffsets = 0; + for (int32 OffsetY = -MaxOffset; OffsetY <= MaxOffset; ++OffsetY) + { + for (int32 OffsetX = -MaxOffset; OffsetX <= MaxOffset; ++OffsetX) + { + if (static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + > Radius * Radius + KINDA_SMALL_NUMBER) + { + continue; + } + if (NumOffsets >= static_cast(MaxSupportPatchOffsets)) + { + return Refuse( + OutReason, + TEXT("The player support patch exceeds its bounded work limit.")); + } + if (OffsetX == 0 && OffsetY == 0) + { + OutCentreOffsetIndex = OutOffsets.Num(); + } + OutOffsets.Add(FIntPoint(OffsetX, OffsetY)); + ++NumOffsets; + } + } + if (OutCentreOffsetIndex == INDEX_NONE || OutOffsets.IsEmpty()) + { + return Refuse(OutReason, TEXT("The player support patch has no footprint samples.")); + } + return true; + } + + bool IsWalkableSupportPatch( + const TArray& SupportOffsets, + const TArray& SupportHeights, + const TArray& bSupported, + int32 CentreOffsetIndex, + int32 RequiredSupportCount, + float MinimumWalkableNormalZ, + int32& OutSupportCount, + float& OutSupportHeight, + int32& OutSupportAnchorZ, + const TArray& SupportAnchorZs) + { + OutSupportCount = 0; + OutSupportHeight = -FLT_MAX; + OutSupportAnchorZ = INDEX_NONE; + if (!bSupported.IsValidIndex(CentreOffsetIndex) + || bSupported[CentreOffsetIndex] == 0u) + { + return false; + } + + for (int32 Index = 0; Index < SupportOffsets.Num(); ++Index) + { + if (bSupported[Index] == 0u) + { + continue; + } + ++OutSupportCount; + if (SupportHeights[Index] > OutSupportHeight + || (SupportHeights[Index] == OutSupportHeight + && (OutSupportAnchorZ == INDEX_NONE + || SupportAnchorZs[Index] > OutSupportAnchorZ))) + { + OutSupportHeight = SupportHeights[Index]; + OutSupportAnchorZ = SupportAnchorZs[Index]; + } + } + if (OutSupportCount < RequiredSupportCount || OutSupportAnchorZ == INDEX_NONE) + { + return false; + } + + // The worst pairwise secant is intentionally conservative: an isolated height jump in + // the footprint cannot hide behind an average plane normal. For the default 1.36-voxel + // capsule this checks the centre and the four cardinal support samples. + float MaximumGradient = 0.0f; + for (int32 First = 0; First < SupportOffsets.Num(); ++First) + { + if (bSupported[First] == 0u) + { + continue; + } + for (int32 Second = First + 1; Second < SupportOffsets.Num(); ++Second) + { + if (bSupported[Second] == 0u) + { + continue; + } + const float DX = static_cast( + SupportOffsets[Second].X - SupportOffsets[First].X); + const float DY = static_cast( + SupportOffsets[Second].Y - SupportOffsets[First].Y); + const float HorizontalDistance = FMath::Sqrt(DX * DX + DY * DY); + if (HorizontalDistance <= KINDA_SMALL_NUMBER) + { + continue; + } + MaximumGradient = FMath::Max( + MaximumGradient, + FMath::Abs(SupportHeights[Second] - SupportHeights[First]) + / HorizontalDistance); + } + } + const float SupportNormalZ = 1.0f / FMath::Sqrt( + 1.0f + MaximumGradient * MaximumGradient); + return FMath::IsFinite(SupportNormalZ) + && SupportNormalZ + KINDA_SMALL_NUMBER >= MinimumWalkableNormalZ; + } + + bool CapsuleClearAtSupportHeight( + const FSampleGrid& Grid, + const TArray& StencilRows, + int32 X, + int32 Y, + float SupportHeight) + { + const float Step = static_cast(Grid.SampleStep); + const float BaseX = Grid.MinX + (static_cast(X) + 0.5f) * Step; + const float BaseY = Grid.MinY + (static_cast(Y) + 0.5f) * Step; + for (const FPlayerCapsuleStencilRow& Row : StencilRows) + { + const float SampleZ = SupportHeight + static_cast(Row.RelativeZ) + 0.5f * Step; + for (const FIntPoint& Offset : Row.HorizontalOffsets) + { + float Density = 0.0f; + if (!SampleGridDensity( + Grid, + FVector( + BaseX + static_cast(Offset.X) * Step, + BaseY + static_cast(Offset.Y) * Step, + SampleZ), + Density) + || !(Density > 0.0f)) + { + return false; + } + } + } + return true; + } + + bool IsPointInsideCapsule( + float RelativeZ, + int32 OffsetX, + int32 OffsetY, + float Radius, + float HalfHeight) + { + // The capsule is upright and this row is evaluated relative to the resolved support + // surface. This is a direct capsule test in the fine voxel lattice; it deliberately does + // not infer occupancy from Manhattan distance. + const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius); + const float DistanceToAxis = FMath::Max( + FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f); + const float DistanceSquared = static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + + DistanceToAxis * DistanceToAxis; + return DistanceSquared <= Radius * Radius + KINDA_SMALL_NUMBER; + } + + bool BuildPlayerCapsuleStencil( + const FVoxelStrateMeasureSettings& Settings, + TArray& OutRows, + FString& OutReason) + { + OutRows.Reset(); + if (!FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels) + || Settings.PlayerCapsuleRadiusVoxels <= 0.0f + || !FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels) + || Settings.PlayerCapsuleHalfHeightVoxels <= 0.0f) + { + return Refuse(OutReason, + TEXT("Player capsule radius and half-height must be finite and greater than zero.")); + } + + const double HeightCellsReal = FMath::CeilToDouble( + 2.0 * static_cast(Settings.PlayerCapsuleHalfHeightVoxels)); + // This is a stencil-size guard, not a gameplay threshold. It prevents a malformed + // caller-supplied capsule from allocating an unbounded per-anchor loop. + constexpr double MaxStencilHeightCells = 4096.0; + if (!FMath::IsFinite(HeightCellsReal) || HeightCellsReal <= 0.0 + || HeightCellsReal > MaxStencilHeightCells) + { + return Refuse(OutReason, + TEXT("The player capsule height exceeds the bounded fit stencil.")); + } + + const double MaxHorizontalOffsetReal = FMath::CeilToDouble( + static_cast(Settings.PlayerCapsuleRadiusVoxels)); + constexpr double MaxStencilHorizontalRadiusCells = 4096.0; + if (!FMath::IsFinite(MaxHorizontalOffsetReal) + || MaxHorizontalOffsetReal < 0.0 + || MaxHorizontalOffsetReal > MaxStencilHorizontalRadiusCells) + { + return Refuse(OutReason, + TEXT("The player capsule radius exceeds the bounded fit stencil.")); + } + const int32 HeightCells = static_cast(HeightCellsReal); + const int32 MaxHorizontalOffset = static_cast(MaxHorizontalOffsetReal); + // This bounds both the retained stencil and the per-anchor occupancy work. It is + // deliberately far above the confirmed capsule's handful of offsets, but prevents a + // malformed caller from turning a valid-looking finite radius into an enormous array. + constexpr int64 MaxStencilOffsets = 1ll << 20; + int64 NumStencilOffsets = 0; + + OutRows.Reserve(HeightCells); + for (int32 RelativeZ = 0; RelativeZ < HeightCells; ++RelativeZ) + { + FPlayerCapsuleStencilRow& Row = OutRows.AddDefaulted_GetRef(); + Row.RelativeZ = RelativeZ; + const float CapsuleRelativeZ = static_cast(RelativeZ) + 0.5f + - Settings.PlayerCapsuleHalfHeightVoxels; + for (int32 OffsetY = -MaxHorizontalOffset; + OffsetY <= MaxHorizontalOffset; + ++OffsetY) + { + for (int32 OffsetX = -MaxHorizontalOffset; + OffsetX <= MaxHorizontalOffset; + ++OffsetX) + { + if (IsPointInsideCapsule( + CapsuleRelativeZ, OffsetX, OffsetY, + Settings.PlayerCapsuleRadiusVoxels, + Settings.PlayerCapsuleHalfHeightVoxels)) + { + if (NumStencilOffsets >= MaxStencilOffsets) + { + return Refuse( + OutReason, + TEXT("The player capsule occupancy stencil exceeds its bounded work limit.")); + } + Row.HorizontalOffsets.Add(FIntPoint(OffsetX, OffsetY)); + ++NumStencilOffsets; + } + } + } + + // Keep a centre sample for every row. This matters for the top partial row of a + // capsule whose height is not an integer number of voxels: the full-height clearance + // requirement must not silently become a seven-centre-point approximation. + if (Row.HorizontalOffsets.IsEmpty()) + { + Row.HorizontalOffsets.Add(FIntPoint::ZeroValue); + } + } + return true; + } + + bool BuildPlayerFitMask( + const FSampleGrid& Grid, + const FVoxelStrateMeasureSettings& Settings, + TArray& OutPlayerFit, + int64& OutNumPlayerFitCells, + FString& OutReason, + TArray* OutPlayerFitSupportHeights = nullptr) + { + OutPlayerFit.Reset(); + OutNumPlayerFitCells = 0; + if (OutPlayerFitSupportHeights != nullptr) + { + OutPlayerFitSupportHeights->Reset(); + } + if (Grid.SampleStep != 1) + { + return Refuse( + OutReason, + *FString::Printf( + TEXT("Player-fit metrics refused: SampleStep=%d; exact capsule fit requires SampleStep=1."), + Grid.SampleStep)); + } + + if (!FMath::IsFinite(Settings.PlayerMaxStepHeightMeters) + || Settings.PlayerMaxStepHeightMeters < 0.0f) + { + return Refuse( + OutReason, + TEXT("PlayerMaxStepHeightMeters must be finite and non-negative.")); + } + if (!FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) + || Settings.PlayerWalkableFloorAngleDegrees < 0.0f + || Settings.PlayerWalkableFloorAngleDegrees > 90.0f) + { + return Refuse( + OutReason, + TEXT("PlayerWalkableFloorAngleDegrees must be finite and within [0,90].")); + } + if (!FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) + || Settings.PlayerSupportPatchMinCoverageFraction <= 0.0f + || Settings.PlayerSupportPatchMinCoverageFraction > 1.0f) + { + return Refuse( + OutReason, + TEXT("PlayerSupportPatchMinCoverageFraction must be within (0,1].")); + } + + const double MaxStepHeightVoxelsReal = + static_cast(Settings.PlayerMaxStepHeightMeters) + / static_cast(FVoxelPlayerCapsuleConstants::VoxelSizeMeters); + constexpr double MaxSupportSearchHeightVoxels = 4096.0; + if (!FMath::IsFinite(MaxStepHeightVoxelsReal) + || MaxStepHeightVoxelsReal < 0.0 + || MaxStepHeightVoxelsReal > MaxSupportSearchHeightVoxels) + { + return Refuse( + OutReason, + TEXT("PlayerMaxStepHeightMeters exceeds the bounded support search.")); + } + const int32 MaxDownwardSearchCells = static_cast( + FMath::CeilToDouble(MaxStepHeightVoxelsReal)); + const float MaxStepHeightVoxels = static_cast(MaxStepHeightVoxelsReal); + const float MinimumWalkableNormalZ = VoxelMath::DetCos(FMath::DegreesToRadians( + Settings.PlayerWalkableFloorAngleDegrees)); + + TArray SupportOffsets; + int32 CentreOffsetIndex = INDEX_NONE; + if (!BuildPlayerSupportPatchOffsets( + Settings, SupportOffsets, CentreOffsetIndex, OutReason)) + { + return false; + } + const int32 RequiredSupportCount = FMath::Clamp( + FMath::CeilToInt( + Settings.PlayerSupportPatchMinCoverageFraction + * static_cast(SupportOffsets.Num())), + 1, + SupportOffsets.Num()); + + TArray StencilRows; + if (!BuildPlayerCapsuleStencil(Settings, StencilRows, OutReason)) + { + return false; + } + + OutPlayerFit.Init(0u, Grid.CellCount); + if (OutPlayerFitSupportHeights != nullptr) + { + OutPlayerFitSupportHeights->Init(-FLT_MAX, Grid.CellCount); + } + TArray PlayerFitCounts; + PlayerFitCounts.Init(0, Grid.NumZ); + + // A candidate pose only reads the immutable sampled grid and writes its own mask cell. + // Give each Z slab private stencil scratch, then reduce the per-slab counts in Z order + // below. This removes the old shared scratch/count race without changing any predicate. + ParallelFor(FMath::Max(Grid.NumZ - 1, 0), [&](int32 SliceIndex) + { + const int32 Z = SliceIndex + 1; + TArray SupportHeights; + SupportHeights.SetNumUninitialized(SupportOffsets.Num()); + TArray SupportAnchorZs; + SupportAnchorZs.SetNumUninitialized(SupportOffsets.Num()); + TArray bSupported; + bSupported.Init(0u, SupportOffsets.Num()); + int64 NumPlayerFitInSlice = 0; + + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Current = Grid.Index(X, Y, Z); + // This is the free-pose candidate. Its support is resolved independently + // below, so an air cell one or two voxels above a surface can be lowered to + // that surface when the character's named step height permits it. + if (Grid.Air[Current] == 0u) + { + continue; + } + + int32 NumSupported = 0; + for (int32 SupportIndex = 0; + SupportIndex < SupportOffsets.Num(); + ++SupportIndex) + { + const FIntPoint& Offset = SupportOffsets[SupportIndex]; + const int32 SupportX = X + Offset.X; + const int32 SupportY = Y + Offset.Y; + bSupported[SupportIndex] = 0u; + if (SupportX < 0 || SupportX >= Grid.NumX + || SupportY < 0 || SupportY >= Grid.NumY) + { + continue; + } + + float SurfaceHeight = 0.0f; + int32 SurfaceAnchorZ = INDEX_NONE; + if (FindSupportSurfaceBelow( + Grid, + SupportX, + SupportY, + Z, + MaxDownwardSearchCells, + MaxStepHeightVoxels, + SurfaceHeight, + SurfaceAnchorZ)) + { + bSupported[SupportIndex] = 1u; + SupportHeights[SupportIndex] = SurfaceHeight; + SupportAnchorZs[SupportIndex] = SurfaceAnchorZ; + ++NumSupported; + } + } + + if (NumSupported < RequiredSupportCount + || bSupported[CentreOffsetIndex] == 0u) + { + continue; + } + + int32 SupportCount = 0; + float SupportHeight = 0.0f; + int32 SupportAnchorZ = INDEX_NONE; + if (!IsWalkableSupportPatch( + SupportOffsets, + SupportHeights, + bSupported, + CentreOffsetIndex, + RequiredSupportCount, + MinimumWalkableNormalZ, + SupportCount, + SupportHeight, + SupportAnchorZ, + SupportAnchorZs)) + { + continue; + } + + // Resolve the pose to the highest coherent support in the patch and run the + // full capsule stencil again there. The support search alone is not allowed + // to turn a ledge or an overhang into a fit result. Keep the original free + // pose cell in the mask: every accepted pose is a valid point in the + // existing 6-neighbour traversal graph, and retaining the bounded vertical + // band is what lets adjacent gently sloped surfaces overlap instead of + // becoming diagonal, disconnected floor anchors. + if (!CapsuleClearAtSupportHeight( + Grid, StencilRows, X, Y, SupportHeight)) + { + continue; + } + + if (SupportAnchorZ < 1 || SupportAnchorZ >= Grid.NumZ) + { + continue; + } + OutPlayerFit[Current] = 1u; + if (OutPlayerFitSupportHeights != nullptr) + { + (*OutPlayerFitSupportHeights)[Current] = SupportHeight; + } + ++NumPlayerFitInSlice; + } + } + PlayerFitCounts[Z] = NumPlayerFitInSlice; + }); + + // Fixed-index reduction is intentional: even though the terms are integral, keeping the + // reduction serial documents the determinism rule shared by the floating metrics below. + for (int32 Z = 1; Z < Grid.NumZ; ++Z) + { + OutNumPlayerFitCells += PlayerFitCounts[Z]; + } + return true; + } + + float MinimumPlayerClearanceChebyshev( + const FSampleGrid& Grid, + const TArray& PlayerFit) + { + if (PlayerFit.Num() != Grid.CellCount) + { + return 0.0f; + } + + const int32 PlaneCells = Grid.NumX * Grid.NumY; + constexpr int32 Infinity = INT32_MAX / 4; + TArray PerSliceMinimum; + PerSliceMinimum.Init(Infinity, Grid.NumZ); + + // Each horizontal distance transform is independent. Keep its mutable raster in the + // worker and publish one integer result per Z slice; the final minimum is reduced in + // ascending Z order for an explicit deterministic reduction. + ParallelFor(Grid.NumZ, [&](int32 Z) + { + TArray Distances; + Distances.SetNumUninitialized(PlaneCells); + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 PlaneIndex = X + Grid.NumX * Y; + Distances[PlaneIndex] = Grid.Air[Grid.Index(X, Y, Z)] == 0u + ? 0 : Infinity; + } + } + + auto Relax = [&](int32 Current, int32 Neighbor) + { + if (Distances[Neighbor] < Infinity) + { + Distances[Current] = FMath::Min( + Distances[Current], Distances[Neighbor] + 1); + } + }; + + // Two raster passes with the complete 8-neighbour stencil give a true 2D + // Chebyshev distance to solid in this horizontal slice. It is used only for the + // reported minimum; capsule occupancy above is the direct stencil check. + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Current = X + Grid.NumX * Y; + if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) continue; + if (X > 0) Relax(Current, Current - 1); + if (Y > 0) + { + Relax(Current, Current - Grid.NumX); + if (X > 0) Relax(Current, Current - Grid.NumX - 1); + if (X + 1 < Grid.NumX) Relax(Current, Current - Grid.NumX + 1); + } + } + } + for (int32 Y = Grid.NumY - 1; Y >= 0; --Y) + { + for (int32 X = Grid.NumX - 1; X >= 0; --X) + { + const int32 Current = X + Grid.NumX * Y; + if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) continue; + if (X + 1 < Grid.NumX) Relax(Current, Current + 1); + if (Y + 1 < Grid.NumY) + { + Relax(Current, Current + Grid.NumX); + if (X > 0) Relax(Current, Current + Grid.NumX - 1); + if (X + 1 < Grid.NumX) Relax(Current, Current + Grid.NumX + 1); + } + } + } + + int32 SliceMinimum = Infinity; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Cell = Grid.Index(X, Y, Z); + if (PlayerFit[Cell] != 0u) + { + SliceMinimum = FMath::Min( + SliceMinimum, Distances[X + Grid.NumX * Y]); + } + } + } + PerSliceMinimum[Z] = SliceMinimum; + }); + + int32 MinimumDistance = Infinity; + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + MinimumDistance = FMath::Min(MinimumDistance, PerSliceMinimum[Z]); + } + + return MinimumDistance == Infinity + ? 0.0f : static_cast(MinimumDistance); + } + + int64 CountAirCells( + const FSampleGrid& Grid, + const TArray* EligibilityMask = nullptr) + { + TArray AirCountsByZ; + AirCountsByZ.Init(0, Grid.NumZ); + ParallelFor(Grid.NumZ, [&](int32 Z) + { + int64 Count = 0; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Cell = Grid.Index(X, Y, Z); + Count += Grid.Air[Cell] != 0u + && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u) + ? 1 + : 0; + } + } + AirCountsByZ[Z] = Count; + }); + + int64 Total = 0; + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + Total += AirCountsByZ[Z]; + } + return Total; + } + + void BuildPlayerFitMetrics( + const FSampleGrid& Grid, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics& InOutMetrics, + TArray* OutPlayerFitMask = nullptr, + TArray* OutPlayerFitSupportHeights = nullptr) + { + InOutMetrics.bPlayerFitResolved = false; + InOutMetrics.PlayerFitRefusalReason.Reset(); + InOutMetrics.NumPlayerFitCells = 0; + InOutMetrics.PlayerFitFraction = 0.0f; + InOutMetrics.NumTraversableComponents = 0; + InOutMetrics.LargestTraversableComponentCells = 0; + InOutMetrics.TraversableComponentShare = 0.0f; + InOutMetrics.MinimumPlayerClearanceVoxels = 0.0f; + + TArray PlayerFit; + TArray PlayerFitSupportHeights; + if (!BuildPlayerFitMask( + Grid, Settings, PlayerFit, InOutMetrics.NumPlayerFitCells, + InOutMetrics.PlayerFitRefusalReason, + &PlayerFitSupportHeights)) + { + if (OutPlayerFitMask != nullptr) + { + OutPlayerFitMask->Reset(); + } + return; + } + InOutMetrics.bPlayerFitResolved = true; + InOutMetrics.PlayerFitFraction = InOutMetrics.NumAir > 0 + ? static_cast(static_cast(InOutMetrics.NumPlayerFitCells) + / static_cast(InOutMetrics.NumAir)) + : 0.0f; + + TArray Components; + int32 NumComponents = 0; + int64 LargestComponentCells = 0; + int32 LargestComponentLowestCell = INDEX_NONE; + int32 NumComponentsAtLeast1Pct = 0; + FloodFillAir( + Grid, + Components, + NumComponents, + InOutMetrics.NumPlayerFitCells, + LargestComponentCells, + LargestComponentLowestCell, + NumComponentsAtLeast1Pct, + nullptr, + &PlayerFit, + &PlayerFitSupportHeights, + &Settings, + EPlayerTraversalPolicy::WalkOnly); + InOutMetrics.NumTraversableComponents = NumComponents; + InOutMetrics.LargestTraversableComponentCells = LargestComponentCells; + InOutMetrics.TraversableComponentShare = InOutMetrics.NumPlayerFitCells > 0 + ? static_cast(static_cast(LargestComponentCells) + / static_cast(InOutMetrics.NumPlayerFitCells)) + : 0.0f; + InOutMetrics.MinimumPlayerClearanceVoxels = MinimumPlayerClearanceChebyshev( + Grid, PlayerFit); + if (OutPlayerFitMask != nullptr) + { + *OutPlayerFitMask = MoveTemp(PlayerFit); + } + if (OutPlayerFitSupportHeights != nullptr) + { + *OutPlayerFitSupportHeights = MoveTemp(PlayerFitSupportHeights); + } + } + + void BuildPlayerFitConnectivityMetrics( + const FSampleGrid& Grid, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics& OutMetrics, + TArray& OutPlayerFitMask, + TArray& OutPlayerFitSupportHeights) + { + OutMetrics = FVoxelStrateMetrics(); + OutMetrics.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; + OutMetrics.SampledMinZ = Grid.SampledMinZ; + OutMetrics.SampledMaxZ = Grid.SampledMaxZ; + OutMetrics.SampledNumX = Grid.NumX; + OutMetrics.SampledNumY = Grid.NumY; + OutMetrics.SampledNumZ = Grid.NumZ; + OutMetrics.SampledMinX = Grid.MinX; + OutMetrics.SampledMaxX = Grid.MaxX; + OutMetrics.SampledMinY = Grid.MinY; + OutMetrics.SampledMaxY = Grid.MaxY; + OutMetrics.NumSampled = Grid.CellCount; + OutMetrics.NumAir = CountAirCells(Grid); + OutMetrics.NumSolid = static_cast(Grid.CellCount) - OutMetrics.NumAir; + OutMetrics.AirFraction = OutMetrics.NumSampled > 0 + ? static_cast(static_cast(OutMetrics.NumAir) + / static_cast(OutMetrics.NumSampled)) + : 0.0f; + BuildPlayerFitMetrics( + Grid, Settings, OutMetrics, &OutPlayerFitMask, + &OutPlayerFitSupportHeights); + OutMetrics.bValid = OutMetrics.NumSampled > 0; + } + + float MedianFloat(TArray& Values) + { + if (Values.Num() == 0) return 0.0f; + Values.Sort(); + const int32 Middle = Values.Num() / 2; + if ((Values.Num() & 1) != 0) + { + return Values[Middle]; + } + return (Values[Middle - 1] + Values[Middle]) * 0.5f; + } + + void BuildMetricsFromGrid( + const FSampleGrid& Grid, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics& InOutMetrics, + TArray* OutPlayerFitMask = nullptr) + { + TArray Components; + int32 NumComponents = 0; + + const int64 NumAir = CountAirCells(Grid); + + int64 LargestComponentCells = 0; + int32 LargestComponentLowestCell = INDEX_NONE; + int32 NumComponentsAtLeast1Pct = 0; + TArray ComponentCells; + FloodFillAir( + Grid, + Components, + NumComponents, + NumAir, + LargestComponentCells, + LargestComponentLowestCell, + NumComponentsAtLeast1Pct, + &ComponentCells); + + InOutMetrics.NumSampled = Grid.CellCount; + InOutMetrics.NumAir = NumAir; + InOutMetrics.NumSolid = static_cast(Grid.CellCount) - NumAir; + InOutMetrics.AirFraction = static_cast( + static_cast(NumAir) / static_cast(Grid.CellCount)); + InOutMetrics.NumAirComponents = NumComponents; + InOutMetrics.AirComponentCells = MoveTemp(ComponentCells); + InOutMetrics.LargestComponentCells = LargestComponentCells; + InOutMetrics.NumComponentsAtLeast1Pct = NumComponentsAtLeast1Pct; + InOutMetrics.LargestComponentShare = NumAir > 0 + ? static_cast(static_cast(LargestComponentCells) + / static_cast(NumAir)) + : 0.0f; + if (NumAir > 0 && LargestComponentLowestCell != INDEX_NONE) + { + int32 LargestX = 0; + int32 LargestY = 0; + int32 LargestZ = 0; + DecodeIndex(Grid, LargestComponentLowestCell, LargestX, LargestY, LargestZ); + InOutMetrics.LargestComponentPoint = Grid.CellCenter(LargestX, LargestY, LargestZ); + } + + TArray ClearanceValues; + ClearanceValues.Reserve(static_cast(NumAir)); + int64 NumWalkable = 0; + const int64 XYColumnCount64 = static_cast(Grid.NumX) + * static_cast(Grid.NumY); + TArray WalkableColumns; + WalkableColumns.Init(0u, static_cast(XYColumnCount64)); + + // Candidate poses read only the immutable sampled field. Keep every worker's clearance + // values and compact column flags private, then merge by fixed Z order so the median and + // projected surface graph remain deterministic. + TArray WalkableCountsByZ; + WalkableCountsByZ.Init(0, Grid.NumZ); + TArray> ClearanceValuesByZ; + ClearanceValuesByZ.SetNum(Grid.NumZ); + TArray> WalkableColumnsByZ; + WalkableColumnsByZ.SetNum(Grid.NumZ); + ParallelFor(Grid.NumZ, [&](int32 Z) + { + TArray& SliceClearanceValues = ClearanceValuesByZ[Z]; + TArray& SliceWalkableColumns = WalkableColumnsByZ[Z]; + SliceWalkableColumns.Init(0u, static_cast(XYColumnCount64)); + int64 SliceWalkableCount = 0; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Current = Grid.Index(X, Y, Z); + if (Grid.Air[Current] == 0u || Z == 0 + || Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u) + { + continue; + } + + bool bHasHeadroom = true; + for (int32 H = 0; H < Settings.HeadroomCells; ++H) + { + const int64 HeadroomZ = static_cast(Z) + H; + if (HeadroomZ >= Grid.NumZ + || Grid.Air[Grid.Index(X, Y, static_cast(HeadroomZ))] == 0u) + { + bHasHeadroom = false; + break; + } + } + if (!bHasHeadroom) continue; + + ++SliceWalkableCount; + SliceWalkableColumns[X + Grid.NumX * Y] = 1u; + int32 ClearanceCells = 0; + while (static_cast(Z) + ClearanceCells < Grid.NumZ + && Grid.Air[Grid.Index(X, Y, Z + ClearanceCells)] != 0u) + { + ++ClearanceCells; + } + const int64 ClearanceVoxels = static_cast(ClearanceCells) + * Settings.SampleStep; + SliceClearanceValues.Add(ClearanceVoxels > INT32_MAX + ? INT32_MAX : static_cast(ClearanceVoxels)); + } + } + WalkableCountsByZ[Z] = SliceWalkableCount; + }); + + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + NumWalkable += WalkableCountsByZ[Z]; + const TArray& SliceWalkableColumns = WalkableColumnsByZ[Z]; + for (int32 Column = 0; Column < SliceWalkableColumns.Num(); ++Column) + { + if (SliceWalkableColumns[Column] != 0u) + { + WalkableColumns[Column] = 1u; + } + } + ClearanceValues.Append(ClearanceValuesByZ[Z]); + } + + InOutMetrics.WalkableFraction = NumAir > 0 + ? static_cast(static_cast(NumWalkable) + / static_cast(NumAir)) + : 0.0f; + for (const uint8 bWalkable : WalkableColumns) + { + InOutMetrics.WalkableFloorColumns += bWalkable != 0u ? 1 : 0; + } + InOutMetrics.WalkableFloorAreaFraction = XYColumnCount64 > 0 + ? static_cast(static_cast(InOutMetrics.WalkableFloorColumns) + / static_cast(XYColumnCount64)) + : 0.0f; + + // The area graph is intentionally projected to XY: each column contributes one floor + // footprint even when several sampled Z cells in it meet the walkability predicate. + // Keep the flood fill deterministic and bounded by the already bounded XY grid. + TArray VisitedWalkableColumns; + VisitedWalkableColumns.Init(0u, static_cast(XYColumnCount64)); + TArray SurfaceQueue; + SurfaceQueue.Reserve(static_cast(XYColumnCount64)); + for (int32 Column = 0; Column < WalkableColumns.Num(); ++Column) + { + if (WalkableColumns[Column] == 0u || VisitedWalkableColumns[Column] != 0u) + { + continue; + } + + ++InOutMetrics.NumWalkableSurfaceComponents; + VisitedWalkableColumns[Column] = 1u; + SurfaceQueue.Reset(); + SurfaceQueue.Add(Column); + int64 ComponentColumns = 0; + for (int32 QueueIndex = 0; QueueIndex < SurfaceQueue.Num(); ++QueueIndex) + { + const int32 CurrentColumn = SurfaceQueue[QueueIndex]; + ++ComponentColumns; + const int32 CurrentX = CurrentColumn % Grid.NumX; + const int32 CurrentY = CurrentColumn / Grid.NumX; + const int32 NeighbourColumns[4] = { + CurrentX > 0 ? CurrentColumn - 1 : INDEX_NONE, + CurrentX + 1 < Grid.NumX ? CurrentColumn + 1 : INDEX_NONE, + CurrentY > 0 ? CurrentColumn - Grid.NumX : INDEX_NONE, + CurrentY + 1 < Grid.NumY ? CurrentColumn + Grid.NumX : INDEX_NONE + }; + for (const int32 Neighbour : NeighbourColumns) + { + if (Neighbour != INDEX_NONE + && WalkableColumns[Neighbour] != 0u + && VisitedWalkableColumns[Neighbour] == 0u) + { + VisitedWalkableColumns[Neighbour] = 1u; + SurfaceQueue.Add(Neighbour); + } + } + } + InOutMetrics.LargestWalkableSurfaceColumns = FMath::Max( + InOutMetrics.LargestWalkableSurfaceColumns, ComponentColumns); + } + InOutMetrics.LargestWalkableSurfaceShare = InOutMetrics.WalkableFloorColumns > 0 + ? static_cast(static_cast(InOutMetrics.LargestWalkableSurfaceColumns) + / static_cast(InOutMetrics.WalkableFloorColumns)) + : 0.0f; + + if (ClearanceValues.Num() > 0) + { + ClearanceValues.Sort(); + // The public metric is integral, so use the lower median for an even sample count. + InOutMetrics.MedianVerticalClearance = ClearanceValues[(ClearanceValues.Num() - 1) / 2]; + } + + if (InOutMetrics.NumSolid > 0 && NumAir > 0) + { + TArray Distances; + BuildManhattanDistanceToSolid(Grid, Distances); + + TArray> FeatureScalesByZ; + FeatureScalesByZ.SetNum(Grid.NumZ); + ParallelFor(Grid.NumZ, [&](int32 Z) + { + TArray& SliceFeatureScales = FeatureScalesByZ[Z]; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Index = Grid.Index(X, Y, Z); + if (Grid.Air[Index] != 0u) + { + SliceFeatureScales.Add(static_cast(Distances[Index]) + * static_cast(Settings.SampleStep)); + } + } + } + }); + TArray FeatureScales; + FeatureScales.Reserve(static_cast(NumAir)); + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + FeatureScales.Append(FeatureScalesByZ[Z]); + } + InOutMetrics.MedianFeatureScale = MedianFloat(FeatureScales); + } + + // This is intentionally a separate fine-resolution pass. The legacy metrics above may + // be useful at SampleStep=4/8, but they cannot answer whether the confirmed player capsule + // fits. A coarse grid therefore receives an explicit refusal instead of an estimate. + BuildPlayerFitMetrics(Grid, Settings, InOutMetrics, OutPlayerFitMask); + } + + bool FindCellForPoint(const FSampleGrid& Grid, const FVector& Point, int32& OutCell) + { + if (!Grid.ContainsPoint(Point)) return false; + + const float Step = static_cast(Grid.SampleStep); + const int32 X = FMath::Clamp(FMath::FloorToInt((Point.X - Grid.MinX) / Step), 0, Grid.NumX - 1); + const int32 Y = FMath::Clamp(FMath::FloorToInt((Point.Y - Grid.MinY) / Step), 0, Grid.NumY - 1); + const int32 Z = FMath::Clamp(FMath::FloorToInt((Point.Z - Grid.MinZ) / Step), 0, Grid.NumZ - 1); + OutCell = Grid.Index(X, Y, Z); + return true; + } + + enum class EEndpointCellResult : uint8 + { + OutOfWindow, + Air, + Solid, + NotPlayerFit + }; + + EEndpointCellResult ResolveEndpointCell( + const FSampleGrid& Grid, + const FVector& Point, + int32& OutCell, + bool& bOutSnapped, + const TArray* EligibilityMask = nullptr) + { + bOutSnapped = false; + int32 OriginalCell = -1; + if (!FindCellForPoint(Grid, Point, OriginalCell)) + { + return EEndpointCellResult::OutOfWindow; + } + const auto IsEligible = [&, EligibilityMask](int32 Cell) + { + return Grid.Air[Cell] != 0u + && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u); + }; + if (IsEligible(OriginalCell)) + { + OutCell = OriginalCell; + return EEndpointCellResult::Air; + } + + int32 OriginalX = 0; + int32 OriginalY = 0; + int32 OriginalZ = 0; + DecodeIndex(Grid, OriginalCell, OriginalX, OriginalY, OriginalZ); + + int32 BestCell = INDEX_NONE; + float BestDistanceSquared = FLT_MAX; + const int32 MaxX = Grid.NumX - 1; + const int32 MaxY = Grid.NumY - 1; + const int32 MaxZ = Grid.NumZ - 1; + const int32 MinX = OriginalX > 0 ? OriginalX - 1 : 0; + const int32 MinY = OriginalY > 0 ? OriginalY - 1 : 0; + const int32 MinZ = OriginalZ > 0 ? OriginalZ - 1 : 0; + const int32 CandidateMaxX = OriginalX < MaxX ? OriginalX + 1 : MaxX; + const int32 CandidateMaxY = OriginalY < MaxY ? OriginalY + 1 : MaxY; + const int32 CandidateMaxZ = OriginalZ < MaxZ ? OriginalZ + 1 : MaxZ; + for (int32 Z = MinZ; + Z <= CandidateMaxZ; + ++Z) + { + for (int32 Y = MinY; + Y <= CandidateMaxY; + ++Y) + { + for (int32 X = MinX; + X <= CandidateMaxX; + ++X) + { + if (X == OriginalX && Y == OriginalY && Z == OriginalZ) + { + continue; + } + + const int32 Candidate = Grid.Index(X, Y, Z); + if (!IsEligible(Candidate)) + { + continue; + } + + const float DistanceSquared = FVector::DistSquared( + Point, Grid.CellCenter(X, Y, Z)); + if (DistanceSquared < BestDistanceSquared + || (DistanceSquared == BestDistanceSquared + && (BestCell == INDEX_NONE || Candidate < BestCell))) + { + BestDistanceSquared = DistanceSquared; + BestCell = Candidate; + } + } + } + } + + if (BestCell == INDEX_NONE) + { + if (EligibilityMask != nullptr) + { + // Passage mouth points are authored on the centreline of a carved connector, not + // necessarily on the floor-anchor lattice used by PlayerFit. Resolve them to the + // nearest player-fitting floor cell over the bounded fine grid, rather than + // declaring an in-air mouth unreachable just because its floor is several cells + // below. The endpoint is reported as snapped, and the route re-check below still + // validates the endpoint-to-anchor segment against density. + for (int32 Candidate = 0; Candidate < Grid.CellCount; ++Candidate) + { + if ((*EligibilityMask)[Candidate] == 0u) + { + continue; + } + + int32 CandidateX = 0; + int32 CandidateY = 0; + int32 CandidateZ = 0; + DecodeIndex(Grid, Candidate, CandidateX, CandidateY, CandidateZ); + const float DistanceSquared = FVector::DistSquared( + Point, Grid.CellCenter(CandidateX, CandidateY, CandidateZ)); + if (DistanceSquared < BestDistanceSquared + || (DistanceSquared == BestDistanceSquared + && (BestCell == INDEX_NONE || Candidate < BestCell))) + { + BestDistanceSquared = DistanceSquared; + BestCell = Candidate; + } + } + } + } + + if (BestCell == INDEX_NONE) + { + return EligibilityMask != nullptr + ? EEndpointCellResult::NotPlayerFit + : EEndpointCellResult::Solid; + } + + OutCell = BestCell; + bOutSnapped = true; + return EEndpointCellResult::Air; + } + + struct FCoarseEdge + { + int32 First = INDEX_NONE; + int32 Second = INDEX_NONE; + + bool IsValid() const + { + return First >= 0 && Second >= 0 && First != Second; + } + }; + + FCoarseEdge MakeCoarseEdge(int32 A, int32 B) + { + FCoarseEdge Edge; + Edge.First = FMath::Min(A, B); + Edge.Second = FMath::Max(A, B); + return Edge; + } + + bool IsCoarseEdgeBlocked( + const TArray& BlockedEdges, + int32 A, + int32 B) + { + const FCoarseEdge Candidate = MakeCoarseEdge(A, B); + for (const FCoarseEdge& Blocked : BlockedEdges) + { + if (Blocked.First == Candidate.First && Blocked.Second == Candidate.Second) + { + return true; + } + } + return false; + } + + bool FullResolutionAirOnSegment( + const UVoxelGenerator* Generator, + const IVoxelStrateDensitySampler* Sampler, + const FVector& Start, + const FVector& End) + { + const FVector Delta = End - Start; + const float MaxDelta = FMath::Max3( + FMath::Abs(Delta.X), FMath::Abs(Delta.Y), FMath::Abs(Delta.Z)); + const int32 NumSteps = FMath::Max(1, FMath::CeilToInt(MaxDelta)); + for (int32 Step = 0; Step <= NumSteps; ++Step) + { + const float Alpha = static_cast(Step) / static_cast(NumSteps); + const FVector Sample = Start + Delta * Alpha; + const float Density = Sampler != nullptr + ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) + : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return false; + } + } + return true; + } + + bool FullResolutionPathIsAir( + const UVoxelGenerator* Generator, + const IVoxelStrateDensitySampler* Sampler, + const FSampleGrid& Grid, + const FVector& A, + const FVector& B, + const TArray& Path, + FCoarseEdge& OutFailedEdge, + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) + { + OutFailedEdge = FCoarseEdge(); + + if (EligibilityMask != nullptr) + { + // A player-fit route is deliberately a graph over supported free poses, not a second + // air route with a coarse-cell illusion. The mask was built at SampleStep=1 and every + // edge below is between adjacent player-fitting cells. + for (int32 PathIndex = 0; PathIndex < Path.Num(); ++PathIndex) + { + if (!EligibilityMask->IsValidIndex(Path[PathIndex]) + || (*EligibilityMask)[Path[PathIndex]] == 0u) + { + if (PathIndex > 0) + { + OutFailedEdge = MakeCoarseEdge( + Path[PathIndex - 1], Path[PathIndex]); + } + return false; + } + if (PathIndex > 0 + && PlayerFitSupportHeights != nullptr + && TraversalSettings != nullptr) + { + bool bClimb = false; + if (!IsPlayerTraversalEdge( + Grid, *EligibilityMask, PlayerFitSupportHeights, + TraversalSettings, Path[PathIndex - 1], Path[PathIndex], bClimb, + TraversalPolicy)) + { + OutFailedEdge = MakeCoarseEdge( + Path[PathIndex - 1], Path[PathIndex]); + return false; + } + } + } + int32 StartX = 0; + int32 StartY = 0; + int32 StartZ = 0; + DecodeIndex(Grid, Path[0], StartX, StartY, StartZ); + int32 EndX = 0; + int32 EndY = 0; + int32 EndZ = 0; + DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ); + if (!FullResolutionAirOnSegment( + Generator, Sampler, A, Grid.CellCenter(StartX, StartY, StartZ)) + || !FullResolutionAirOnSegment( + Generator, Sampler, Grid.CellCenter(EndX, EndY, EndZ), B)) + { + // Endpoint approaches have no graph edge to exclude. The player-fit route is + // therefore unknown if either mouth-to-anchor segment contains solid density. + return false; + } + return true; + } + + int32 StartX = 0; + int32 StartY = 0; + int32 StartZ = 0; + DecodeIndex(Grid, Path[0], StartX, StartY, StartZ); + int32 EndX = 0; + int32 EndY = 0; + int32 EndZ = 0; + DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ); + + if (!FullResolutionAirOnSegment(Generator, Sampler, A, Grid.CellCenter(StartX, StartY, StartZ))) + { + // The endpoint-to-cell-center segment has no coarse cell pair to exclude. It is + // common to every route from this endpoint, so report unknown rather than claiming + // that the pair is disconnected at this resolution. + return false; + } + + for (int32 PathIndex = 1; PathIndex < Path.Num(); ++PathIndex) + { + int32 PreviousX = 0; + int32 PreviousY = 0; + int32 PreviousZ = 0; + DecodeIndex(Grid, Path[PathIndex - 1], PreviousX, PreviousY, PreviousZ); + int32 CurrentX = 0; + int32 CurrentY = 0; + int32 CurrentZ = 0; + DecodeIndex(Grid, Path[PathIndex], CurrentX, CurrentY, CurrentZ); + if (!FullResolutionAirOnSegment( + Generator, Sampler, + Grid.CellCenter(PreviousX, PreviousY, PreviousZ), + Grid.CellCenter(CurrentX, CurrentY, CurrentZ))) + { + OutFailedEdge = MakeCoarseEdge(Path[PathIndex - 1], Path[PathIndex]); + return false; + } + } + + if (!FullResolutionAirOnSegment(Generator, Sampler, Grid.CellCenter(EndX, EndY, EndZ), B)) + { + // As above, the final endpoint segment is shared by every route that reaches the + // goal cell and therefore has no precise coarse edge to exclude. + return false; + } + return true; + } + + bool FindCoarsePath( + const FSampleGrid& Grid, + int32 Start, + int32 Goal, + const TArray& BlockedEdges, + TArray& OutPath, + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) + { + const auto IsEligible = [&, EligibilityMask](int32 Cell) + { + return Grid.Air[Cell] != 0u + && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u); + }; + if (!IsEligible(Start) || !IsEligible(Goal)) + { + return false; + } + + TArray Parent; + Parent.SetNumUninitialized(Grid.CellCount); + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + Parent[Index] = -2; // unvisited; -1 is the root's parent + } + + TArray Queue; + Queue.SetNumUninitialized(Grid.CellCount); + int32 Head = 0; + int32 Tail = 0; + Parent[Start] = -1; + Queue[Tail++] = Start; + + while (Head < Tail && Parent[Goal] == -2) + { + const int32 Current = Queue[Head++]; + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Current, X, Y, Z); + + auto Visit = [&](int32 Neighbor) + { + if (IsEligible(Neighbor) + && Parent[Neighbor] == -2 + && !IsCoarseEdgeBlocked(BlockedEdges, Current, Neighbor)) + { + Parent[Neighbor] = Current; + Queue[Tail++] = Neighbor; + } + }; + + if (EligibilityMask != nullptr + && PlayerFitSupportHeights != nullptr + && TraversalSettings != nullptr) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, *EligibilityMask, PlayerFitSupportHeights, + TraversalSettings, Current, Neighbours, TraversalPolicy); + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + Visit(Neighbour.Cell); + } + } + else + { + // The order is fixed so the recovered route is deterministic. This is a real BFS + // parent chain, not just a component-membership answer. + if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); + if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); + if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); + if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + } + } + + if (Parent[Goal] == -2) + { + return false; + } + + OutPath.Reset(); + OutPath.Reserve(Tail); + for (int32 Current = Goal; Current >= 0; Current = Parent[Current]) + { + OutPath.Add(Current); + if (Current == Start) break; + } + if (OutPath.Last() != Start) + { + OutPath.Reset(); + return false; + } + + for (int32 Left = 0, Right = OutPath.Num() - 1; Left < Right; ++Left, --Right) + { + Swap(OutPath[Left], OutPath[Right]); + } + return true; + } + + EVoxelConnectivityResult EvaluateConnectivityOnGrid( + const UVoxelGenerator* Generator, + const IVoxelStrateDensitySampler* Sampler, + const FSampleGrid& Grid, + const FVector& AVoxel, + const FVector& BVoxel, + int32& OutStart, + int32& OutGoal, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + int32& OutNumRouteRetries, + int32 MaxRouteRetries, + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) + { + OutStart = -1; + OutGoal = -1; + bOutStartSnapped = false; + bOutGoalSnapped = false; + OutNumRouteRetries = 0; + const EEndpointCellResult StartCell = ResolveEndpointCell( + Grid, AVoxel, OutStart, bOutStartSnapped, EligibilityMask); + const EEndpointCellResult GoalCell = ResolveEndpointCell( + Grid, BVoxel, OutGoal, bOutGoalSnapped, EligibilityMask); + if (StartCell == EEndpointCellResult::OutOfWindow + || GoalCell == EEndpointCellResult::OutOfWindow) + { + return EVoxelConnectivityResult::OutOfWindow; + } + if (StartCell == EEndpointCellResult::Solid) + { + return EVoxelConnectivityResult::StartCellSolid; + } + if (StartCell == EEndpointCellResult::NotPlayerFit) + { + return EVoxelConnectivityResult::StartCellNotPlayerFit; + } + if (GoalCell == EEndpointCellResult::Solid) + { + return EVoxelConnectivityResult::GoalCellSolid; + } + if (GoalCell == EEndpointCellResult::NotPlayerFit) + { + return EVoxelConnectivityResult::GoalCellNotPlayerFit; + } + + TArray BlockedEdges; + TArray Path; + for (;;) + { + if (!FindCoarsePath( + Grid, OutStart, OutGoal, BlockedEdges, Path, EligibilityMask, + PlayerFitSupportHeights, TraversalSettings, TraversalPolicy)) + { + // This is a negative verdict only after all edges excluded by earlier + // full-resolution failures leave no coarse route at this resolution. + return EVoxelConnectivityResult::NotConnectedAtThisResolution; + } + + // The coarse route is evidence, not a pass. Re-check every candidate at one-voxel + // spacing in the original voxel field. A single solid sample refutes only this + // route, so exclude its precise coarse edge and search again. + FCoarseEdge FailedEdge; + if (FullResolutionPathIsAir( + Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge, + EligibilityMask, PlayerFitSupportHeights, TraversalSettings, TraversalPolicy)) + { + return EVoxelConnectivityResult::Connected; + } + + if (OutNumRouteRetries >= MaxRouteRetries || !FailedEdge.IsValid()) + { + // A route can be refuted without a retryable pair when the failure is in an + // endpoint-to-cell segment (or when the explicit retry budget is spent). Both + // cases are unknown, never evidence of disconnection. + return EVoxelConnectivityResult::CoarseLiedBudgetExhausted; + } + + check(FailedEdge.First != FailedEdge.Second); + check(!IsCoarseEdgeBlocked( + BlockedEdges, FailedEdge.First, FailedEdge.Second)); + BlockedEdges.Add(FailedEdge); + ++OutNumRouteRetries; + } + } + + float DistanceToComponent( + const FSampleGrid& Grid, + int32 StartCell, + int32 TargetComponent, + const TArray& Components) + { + if (Components[StartCell] == TargetComponent) + { + return 0.0f; + } + + int32 StartX = 0; + int32 StartY = 0; + int32 StartZ = 0; + DecodeIndex(Grid, StartCell, StartX, StartY, StartZ); + int64 BestDistanceSquared = MAX_int64; + for (int32 Index = 0; Index < Grid.CellCount; ++Index) + { + if (Components[Index] != TargetComponent) + { + continue; + } + + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Index, X, Y, Z); + const int64 DX = static_cast(X) - static_cast(StartX); + const int64 DY = static_cast(Y) - static_cast(StartY); + const int64 DZ = static_cast(Z) - static_cast(StartZ); + const int64 DistanceSquared = DX * DX + DY * DY + DZ * DZ; + BestDistanceSquared = FMath::Min(BestDistanceSquared, DistanceSquared); + } + + return BestDistanceSquared == MAX_int64 + ? -1.0f + : FMath::Sqrt(static_cast(BestDistanceSquared)); + } + + void PopulateConnectivityDiagnostics( + const FSampleGrid& Grid, + int32 Start, + int32 Goal, + EVoxelConnectivityResult ConnectivityResult, + bool bStartSnapped, + bool bGoalSnapped, + int32 NumRouteRetries, + FVoxelConnectivityDiagnostics& OutDiagnostics, + const TArray* EligibilityMask = nullptr, + TArray* OutComponents = nullptr, + int32* OutNumComponents = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) + { + OutDiagnostics = FVoxelConnectivityDiagnostics(); + if (OutComponents != nullptr) + { + OutComponents->Reset(); + } + if (OutNumComponents != nullptr) + { + *OutNumComponents = 0; + } + OutDiagnostics.Result = ConnectivityResult; + OutDiagnostics.bPlayerFitRestricted = EligibilityMask != nullptr; + OutDiagnostics.bPlayerFitResolved = EligibilityMask != nullptr; + OutDiagnostics.bStartSnapped = bStartSnapped; + OutDiagnostics.bGoalSnapped = bGoalSnapped; + OutDiagnostics.NumRouteRetries = NumRouteRetries; + OutDiagnostics.bValid = ConnectivityResult != EVoxelConnectivityResult::OutOfWindow + || (Start >= 0 && Goal >= 0); + if (Start < 0 || Goal < 0) + { + return; + } + + TArray Components; + int32 NumComponents = 0; + int64 LargestComponentCells = 0; + int32 LargestComponentLowestCell = INDEX_NONE; + int32 NumComponentsAtLeast1Pct = 0; + const int64 NumAir = CountAirCells(Grid, EligibilityMask); + FloodFillAir( + Grid, + Components, + NumComponents, + NumAir, + LargestComponentCells, + LargestComponentLowestCell, + NumComponentsAtLeast1Pct, + nullptr, + EligibilityMask, + PlayerFitSupportHeights, + TraversalSettings, + TraversalPolicy); + + if (OutComponents != nullptr) + { + *OutComponents = Components; + } + if (OutNumComponents != nullptr) + { + *OutNumComponents = NumComponents; + } + + const int32 StartComponent = Components[Start]; + const int32 GoalComponent = Components[Goal]; + if (StartComponent < 0 || GoalComponent < 0) + { + return; + } + + TArray ComponentCells; + ComponentCells.Init(0, NumComponents); + for (const int32 Component : Components) + { + if (Component >= 0) + { + ++ComponentCells[Component]; + } + } + + OutDiagnostics.NumAirCells = NumAir; + OutDiagnostics.StartComponentCells = ComponentCells[StartComponent]; + OutDiagnostics.GoalComponentCells = ComponentCells[GoalComponent]; + OutDiagnostics.LargestComponentCells = LargestComponentCells; + if (NumAir > 0) + { + OutDiagnostics.StartComponentShare = static_cast( + static_cast(OutDiagnostics.StartComponentCells) + / static_cast(NumAir)); + OutDiagnostics.GoalComponentShare = static_cast( + static_cast(OutDiagnostics.GoalComponentCells) + / static_cast(NumAir)); + OutDiagnostics.LargestComponentShare = static_cast( + static_cast(LargestComponentCells) + / static_cast(NumAir)); + } + OutDiagnostics.bStartComponentIsLargest = + OutDiagnostics.StartComponentCells == LargestComponentCells; + OutDiagnostics.bGoalComponentIsLargest = + OutDiagnostics.GoalComponentCells == LargestComponentCells; + TArray ComponentDistances; + ComponentDistances.Init(0.0f, 2); + ParallelFor(2, [&](int32 DistanceIndex) + { + ComponentDistances[DistanceIndex] = DistanceIndex == 0 + ? DistanceToComponent(Grid, Start, GoalComponent, Components) + : DistanceToComponent(Grid, Goal, StartComponent, Components); + }); + OutDiagnostics.StartToGoalComponentDistanceCells = ComponentDistances[0]; + OutDiagnostics.GoalToStartComponentDistanceCells = ComponentDistances[1]; + } + + // The hard capability graph is deliberately flooded with walking edges only. This second + // pass contracts each walk-only component to one node and adds only the explicitly supported + // climb edges between nodes. It gives the reporting tiers without allowing a climb to turn a + // failed primordial walk-only verdict into a pass. + void BuildClimbTierFromWalkComponents( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray& PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings& Settings, + const TArray& Components, + int32 NumComponents, + int32 StartCell, + int32 GoalCell, + int64 PlayerFitCells, + int64& OutWalkOnlyReachableCells, + int64& OutClimbReachableCells, + int64& OutMineRequiredCells, + bool& bOutClimbCanReach, + int64& OutClimbEdges) + { + OutWalkOnlyReachableCells = 0; + OutClimbReachableCells = 0; + OutMineRequiredCells = 0; + bOutClimbCanReach = false; + OutClimbEdges = 0; + + if (NumComponents <= 0 + || Components.Num() != Grid.CellCount + || !Components.IsValidIndex(StartCell) + || !Components.IsValidIndex(GoalCell)) + { + return; + } + + const int32 StartComponent = Components[StartCell]; + const int32 GoalComponent = Components[GoalCell]; + if (StartComponent < 0 || StartComponent >= NumComponents + || GoalComponent < 0 || GoalComponent >= NumComponents) + { + return; + } + + TArray> CellsByComponent; + CellsByComponent.SetNum(NumComponents); + for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) + { + const int32 Component = Components[Cell]; + if (Component >= 0 && Component < NumComponents + && IsEligiblePlayerFitCell(Grid, PlayerFitMask, Cell)) + { + CellsByComponent[Component].Add(Cell); + } + } + + OutWalkOnlyReachableCells = CellsByComponent[StartComponent].Num(); + + TArray ReachedComponents; + ReachedComponents.Init(0u, NumComponents); + TArray Queue; + Queue.Reserve(NumComponents); + ReachedComponents[StartComponent] = 1u; + Queue.Add(StartComponent); + + for (int32 QueueIndex = 0; QueueIndex < Queue.Num(); ++QueueIndex) + { + const int32 Component = Queue[QueueIndex]; + for (const int32 Cell : CellsByComponent[Component]) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, + PlayerFitMask, + &PlayerFitSupportHeights, + &Settings, + Cell, + Neighbours, + EPlayerTraversalPolicy::WalkAndClimb); + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + if (!Neighbour.bClimb || !Components.IsValidIndex(Neighbour.Cell)) + { + continue; + } + ++OutClimbEdges; + const int32 NeighbourComponent = Components[Neighbour.Cell]; + if (NeighbourComponent >= 0 && NeighbourComponent < NumComponents + && NeighbourComponent != Component + && ReachedComponents[NeighbourComponent] == 0u) + { + ReachedComponents[NeighbourComponent] = 1u; + Queue.Add(NeighbourComponent); + } + } + } + } + + for (int32 Component = 0; Component < NumComponents; ++Component) + { + if (ReachedComponents[Component] != 0u) + { + OutClimbReachableCells += CellsByComponent[Component].Num(); + } + } + bOutClimbCanReach = ReachedComponents[GoalComponent] != 0u; + OutMineRequiredCells = FMath::Max( + 0, + PlayerFitCells - OutClimbReachableCells); + } + + EVoxelConnectivityResult QueryConnectivity( + const UVoxelGenerator* Generator, + const UVoxelStrateManager* Manager, + const IVoxelStrateDensitySampler* Sampler, + int32 StrateIndex, + int32 ExplicitBottomWorldZ, + int32 ExplicitTopWorldZ, + float ExplicitBoundarySealThickness, + bool bUseExplicitBounds, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + int32& OutNumRouteRetries, + FVoxelConnectivityDiagnostics* OutDiagnostics, + bool bPlayerFitRestricted = false, + FVoxelStrateMetrics* OutPlayerMetrics = nullptr) + { + bOutStartSnapped = false; + bOutGoalSnapped = false; + OutNumRouteRetries = 0; + if (OutPlayerMetrics != nullptr) + { + *OutPlayerMetrics = FVoxelStrateMetrics(); + } + auto RefusePlayerFit = [&](const FString& Reason) + { + if (OutDiagnostics != nullptr) + { + *OutDiagnostics = FVoxelConnectivityDiagnostics(); + OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; + OutDiagnostics->RefusalReason = Reason; + } + if (OutPlayerMetrics != nullptr) + { + OutPlayerMetrics->RefusalReason = Reason; + OutPlayerMetrics->PlayerFitRefusalReason = Reason; + } + }; + if (!FMath::IsFinite(AVoxel.X) || !FMath::IsFinite(AVoxel.Y) + || !FMath::IsFinite(AVoxel.Z) || !FMath::IsFinite(BVoxel.X) + || !FMath::IsFinite(BVoxel.Y) || !FMath::IsFinite(BVoxel.Z)) + { + if (OutDiagnostics != nullptr) + { + *OutDiagnostics = FVoxelConnectivityDiagnostics(); + OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; + } + return EVoxelConnectivityResult::OutOfWindow; + } + if (Settings.MaxRouteRetries < 0) + { + if (OutDiagnostics != nullptr) + { + *OutDiagnostics = FVoxelConnectivityDiagnostics(); + OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; + } + return EVoxelConnectivityResult::OutOfWindow; + } + + if (bPlayerFitRestricted && Settings.SampleStep != 1) + { + RefusePlayerFit(FString::Printf( + TEXT("Player-fit connectivity refused: SampleStep=%d; exact capsule fit requires SampleStep=1."), + Settings.SampleStep)); + return EVoxelConnectivityResult::OutOfWindow; + } + + FSampleGrid Grid; + FString RefusalReason; + if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex, + ExplicitBottomWorldZ, ExplicitTopWorldZ, + ExplicitBoundarySealThickness, bUseExplicitBounds, + Settings, bPlayerFitRestricted, Grid, RefusalReason)) + { + if (OutDiagnostics != nullptr) + { + *OutDiagnostics = FVoxelConnectivityDiagnostics(); + OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; + OutDiagnostics->RefusalReason = RefusalReason; + } + if (OutPlayerMetrics != nullptr) + { + OutPlayerMetrics->RefusalReason = RefusalReason; + OutPlayerMetrics->PlayerFitRefusalReason = RefusalReason; + } + return EVoxelConnectivityResult::OutOfWindow; + } + + TArray PlayerFitMask; + TArray PlayerFitSupportHeights; + FVoxelStrateMetrics PlayerMetrics; + if (bPlayerFitRestricted) + { + // A player-fit query needs only the fine-grid air count plus the direct capsule + // stencil. Do not pay for the legacy all-air flood fill, walkable-column scan, or + // Manhattan feature transform here: those values belong to the separate legacy + // measurement pass, and recomputing them for every fine candidate made the gate + // needlessly expensive without changing its answer. + BuildPlayerFitConnectivityMetrics( + Grid, Settings, PlayerMetrics, PlayerFitMask, PlayerFitSupportHeights); + if (OutPlayerMetrics != nullptr) + { + *OutPlayerMetrics = PlayerMetrics; + } + if (!PlayerMetrics.bPlayerFitResolved) + { + RefusePlayerFit(PlayerMetrics.PlayerFitRefusalReason); + return EVoxelConnectivityResult::OutOfWindow; + } + } + + int32 Start = -1; + int32 Goal = -1; + const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid( + Generator, + Sampler, + Grid, + AVoxel, + BVoxel, + Start, + Goal, + bOutStartSnapped, + bOutGoalSnapped, + OutNumRouteRetries, + Settings.MaxRouteRetries, + bPlayerFitRestricted ? &PlayerFitMask : nullptr, + bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, + bPlayerFitRestricted ? &Settings : nullptr, + bPlayerFitRestricted + ? EPlayerTraversalPolicy::WalkOnly + : EPlayerTraversalPolicy::WalkAndClimb); + if (OutDiagnostics != nullptr) + { + PopulateConnectivityDiagnostics( + Grid, + Start, + Goal, + Result, + bOutStartSnapped, + bOutGoalSnapped, + OutNumRouteRetries, + *OutDiagnostics, + bPlayerFitRestricted ? &PlayerFitMask : nullptr, + nullptr, + nullptr, + bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, + bPlayerFitRestricted ? &Settings : nullptr, + bPlayerFitRestricted + ? EPlayerTraversalPolicy::WalkOnly + : EPlayerTraversalPolicy::WalkAndClimb); + if (bPlayerFitRestricted && + (Result == EVoxelConnectivityResult::StartCellNotPlayerFit + || Result == EVoxelConnectivityResult::GoalCellNotPlayerFit)) + { + OutDiagnostics->RefusalReason = Result == EVoxelConnectivityResult::StartCellNotPlayerFit + ? TEXT("arrival endpoint is not a player-fitting floor cell") + : TEXT("departure endpoint is not a player-fitting floor cell"); + } + } + return Result; + } + + int32 CountPlayerFitNeighbours( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* Settings, + int32 Cell, + EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbours, Policy); + int32 Count = 0; + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + ++Count; + } + return Count; + } + + int32 LocalAirSpanVoxels( + const FSampleGrid& Grid, + int32 Cell, + int32 Axis, + int32 MaxRelevantSpanVoxels) + { + if (Cell < 0 || Cell >= Grid.CellCount || Grid.Air[Cell] == 0u) + { + return 0; + } + + int32 CentreX = 0; + int32 CentreY = 0; + int32 CentreZ = 0; + DecodeIndex(Grid, Cell, CentreX, CentreY, CentreZ); + int32 Span = 1; + bool bTouchesWindowBoundary = false; + for (const int32 Sign : { -1, 1 }) + { + int32 Offset = 1; + for (;; ++Offset) + { + if (Span >= MaxRelevantSpanVoxels) + { + return Span; + } + const int32 X = Axis == 0 ? CentreX + Sign * Offset : CentreX; + const int32 Y = Axis == 1 ? CentreY + Sign * Offset : CentreY; + const int32 Z = CentreZ; + if (X < 0 || X >= Grid.NumX || Y < 0 || Y >= Grid.NumY) + { + bTouchesWindowBoundary = true; + break; + } + if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) + { + break; + } + ++Span; + } + } + // A window edge is a lower bound, not a solid wall. Do not label a corridor narrow merely + // because the fitted diagnostic box clipped the air span before reaching rock. + return bTouchesWindowBoundary ? MAX_int32 : Span * Grid.SampleStep; + } + + float LocalWidestHorizontalAirSpanVoxels( + const FSampleGrid& Grid, + int32 Cell, + float NarrowGapThresholdVoxels) + { + const int32 MaxRelevantSpanVoxels = FMath::Max( + 1, + FMath::CeilToInt(NarrowGapThresholdVoxels)); + return static_cast(FMath::Max( + LocalAirSpanVoxels(Grid, Cell, 0, MaxRelevantSpanVoxels), + LocalAirSpanVoxels(Grid, Cell, 1, MaxRelevantSpanVoxels))); + } + + void WalkPlayerFitGraph( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* TraversalSettings, + EPlayerTraversalPolicy TraversalPolicy, + int32 Start, + int32 Goal, + float NarrowGapThresholdVoxels, + FVoxelPlayerFitWalkReport& InOutReport, + int32& OutFinalCell, + int32& OutLastReachedCell) + { + OutFinalCell = INDEX_NONE; + OutLastReachedCell = INDEX_NONE; + if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Start) + || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, Goal)) + { + return; + } + + OutFinalCell = Start; + OutLastReachedCell = Start; + + TArray Visited; + Visited.Init(0u, Grid.CellCount); + TArray NarrowCell; + NarrowCell.Init(255u, Grid.CellCount); + TArray Stack; + TArray NextDirection; + // Reserve the full bounded capacity up front. TArray growth slack is otherwise allowed + // to exceed the walker's stated memory budget while the DFS discovers a large component. + Stack.Reserve(Grid.CellCount); + NextDirection.Reserve(Grid.CellCount); + Stack.Add(Start); + NextDirection.Add(0); + Visited[Start] = 1u; + + auto RecordTraversal = [&](int32 From, int32 To) + { + ++InOutReport.AgentGraphTraversals; + bool bClimb = false; + if (PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr + && IsPlayerTraversalEdge( + Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, + From, To, bClimb, TraversalPolicy) + && bClimb) + { + ++InOutReport.ClimbTraversals; + } + auto IsNarrowCell = [&](int32 Cell) + { + if (NarrowCell[Cell] == 255u) + { + NarrowCell[Cell] = LocalWidestHorizontalAirSpanVoxels( + Grid, Cell, NarrowGapThresholdVoxels) + < NarrowGapThresholdVoxels ? 1u : 0u; + } + return NarrowCell[Cell] != 0u; + }; + const bool bNarrow = IsNarrowCell(From) || IsNarrowCell(To); + if (bNarrow) + { + ++InOutReport.NarrowGapTraversals; + } + }; + + while (Stack.Num() > 0) + { + const int32 Current = Stack.Last(); + OutFinalCell = Current; + if (Current == Goal) + { + InOutReport.bCanReachDeparture = true; + break; + } + + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, + Current, Neighbours, TraversalPolicy); + bool bAdvanced = false; + while (NextDirection.Last() < Neighbours.Num()) + { + const int32 Direction = NextDirection.Last()++; + const int32 Neighbour = Neighbours[Direction].Cell; + if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Neighbour) + || Visited[Neighbour] != 0u) + { + continue; + } + + Visited[Neighbour] = 1u; + RecordTraversal(Current, Neighbour); + Stack.Add(Neighbour); + NextDirection.Add(0); + OutLastReachedCell = Neighbour; + bAdvanced = true; + break; + } + + if (bAdvanced) + { + continue; + } + + // A degree-one pose is an actual topological dead end. A higher-degree pose with all + // branches already visited is merely a DFS backtrack through a loop/junction. + if (Current != Start && Current != Goal + && CountPlayerFitNeighbours( + Grid, PlayerFitMask, PlayerFitSupportHeights, + TraversalSettings, Current, TraversalPolicy) <= 1) + { + ++InOutReport.DeadEndsEncountered; + } + + Stack.Pop(); + NextDirection.Pop(); + if (Stack.Num() > 0) + { + RecordTraversal(Current, Stack.Last()); + } + } + + const float DistanceMeters = static_cast(InOutReport.AgentGraphTraversals) + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + InOutReport.DistanceTravelledMeters = DistanceMeters; + InOutReport.NarrowGapFraction = InOutReport.AgentGraphTraversals > 0 + ? static_cast(static_cast(InOutReport.NarrowGapTraversals) + / static_cast(InOutReport.AgentGraphTraversals)) + : 0.0f; + InOutReport.NarrowGapEventsPer100m = DistanceMeters > KINDA_SMALL_NUMBER + ? static_cast(static_cast(InOutReport.NarrowGapTraversals) + * 100.0 / static_cast(DistanceMeters)) + : 0.0f; + InOutReport.DeadEndsPer100m = DistanceMeters > KINDA_SMALL_NUMBER + ? static_cast(static_cast(InOutReport.DeadEndsEncountered) + * 100.0 / static_cast(DistanceMeters)) + : 0.0f; + InOutReport.DeadEndsEncountered = FMath::Max( + InOutReport.DeadEndsEncountered, 0); + } +} + +FVoxelStrateMetrics VF_MeasureStrate( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid) +{ + FVoxelStrateMetrics Result; + if (OutSampleGrid != nullptr) + { + *OutSampleGrid = FVoxelStrateSampleGrid(); + } + VoxelStrateMeasurePrivate::FSampleGrid Grid; + if (!VoxelStrateMeasurePrivate::BuildSampleGrid( + &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, + Settings, OutSampleGrid != nullptr || Settings.SampleStep == 1, + Grid, Result.RefusalReason)) + { + return Result; + } + + Result.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; + Result.SampledMinZ = Grid.SampledMinZ; + Result.SampledMaxZ = Grid.SampledMaxZ; + Result.SampledNumX = Grid.NumX; + Result.SampledNumY = Grid.NumY; + Result.SampledNumZ = Grid.NumZ; + Result.SampledMinX = Grid.MinX; + Result.SampledMaxX = Grid.MaxX; + Result.SampledMinY = Grid.MinY; + Result.SampledMaxY = Grid.MaxY; + TArray PlayerFitMask; + VoxelStrateMeasurePrivate::BuildMetricsFromGrid( + Grid, + Settings, + Result, + OutSampleGrid != nullptr ? &PlayerFitMask : nullptr); + Result.bValid = Result.NumSampled > 0; + if (!Result.bValid) + { + Result.RefusalReason = TEXT("The measurement grid was empty."); + } + if (OutSampleGrid != nullptr && Result.bValid) + { + VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); + OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); + OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells; + } + return Result; +} + +FVoxelStrateMetrics VF_MeasureStrateWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid) +{ + FVoxelStrateMetrics Result; + if (OutSampleGrid != nullptr) + { + *OutSampleGrid = FVoxelStrateSampleGrid(); + } + VoxelStrateMeasurePrivate::FSampleGrid Grid; + if (!VoxelStrateMeasurePrivate::BuildSampleGrid( + nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, + BoundarySealThickness, true, Settings, + OutSampleGrid != nullptr || Settings.SampleStep == 1, + Grid, Result.RefusalReason)) + { + return Result; + } + + Result.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; + Result.SampledMinZ = Grid.SampledMinZ; + Result.SampledMaxZ = Grid.SampledMaxZ; + Result.SampledNumX = Grid.NumX; + Result.SampledNumY = Grid.NumY; + Result.SampledNumZ = Grid.NumZ; + Result.SampledMinX = Grid.MinX; + Result.SampledMaxX = Grid.MaxX; + Result.SampledMinY = Grid.MinY; + Result.SampledMaxY = Grid.MaxY; + TArray PlayerFitMask; + VoxelStrateMeasurePrivate::BuildMetricsFromGrid( + Grid, + Settings, + Result, + OutSampleGrid != nullptr ? &PlayerFitMask : nullptr); + Result.bValid = Result.NumSampled > 0; + if (!Result.bValid) + { + Result.RefusalReason = TEXT("The measurement grid was empty."); + } + if (OutSampleGrid != nullptr && Result.bValid) + { + VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); + OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); + OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells; + } + return Result; +} + +EVoxelConnectivityResult VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped) +{ + int32 NumRouteRetries = 0; + return VoxelStrateMeasurePrivate::QueryConnectivity( + &Generator, + &Manager, + nullptr, + StrateIndex, + 0, 0, 0.0f, false, + AVoxel, + BVoxel, + Settings, + bOutStartSnapped, + bOutGoalSnapped, + NumRouteRetries, + nullptr); +} + +EVoxelConnectivityResult VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + int32& OutNumRouteRetries) +{ + return VoxelStrateMeasurePrivate::QueryConnectivity( + &Generator, + &Manager, + nullptr, + StrateIndex, + 0, 0, 0.0f, false, + AVoxel, + BVoxel, + Settings, + bOutStartSnapped, + bOutGoalSnapped, + OutNumRouteRetries, + nullptr); +} + +EVoxelConnectivityResult VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + FVoxelConnectivityDiagnostics& OutDiagnostics) +{ + int32 NumRouteRetries = 0; + return VoxelStrateMeasurePrivate::QueryConnectivity( + &Generator, + &Manager, + nullptr, + StrateIndex, + 0, 0, 0.0f, false, + AVoxel, + BVoxel, + Settings, + bOutStartSnapped, + bOutGoalSnapped, + NumRouteRetries, + &OutDiagnostics); +} + +FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings) +{ + FVoxelConnectivityDiagnostics Result; + int32 NumRouteRetries = 0; + VoxelStrateMeasurePrivate::QueryConnectivity( + &Generator, + &Manager, + nullptr, + StrateIndex, + 0, 0, 0.0f, false, + AVoxel, + BVoxel, + Settings, + Result.bStartSnapped, + Result.bGoalSnapped, + NumRouteRetries, + &Result); + return Result; +} + +EVoxelConnectivityResult VF_AreConnectedWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + FVoxelConnectivityDiagnostics* OutDiagnostics) +{ + int32 NumRouteRetries = 0; + return VoxelStrateMeasurePrivate::QueryConnectivity( + nullptr, nullptr, &Sampler, INDEX_NONE, + StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, + AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, + NumRouteRetries, OutDiagnostics); +} + +FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings) +{ + FVoxelConnectivityDiagnostics Result; + VF_AreConnectedWithSampler(Sampler, StrateBottomWorldZ, StrateTopWorldZ, + BoundarySealThickness, AVoxel, BVoxel, Settings, + Result.bStartSnapped, Result.bGoalSnapped, &Result); + return Result; +} + +FVoxelConnectivityDiagnostics VF_DiagnosePlayerFitConnectivityWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics* OutPlayerMetrics) +{ + FVoxelConnectivityDiagnostics Result; + int32 NumRouteRetries = 0; + VoxelStrateMeasurePrivate::QueryConnectivity( + nullptr, nullptr, &Sampler, INDEX_NONE, + StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, + AVoxel, BVoxel, Settings, Result.bStartSnapped, Result.bGoalSnapped, + NumRouteRetries, &Result, true, OutPlayerMetrics); + return Result; +} + +bool VF_MeasurePlayerFitWalkWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + FVoxelPlayerFitWalkReport& OutReport, + const TArray* ComponentProbePoints, + FVoxelStrateSampleGrid* OutSampleGrid) +{ + using namespace VoxelStrateMeasurePrivate; + + OutReport = FVoxelPlayerFitWalkReport(); + if (OutSampleGrid != nullptr) + { + *OutSampleGrid = FVoxelStrateSampleGrid(); + } + OutReport.CapsuleRadiusMeters = Settings.PlayerCapsuleRadiusVoxels + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + OutReport.CapsuleWidthMeters = 2.0f * OutReport.CapsuleRadiusMeters; + OutReport.CapsuleHeightMeters = 2.0f * Settings.PlayerCapsuleHalfHeightVoxels + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + OutReport.bClimbingEnabled = Settings.bPlayerClimbingEnabled; + OutReport.MaxClimbHeightMeters = Settings.PlayerMaxClimbHeightMeters; + OutReport.NarrowGapThresholdMeters = 1.5f * OutReport.CapsuleWidthMeters; + OutReport.NarrowGapDefinition = TEXT( + "widest axis-aligned horizontal air span at each traversed fit-graph cell"); + + const bool bFiniteEndpoints = FMath::IsFinite(AVoxel.X) + && FMath::IsFinite(AVoxel.Y) + && FMath::IsFinite(AVoxel.Z) + && FMath::IsFinite(BVoxel.X) + && FMath::IsFinite(BVoxel.Y) + && FMath::IsFinite(BVoxel.Z); + if (!bFiniteEndpoints) + { + OutReport.RefusalReason = TEXT("Arrival and departure points must be finite."); + return false; + } + if (Settings.MaxRouteRetries < 0) + { + OutReport.RefusalReason = TEXT("MaxRouteRetries cannot be negative."); + return false; + } + if (Settings.SampleStep != 1) + { + OutReport.RefusalReason = TEXT( + "Player-fit walk refused: exact capsule fit requires SampleStep=1."); + return false; + } + + FSampleGrid Grid; + FString RefusalReason; + if (!BuildSampleGrid( + nullptr, + nullptr, + &Sampler, + INDEX_NONE, + StrateBottomWorldZ, + StrateTopWorldZ, + BoundarySealThickness, + true, + Settings, + true, + Grid, + RefusalReason)) + { + OutReport.RefusalReason = RefusalReason; + return false; + } + + TArray PlayerFitMask; + TArray PlayerFitSupportHeights; + int64 NumPlayerFitCells = 0; + if (!BuildPlayerFitMask( + Grid, + Settings, + PlayerFitMask, + NumPlayerFitCells, + RefusalReason, + &PlayerFitSupportHeights)) + { + OutReport.RefusalReason = RefusalReason; + return false; + } + + int32 Start = INDEX_NONE; + int32 Goal = INDEX_NONE; + int32 NumRouteRetries = 0; + const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid( + nullptr, + &Sampler, + Grid, + AVoxel, + BVoxel, + Start, + Goal, + OutReport.bStartSnapped, + OutReport.bGoalSnapped, + NumRouteRetries, + Settings.MaxRouteRetries, + &PlayerFitMask, + &PlayerFitSupportHeights, + &Settings, + EPlayerTraversalPolicy::WalkOnly); + + FVoxelConnectivityDiagnostics Diagnostics; + TArray Components; + int32 NumComponents = 0; + PopulateConnectivityDiagnostics( + Grid, + Start, + Goal, + Result, + OutReport.bStartSnapped, + OutReport.bGoalSnapped, + NumRouteRetries, + Diagnostics, + &PlayerFitMask, + &Components, + &NumComponents, + &PlayerFitSupportHeights, + &Settings, + EPlayerTraversalPolicy::WalkOnly); + + OutReport.bValid = true; + OutReport.Result = Result; + OutReport.NumRouteRetries = NumRouteRetries; + OutReport.SampledNumX = Grid.NumX; + OutReport.SampledNumY = Grid.NumY; + OutReport.SampledNumZ = Grid.NumZ; + OutReport.SampledMinZ = Grid.SampledMinZ; + OutReport.SampledMaxZ = Grid.SampledMaxZ; + OutReport.SampledMinX = Grid.MinX; + OutReport.SampledMaxX = Grid.MaxX; + OutReport.SampledMinY = Grid.MinY; + OutReport.SampledMaxY = Grid.MaxY; + OutReport.PlayerFitVolumeCells = NumPlayerFitCells; + OutReport.WalkOnlyReachablePlayerFitCells = FMath::Max( + Diagnostics.StartComponentCells, 0); + OutReport.ReachablePlayerFitCells = OutReport.WalkOnlyReachablePlayerFitCells; + OutReport.bWalkOnlyCanReachDeparture = Result == EVoxelConnectivityResult::Connected; + OutReport.bCanReachDeparture = OutReport.bWalkOnlyCanReachDeparture; + BuildClimbTierFromWalkComponents( + Grid, + PlayerFitMask, + PlayerFitSupportHeights, + Settings, + Components, + NumComponents, + Start, + Goal, + NumPlayerFitCells, + OutReport.WalkOnlyReachablePlayerFitCells, + OutReport.ClimbReachablePlayerFitCells, + OutReport.MineRequiredPlayerFitCells, + OutReport.bClimbCanReachDeparture, + OutReport.ClimbEdges); + OutReport.ReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 + ? static_cast(static_cast(OutReport.ReachablePlayerFitCells) + / static_cast(OutReport.PlayerFitVolumeCells)) + : 0.0f; + OutReport.ClimbReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 + ? static_cast(static_cast(OutReport.ClimbReachablePlayerFitCells) + / static_cast(OutReport.PlayerFitVolumeCells)) + : 0.0f; + OutReport.PlayerFitComponents = NumComponents; + OutReport.LargestPlayerFitComponentCells = Diagnostics.LargestComponentCells; + OutReport.ArrivalComponentCells = Diagnostics.StartComponentCells; + OutReport.DepartureComponentCells = Diagnostics.GoalComponentCells; + OutReport.MouthComponentGapVoxels = Diagnostics.StartToGoalComponentDistanceCells; + OutReport.StraightLineMeters = FVector::Dist(AVoxel, BVoxel) + * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + + int32 AgentFinalCell = INDEX_NONE; + int32 LastReachedCell = INDEX_NONE; + if (Start >= 0 && Goal >= 0) + { + const float NarrowGapThresholdVoxels = OutReport.NarrowGapThresholdMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + WalkPlayerFitGraph( + Grid, + PlayerFitMask, + &PlayerFitSupportHeights, + &Settings, + EPlayerTraversalPolicy::WalkOnly, + Start, + Goal, + NarrowGapThresholdVoxels, + OutReport, + AgentFinalCell, + LastReachedCell); + } + + auto RecordCellPosition = [&Grid]( + int32 Cell, + bool& bOutHasPosition, + FVector& OutPosition) + { + if (Cell >= 0 && Cell < Grid.CellCount) + { + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Cell, X, Y, Z); + bOutHasPosition = true; + OutPosition = Grid.CellCenter(X, Y, Z); + } + }; + RecordCellPosition( + AgentFinalCell, + OutReport.bHasAgentFinalPosition, + OutReport.AgentFinalVoxels); + RecordCellPosition( + LastReachedCell, + OutReport.bHasLastReachedPosition, + OutReport.LastReachedVoxels); + + const int32 StartComponent = Start >= 0 && Components.IsValidIndex(Start) + ? Components[Start] : INDEX_NONE; + const int32 GoalComponent = Goal >= 0 && Components.IsValidIndex(Goal) + ? Components[Goal] : INDEX_NONE; + + if (ComponentProbePoints != nullptr) + { + OutReport.ComponentProbePlayerFit.Init(0u, ComponentProbePoints->Num()); + OutReport.ComponentProbeInStartComponent.Init(0u, ComponentProbePoints->Num()); + for (int32 ProbeIndex = 0; ProbeIndex < ComponentProbePoints->Num(); ++ProbeIndex) + { + int32 ProbeCell = INDEX_NONE; + const FVector& ProbePoint = (*ComponentProbePoints)[ProbeIndex]; + if (!FindCellForPoint(Grid, ProbePoint, ProbeCell) + || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, ProbeCell)) + { + continue; + } + OutReport.ComponentProbePlayerFit[ProbeIndex] = 1u; + if (StartComponent >= 0 && Components.IsValidIndex(ProbeCell) + && Components[ProbeCell] == StartComponent) + { + OutReport.ComponentProbeInStartComponent[ProbeIndex] = 1u; + } + } + } + if (StartComponent >= 0) + { + float BestOriginDistanceSquared = FLT_MAX; + for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) + { + if (!Components.IsValidIndex(Cell) || Components[Cell] != StartComponent) + { + continue; + } + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Cell, X, Y, Z); + const FVector Position = Grid.CellCenter(X, Y, Z); + BestOriginDistanceSquared = FMath::Min( + BestOriginDistanceSquared, + Position.X * Position.X + Position.Y * Position.Y); + } + if (BestOriginDistanceSquared < FLT_MAX) + { + OutReport.ReachableSetToOriginColumnVoxels = FMath::Sqrt( + BestOriginDistanceSquared); + } + } + + const bool bOriginPointInWindow = 0.0f >= Grid.MinX && 0.0f < Grid.MaxX + && 0.0f >= Grid.MinY && 0.0f < Grid.MaxY; + if (bOriginPointInWindow) + { + const int32 OriginX = FMath::Clamp( + FMath::FloorToInt((0.0f - Grid.MinX) / static_cast(Grid.SampleStep)), + 0, + Grid.NumX - 1); + const int32 OriginY = FMath::Clamp( + FMath::FloorToInt((0.0f - Grid.MinY) / static_cast(Grid.SampleStep)), + 0, + Grid.NumY - 1); + OutReport.bOriginColumnInSampledWindow = true; + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + const int32 Cell = Grid.Index(OriginX, OriginY, Z); + if (PlayerFitMask[Cell] == 0u) + { + continue; + } + ++OutReport.OriginColumnPlayerFitCells; + OutReport.bOriginColumnHasPlayerFit = true; + if (Components.IsValidIndex(Cell) && Components[Cell] == StartComponent) + { + OutReport.bOriginColumnReachable = true; + } + } + } + + if (AgentFinalCell >= 0 && AgentFinalCell < Grid.CellCount && GoalComponent >= 0) + { + const FVector AgentPosition = OutReport.AgentFinalVoxels; + float BestTargetDistanceSquared = FLT_MAX; + int32 BestTargetCell = INDEX_NONE; + for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) + { + if (!Components.IsValidIndex(Cell) || Components[Cell] != GoalComponent) + { + continue; + } + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Cell, X, Y, Z); + const float DistanceSquared = FVector::DistSquared( + AgentPosition, Grid.CellCenter(X, Y, Z)); + if (DistanceSquared < BestTargetDistanceSquared + || (DistanceSquared == BestTargetDistanceSquared + && (BestTargetCell == INDEX_NONE || Cell < BestTargetCell))) + { + BestTargetDistanceSquared = DistanceSquared; + BestTargetCell = Cell; + } + } + if (BestTargetCell >= 0) + { + RecordCellPosition( + BestTargetCell, + OutReport.bHasTargetComponentNearestCell, + OutReport.TargetComponentNearestVoxels); + OutReport.AgentToTargetComponentGapVoxels = FMath::Sqrt( + BestTargetDistanceSquared); + } + } + + OutReport.bCanReachDeparture = Result == EVoxelConnectivityResult::Connected; + OutReport.Tortuosity = OutReport.StraightLineMeters > KINDA_SMALL_NUMBER + ? OutReport.DistanceTravelledMeters / OutReport.StraightLineMeters + : 0.0f; + OutReport.DeadEndsEncountered = FMath::Max( + OutReport.DeadEndsEncountered, 0); + OutReport.NarrowGapEventsPer100m = OutReport.DistanceTravelledMeters + > KINDA_SMALL_NUMBER + ? static_cast(static_cast(OutReport.NarrowGapTraversals) + * 100.0 / static_cast(OutReport.DistanceTravelledMeters)) + : 0.0f; + OutReport.DeadEndsPer100m = OutReport.DistanceTravelledMeters + > KINDA_SMALL_NUMBER + ? static_cast(static_cast(OutReport.DeadEndsEncountered) + * 100.0 / static_cast(OutReport.DistanceTravelledMeters)) + : 0.0f; + + if (Result == EVoxelConnectivityResult::StartCellNotPlayerFit) + { + OutReport.RefusalReason = TEXT("arrival endpoint is not a player-fitting floor cell"); + } + else if (Result == EVoxelConnectivityResult::GoalCellNotPlayerFit) + { + OutReport.RefusalReason = TEXT("departure endpoint is not a player-fitting floor cell"); + } + else if (Result == EVoxelConnectivityResult::OutOfWindow) + { + OutReport.RefusalReason = TEXT("arrival or departure point is outside the fitted walk window"); + } + else + { + OutReport.RefusalReason = Diagnostics.RefusalReason; + } + + if (OutSampleGrid != nullptr) + { + ExportSampleGrid(Grid, *OutSampleGrid); + OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); + OutSampleGrid->PlayerFitCellCount = NumPlayerFitCells; + } + return true; +} + +#endif // WITH_EDITOR — measurement never ships diff --git a/Standalone/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp b/Standalone/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp new file mode 100644 index 0000000..7537d98 --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp @@ -0,0 +1,87 @@ +// VoxelTerrainOpDefinition.cpp +// Applies a terrain operation's parameters to the generation params struct. + +#include "VoxelTerrainOpDefinition.h" + +void UVoxelTerrainOpDefinition::ApplyTo(FStrateGenerationParams& OutParams, float Weight) const +{ + // Weight scales the "primary" field of each op type — the one that controls + // whether the op is active and how strong it is. Other fields (radii, spacing, + // ratios) are copied directly since they define shape, not intensity. + + switch (Type) + { + case EVoxelTerrainOpType::Terrace: + // TerraceStepHeight is the activation field (0 = disabled) + OutParams.TerraceStepHeight = TerraceStepHeight * Weight; + OutParams.TerraceHardness = TerraceHardness; + OutParams.TerraceNoiseDisplacement = TerraceNoiseDisplacement; + break; + + case EVoxelTerrainOpType::LayerLines: + // LayerLineSpacing is the activation field (0 = disabled) + OutParams.LayerLineSpacing = LayerLineSpacing * Weight; + OutParams.LayerLineDepth = LayerLineDepth; + break; + + case EVoxelTerrainOpType::Ribbing: + // RibbingSpacing is the activation field (0 = disabled) + OutParams.RibbingSpacing = RibbingSpacing * Weight; + OutParams.RibbingDepth = RibbingDepth; + break; + + case EVoxelTerrainOpType::Cliff: + OutParams.CliffStrength = CliffStrength * Weight; + break; + + case EVoxelTerrainOpType::Scallop: + OutParams.ScallopStrength = ScallopStrength * Weight; + OutParams.ScallopFrequency = ScallopFrequency; + break; + + case EVoxelTerrainOpType::Overhang: + OutParams.OverhangStrength = OverhangStrength * Weight; + OutParams.OverhangDepth = OverhangDepth; + OutParams.OverhangFrequency = OverhangFrequency; + break; + + case EVoxelTerrainOpType::Arch: + OutParams.ArchDensity = ArchDensity * Weight; + OutParams.ArchMinRadius = ArchMinRadius; + OutParams.ArchMaxRadius = ArchMaxRadius; + break; + + case EVoxelTerrainOpType::Column: + OutParams.ColumnDensity = ColumnDensity * Weight; + OutParams.ColumnMinRadius = ColumnMinRadius; + OutParams.ColumnMaxRadius = ColumnMaxRadius; + break; + + case EVoxelTerrainOpType::Pit: + OutParams.PitDensity = PitDensity * Weight; + OutParams.PitMinRadius = PitMinRadius; + OutParams.PitMaxRadius = PitMaxRadius; + OutParams.PitDepth = PitDepth; + break; + + case EVoxelTerrainOpType::Chimney: + OutParams.ChimneyDensity = ChimneyDensity * Weight; + OutParams.ChimneyMinRadius = ChimneyMinRadius; + OutParams.ChimneyMaxRadius = ChimneyMaxRadius; + OutParams.ChimneyHeight = ChimneyHeight; + break; + + case EVoxelTerrainOpType::Dome: + OutParams.DomeDensity = DomeDensity * Weight; + OutParams.DomeMinRadius = DomeMinRadius; + OutParams.DomeMaxRadius = DomeMaxRadius; + OutParams.DomeHeightRatio = DomeHeightRatio; + break; + + case EVoxelTerrainOpType::Pinch: + OutParams.PinchDensity = PinchDensity * Weight; + OutParams.PinchStrength = PinchStrength; + OutParams.PinchLength = PinchLength; + break; + } +} diff --git a/Standalone/Source/VoxelForge/Private/VoxelWormField.cpp b/Standalone/Source/VoxelForge/Private/VoxelWormField.cpp new file mode 100644 index 0000000..229363e --- /dev/null +++ b/Standalone/Source/VoxelForge/Private/VoxelWormField.cpp @@ -0,0 +1,88 @@ +// VoxelWormField.cpp +// Shared deterministic exact worm-field evaluation. + +#include "VoxelWormField.h" + +#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset +#include "VoxelNoise.h" +#include "VoxelTypes.h" // VOXEL_NOISE_SCALE + +namespace +{ + FORCEINLINE FVector3f MakeWormNoiseBase( + float WorldX, + float WorldY, + float WorldZ, + const VoxelWormField::FParameters& Parameters) + { + const float EffectiveZ = (Parameters.VerticalScale != 1.0f + && Parameters.VerticalScale > 0.0f) + ? WorldZ / Parameters.VerticalScale : WorldZ; + const float WormZFrequency = Parameters.Frequency * Parameters.HorizontalBias; + return FVector3f( + WorldX * Parameters.Frequency + + VoxelHash::SeedOffset(Parameters.Seed, 1.0f), + WorldY * Parameters.Frequency + + VoxelHash::SeedOffset(Parameters.Seed, 1.7f), + EffectiveZ * WormZFrequency + + VoxelHash::SeedOffset(Parameters.Seed, 2.3f)); + } + + FORCEINLINE float EvaluateN1Field( + float WorldX, + float WorldY, + float WorldZ, + const VoxelWormField::FParameters& Parameters, + float NoiseScale) + { + const FVector3f NoiseBase = MakeWormNoiseBase( + WorldX, WorldY, WorldZ, Parameters); + return FMath::Abs(VoxelNoise::Perlin3D(NoiseBase) * NoiseScale); + } + + FORCEINLINE float EvaluateN2Field( + float WorldX, + float WorldY, + float WorldZ, + const VoxelWormField::FParameters& Parameters, + float NoiseScale) + { + const FVector3f NoiseBase = MakeWormNoiseBase( + WorldX, WorldY, WorldZ, Parameters); + const FVector3f N2Position = NoiseBase + FVector3f(137.0f, 259.0f, 431.0f); + return FMath::Abs(VoxelNoise::Perlin3D(N2Position) * NoiseScale); + } + + FORCEINLINE float EvaluateExactField( + float WorldX, + float WorldY, + float WorldZ, + float Threshold, + const VoxelWormField::FParameters& Parameters) + { + const float NoiseScale = VOXEL_NOISE_SCALE; + const float N1 = EvaluateN1Field( + WorldX, WorldY, WorldZ, Parameters, NoiseScale); + + // N2 is non-negative, so this preserves the exact evaluator's short-circuit. + if (N1 >= Threshold) + { + return N1; + } + return N1 + EvaluateN2Field( + WorldX, WorldY, WorldZ, Parameters, NoiseScale); + } +} + +namespace VoxelWormField +{ + float Evaluate( + float WorldX, + float WorldY, + float WorldZ, + float Threshold, + const FParameters& Parameters) + { + return EvaluateExactField(WorldX, WorldY, WorldZ, Threshold, Parameters); + } +} diff --git a/Standalone/Source/VoxelForge/Public/MarchingCubesTables.h b/Standalone/Source/VoxelForge/Public/MarchingCubesTables.h new file mode 100644 index 0000000..2dc2cc6 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/MarchingCubesTables.h @@ -0,0 +1,344 @@ +// MarchingCubesTables.h +// Standard lookup tables for the Marching Cubes algorithm +// +// These tables are reference data — everyone uses the same ones. +// Originally from Paul Bourke's Marching Cubes implementation. +// +// CUBE CORNER LAYOUT: +// ------------------- +// 4-------5 +// /| /| +// / | / | +// 7-------6 | +// | 0----|--1 +// | / | / +// |/ |/ +// 3-------2 +// +// Corner indices and their positions relative to (X, Y, Z): +// 0: (0, 0, 0) 4: (0, 0, 1) +// 1: (1, 0, 0) 5: (1, 0, 1) +// 2: (1, 1, 0) 6: (1, 1, 1) +// 3: (0, 1, 0) 7: (0, 1, 1) +// +// EDGE INDICES: +// ------------- +// Edge 0: corner 0 - corner 1 (bottom front) +// Edge 1: corner 1 - corner 2 (bottom right) +// Edge 2: corner 2 - corner 3 (bottom back) +// Edge 3: corner 3 - corner 0 (bottom left) +// Edge 4: corner 4 - corner 5 (top front) +// Edge 5: corner 5 - corner 6 (top right) +// Edge 6: corner 6 - corner 7 (top back) +// Edge 7: corner 7 - corner 4 (top left) +// Edge 8: corner 0 - corner 4 (vertical left-front) +// Edge 9: corner 1 - corner 5 (vertical right-front) +// Edge 10: corner 2 - corner 6 (vertical right-back) +// Edge 11: corner 3 - corner 7 (vertical left-back) + +#pragma once + +#include "CoreMinimal.h" + +// EdgeTable[caseIndex] = 12-bit bitmask +// Each bit tells you if that edge is intersected by the surface. +// Example: EdgeTable[1] = 0x109 = 0000 0001 0000 1001 +// → edges 0, 3, and 8 are intersected +static const int32 MCEdgeTable[256] = { + 0x000, 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, + 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00, + 0x190, 0x099, 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, + 0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, + 0x230, 0x339, 0x033, 0x13a, 0x636, 0x73f, 0x435, 0x53c, + 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, + 0x3a0, 0x2a9, 0x1a3, 0x0aa, 0x7a6, 0x6af, 0x5a5, 0x4ac, + 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, + 0x460, 0x569, 0x663, 0x76a, 0x066, 0x16f, 0x265, 0x36c, + 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60, + 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0x0ff, 0x3f5, 0x2fc, + 0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, + 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x055, 0x15c, + 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, + 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0x0cc, + 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, + 0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, + 0x0cc, 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0, + 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, + 0x15c, 0x055, 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650, + 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, + 0x2fc, 0x3f5, 0x0ff, 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, + 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c, + 0x36c, 0x265, 0x16f, 0x066, 0x76a, 0x663, 0x569, 0x460, + 0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, + 0x4ac, 0x5a5, 0x6af, 0x7a6, 0x0aa, 0x1a3, 0x2a9, 0x3a0, + 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c, + 0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x033, 0x339, 0x230, + 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, + 0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x099, 0x190, + 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c, + 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x000 +}; + +// TriTable[caseIndex][up to 16] +// Lists edge indices that form triangles, in groups of 3. +// -1 terminates the list. +// Example: TriTable[1] = {0, 8, 3, -1, ...} +// → one triangle using edges 0, 8, and 3 +static const int32 MCTriTable[256][16] = { + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 8, 3, 9, 8, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 2, 10, 0, 2, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 8, 3, 2, 10, 8, 10, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + { 3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 11, 2, 8, 11, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 9, 0, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 11, 2, 1, 9, 11, 9, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + { 3, 10, 1, 11, 10, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 10, 1, 0, 8, 10, 8, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + { 3, 9, 0, 3, 11, 9, 11, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + { 9, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 3, 0, 7, 3, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 1, 9, 4, 7, 1, 7, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 4, 7, 3, 0, 4, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1}, + { 9, 2, 10, 9, 0, 2, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + { 2, 10, 9, 2, 9, 7, 2, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + { 8, 4, 7, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 4, 7, 11, 2, 4, 2, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + { 9, 0, 1, 8, 4, 7, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + { 4, 7, 11, 9, 4, 11, 9, 11, 2, 9, 2, 1, -1, -1, -1, -1}, + { 3, 10, 1, 3, 11, 10, 7, 8, 4, -1, -1, -1, -1, -1, -1, -1}, + { 1, 11, 10, 1, 4, 11, 1, 0, 4, 7, 11, 4, -1, -1, -1, -1}, + { 4, 7, 8, 9, 0, 11, 9, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + { 4, 7, 11, 4, 11, 9, 9, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 4, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 5, 4, 1, 5, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 5, 4, 8, 3, 5, 3, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 8, 1, 2, 10, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + { 5, 2, 10, 5, 4, 2, 4, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + { 2, 10, 5, 3, 2, 5, 3, 5, 4, 3, 4, 8, -1, -1, -1, -1}, + { 9, 5, 4, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 11, 2, 0, 8, 11, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + { 0, 5, 4, 0, 1, 5, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + { 2, 1, 5, 2, 5, 8, 2, 8, 11, 4, 8, 5, -1, -1, -1, -1}, + {10, 3, 11, 10, 1, 3, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 5, 0, 8, 1, 8, 10, 1, 8, 11, 10, -1, -1, -1, -1}, + { 5, 4, 0, 5, 0, 11, 5, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + { 5, 4, 8, 5, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + { 9, 7, 8, 5, 7, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 3, 0, 9, 5, 3, 5, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + { 0, 7, 8, 0, 1, 7, 1, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + { 1, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 7, 8, 9, 5, 7, 10, 1, 2, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 9, 5, 0, 5, 3, 0, 5, 7, 3, -1, -1, -1, -1}, + { 8, 0, 2, 8, 2, 5, 8, 5, 7, 10, 5, 2, -1, -1, -1, -1}, + { 2, 10, 5, 2, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + { 7, 9, 5, 7, 8, 9, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 7, 9, 7, 2, 9, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + { 2, 3, 11, 0, 1, 8, 1, 7, 8, 1, 5, 7, -1, -1, -1, -1}, + {11, 2, 1, 11, 1, 7, 7, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 8, 8, 5, 7, 10, 1, 3, 10, 3, 11, -1, -1, -1, -1}, + { 5, 7, 0, 5, 0, 9, 7, 11, 0, 1, 0, 10, 11, 10, 0, -1}, + {11, 10, 0, 11, 0, 3, 10, 5, 0, 8, 0, 7, 5, 7, 0, -1}, + {11, 10, 5, 7, 11, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 0, 1, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 8, 3, 1, 9, 8, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + { 1, 6, 5, 2, 6, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 6, 5, 1, 2, 6, 3, 0, 8, -1, -1, -1, -1, -1, -1, -1}, + { 9, 6, 5, 9, 0, 6, 0, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + { 5, 9, 8, 5, 8, 2, 5, 2, 6, 3, 2, 8, -1, -1, -1, -1}, + { 2, 3, 11, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 0, 8, 11, 2, 0, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + { 5, 10, 6, 1, 9, 2, 9, 11, 2, 9, 8, 11, -1, -1, -1, -1}, + { 6, 3, 11, 6, 5, 3, 5, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 11, 0, 11, 5, 0, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + { 3, 11, 6, 0, 3, 6, 0, 6, 5, 0, 5, 9, -1, -1, -1, -1}, + { 6, 5, 9, 6, 9, 11, 11, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + { 5, 10, 6, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 3, 0, 4, 7, 3, 6, 5, 10, -1, -1, -1, -1, -1, -1, -1}, + { 1, 9, 0, 5, 10, 6, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, 1, 9, 7, 1, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + { 6, 1, 2, 6, 5, 1, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 5, 5, 2, 6, 3, 0, 4, 3, 4, 7, -1, -1, -1, -1}, + { 8, 4, 7, 9, 0, 5, 0, 6, 5, 0, 2, 6, -1, -1, -1, -1}, + { 7, 3, 9, 7, 9, 4, 3, 2, 9, 5, 9, 6, 2, 6, 9, -1}, + { 3, 11, 2, 7, 8, 4, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + { 5, 10, 6, 4, 7, 2, 4, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + { 0, 1, 9, 4, 7, 8, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1}, + { 9, 2, 1, 9, 11, 2, 9, 4, 11, 7, 11, 4, 5, 10, 6, -1}, + { 8, 4, 7, 3, 11, 5, 3, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + { 5, 1, 11, 5, 11, 6, 1, 0, 11, 7, 11, 4, 0, 4, 11, -1}, + { 0, 5, 9, 0, 6, 5, 0, 3, 6, 11, 6, 3, 8, 4, 7, -1}, + { 6, 5, 9, 6, 9, 11, 4, 7, 9, 7, 11, 9, -1, -1, -1, -1}, + {10, 4, 9, 6, 4, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 10, 6, 4, 9, 10, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1}, + {10, 0, 1, 10, 6, 0, 6, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + { 8, 3, 1, 8, 1, 6, 8, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + { 1, 4, 9, 1, 2, 4, 2, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 8, 1, 2, 9, 2, 4, 9, 2, 6, 4, -1, -1, -1, -1}, + { 0, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 3, 2, 8, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 4, 9, 10, 6, 4, 11, 2, 3, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 2, 2, 8, 11, 4, 9, 10, 4, 10, 6, -1, -1, -1, -1}, + { 3, 11, 2, 0, 1, 6, 0, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + { 6, 4, 1, 6, 1, 10, 4, 8, 1, 2, 1, 11, 8, 11, 1, -1}, + { 9, 6, 4, 9, 3, 6, 9, 1, 3, 11, 6, 3, -1, -1, -1, -1}, + { 8, 11, 1, 8, 1, 0, 11, 6, 1, 9, 1, 4, 6, 4, 1, -1}, + { 3, 11, 6, 3, 6, 0, 0, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + { 6, 4, 8, 11, 6, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 10, 6, 7, 8, 10, 8, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + { 0, 7, 3, 0, 10, 7, 0, 9, 10, 6, 7, 10, -1, -1, -1, -1}, + {10, 6, 7, 1, 10, 7, 1, 7, 8, 1, 8, 0, -1, -1, -1, -1}, + {10, 6, 7, 10, 7, 1, 1, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 6, 1, 6, 8, 1, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + { 2, 6, 9, 2, 9, 1, 6, 7, 9, 0, 9, 3, 7, 3, 9, -1}, + { 7, 8, 0, 7, 0, 6, 6, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + { 7, 3, 2, 6, 7, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 3, 11, 10, 6, 8, 10, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + { 2, 0, 7, 2, 7, 11, 0, 9, 7, 6, 7, 10, 9, 10, 7, -1}, + { 1, 8, 0, 1, 7, 8, 1, 10, 7, 6, 7, 10, 2, 3, 11, -1}, + {11, 2, 1, 11, 1, 7, 10, 6, 1, 6, 7, 1, -1, -1, -1, -1}, + { 8, 9, 6, 8, 6, 7, 9, 1, 6, 11, 6, 3, 1, 3, 6, -1}, + { 0, 9, 1, 11, 6, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 8, 0, 7, 0, 6, 3, 11, 0, 11, 6, 0, -1, -1, -1, -1}, + { 7, 11, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 8, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 1, 9, 8, 3, 1, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 3, 0, 8, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + { 2, 9, 0, 2, 10, 9, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + { 6, 11, 7, 2, 10, 3, 10, 8, 3, 10, 9, 8, -1, -1, -1, -1}, + { 7, 2, 3, 6, 2, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 0, 8, 7, 6, 0, 6, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + { 2, 7, 6, 2, 3, 7, 0, 1, 9, -1, -1, -1, -1, -1, -1, -1}, + { 1, 6, 2, 1, 8, 6, 1, 9, 8, 8, 7, 6, -1, -1, -1, -1}, + {10, 7, 6, 10, 1, 7, 1, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 6, 1, 7, 10, 1, 8, 7, 1, 0, 8, -1, -1, -1, -1}, + { 0, 3, 7, 0, 7, 10, 0, 10, 9, 6, 10, 7, -1, -1, -1, -1}, + { 7, 6, 10, 7, 10, 8, 8, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + { 6, 8, 4, 11, 8, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 6, 11, 3, 0, 6, 0, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + { 8, 6, 11, 8, 4, 6, 9, 0, 1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 4, 6, 9, 6, 3, 9, 3, 1, 11, 3, 6, -1, -1, -1, -1}, + { 6, 8, 4, 6, 11, 8, 2, 10, 1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 3, 0, 11, 0, 6, 11, 0, 4, 6, -1, -1, -1, -1}, + { 4, 11, 8, 4, 6, 11, 0, 2, 9, 2, 10, 9, -1, -1, -1, -1}, + {10, 9, 3, 10, 3, 2, 9, 4, 3, 11, 3, 6, 4, 6, 3, -1}, + { 8, 2, 3, 8, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + { 0, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 9, 0, 2, 3, 4, 2, 4, 6, 4, 3, 8, -1, -1, -1, -1}, + { 1, 9, 4, 1, 4, 2, 2, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + { 8, 1, 3, 8, 6, 1, 8, 4, 6, 6, 10, 1, -1, -1, -1, -1}, + {10, 1, 0, 10, 0, 6, 6, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + { 4, 6, 3, 4, 3, 8, 6, 10, 3, 0, 3, 9, 10, 9, 3, -1}, + {10, 9, 4, 6, 10, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 5, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 4, 9, 5, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + { 5, 0, 1, 5, 4, 0, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 6, 8, 3, 4, 3, 5, 4, 3, 1, 5, -1, -1, -1, -1}, + { 9, 5, 4, 10, 1, 2, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + { 6, 11, 7, 1, 2, 10, 0, 8, 3, 4, 9, 5, -1, -1, -1, -1}, + { 7, 6, 11, 5, 4, 10, 4, 2, 10, 4, 0, 2, -1, -1, -1, -1}, + { 3, 4, 8, 3, 5, 4, 3, 2, 5, 10, 5, 2, 11, 7, 6, -1}, + { 7, 2, 3, 7, 6, 2, 5, 4, 9, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 4, 0, 8, 6, 0, 6, 2, 6, 8, 7, -1, -1, -1, -1}, + { 3, 6, 2, 3, 7, 6, 1, 5, 0, 5, 4, 0, -1, -1, -1, -1}, + { 6, 2, 8, 6, 8, 7, 2, 1, 8, 4, 8, 5, 1, 5, 8, -1}, + { 9, 5, 4, 10, 1, 6, 1, 7, 6, 1, 3, 7, -1, -1, -1, -1}, + { 1, 6, 10, 1, 7, 6, 1, 0, 7, 8, 7, 0, 9, 5, 4, -1}, + { 4, 0, 10, 4, 10, 5, 0, 3, 10, 6, 10, 7, 3, 7, 10, -1}, + { 7, 6, 10, 7, 10, 8, 5, 4, 10, 4, 8, 10, -1, -1, -1, -1}, + { 6, 9, 5, 6, 11, 9, 11, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + { 3, 6, 11, 0, 6, 3, 0, 5, 6, 0, 9, 5, -1, -1, -1, -1}, + { 0, 11, 8, 0, 5, 11, 0, 1, 5, 5, 6, 11, -1, -1, -1, -1}, + { 6, 11, 3, 6, 3, 5, 5, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 9, 5, 11, 9, 11, 8, 11, 5, 6, -1, -1, -1, -1}, + { 0, 11, 3, 0, 6, 11, 0, 9, 6, 5, 6, 9, 1, 2, 10, -1}, + {11, 8, 5, 11, 5, 6, 8, 0, 5, 10, 5, 2, 0, 2, 5, -1}, + { 6, 11, 3, 6, 3, 5, 2, 10, 3, 10, 5, 3, -1, -1, -1, -1}, + { 5, 8, 9, 5, 2, 8, 5, 6, 2, 3, 8, 2, -1, -1, -1, -1}, + { 9, 5, 6, 9, 6, 0, 0, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + { 1, 5, 8, 1, 8, 0, 5, 6, 8, 3, 8, 2, 6, 2, 8, -1}, + { 1, 5, 6, 2, 1, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 3, 6, 1, 6, 10, 3, 8, 6, 5, 6, 9, 8, 9, 6, -1}, + {10, 1, 0, 10, 0, 6, 9, 5, 0, 5, 6, 0, -1, -1, -1, -1}, + { 0, 3, 8, 5, 6, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 5, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 7, 5, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 11, 7, 5, 8, 3, 0, -1, -1, -1, -1, -1, -1, -1}, + { 5, 11, 7, 5, 10, 11, 1, 9, 0, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 5, 10, 11, 7, 9, 8, 1, 8, 3, 1, -1, -1, -1, -1}, + {11, 1, 2, 11, 7, 1, 7, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 1, 2, 7, 1, 7, 5, 7, 2, 11, -1, -1, -1, -1}, + { 9, 7, 5, 9, 2, 7, 9, 0, 2, 2, 11, 7, -1, -1, -1, -1}, + { 7, 5, 2, 7, 2, 11, 5, 9, 2, 3, 2, 8, 9, 8, 2, -1}, + { 2, 5, 10, 2, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + { 8, 2, 0, 8, 5, 2, 8, 7, 5, 10, 2, 5, -1, -1, -1, -1}, + { 9, 0, 1, 5, 10, 3, 5, 3, 7, 3, 10, 2, -1, -1, -1, -1}, + { 9, 8, 2, 9, 2, 1, 8, 7, 2, 10, 2, 5, 7, 5, 2, -1}, + { 1, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 7, 0, 7, 1, 1, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + { 9, 0, 3, 9, 3, 5, 5, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + { 9, 8, 7, 5, 9, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 5, 8, 4, 5, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + { 5, 0, 4, 5, 11, 0, 5, 10, 11, 11, 3, 0, -1, -1, -1, -1}, + { 0, 1, 9, 8, 4, 10, 8, 10, 11, 10, 4, 5, -1, -1, -1, -1}, + {10, 11, 4, 10, 4, 5, 11, 3, 4, 9, 4, 1, 3, 1, 4, -1}, + { 2, 5, 1, 2, 8, 5, 2, 11, 8, 4, 5, 8, -1, -1, -1, -1}, + { 0, 4, 11, 0, 11, 3, 4, 5, 11, 2, 11, 1, 5, 1, 11, -1}, + { 0, 2, 5, 0, 5, 9, 2, 11, 5, 4, 5, 8, 11, 8, 5, -1}, + { 9, 4, 5, 2, 11, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 5, 10, 3, 5, 2, 3, 4, 5, 3, 8, 4, -1, -1, -1, -1}, + { 5, 10, 2, 5, 2, 4, 4, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + { 3, 10, 2, 3, 5, 10, 3, 8, 5, 4, 5, 8, 0, 1, 9, -1}, + { 5, 10, 2, 5, 2, 4, 1, 9, 2, 9, 4, 2, -1, -1, -1, -1}, + { 8, 4, 5, 8, 5, 3, 3, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 4, 5, 1, 0, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 4, 5, 8, 5, 3, 9, 0, 5, 0, 3, 5, -1, -1, -1, -1}, + { 9, 4, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 11, 7, 4, 9, 11, 9, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 4, 9, 7, 9, 11, 7, 9, 10, 11, -1, -1, -1, -1}, + { 1, 10, 11, 1, 11, 4, 1, 4, 0, 7, 4, 11, -1, -1, -1, -1}, + { 3, 1, 4, 3, 4, 8, 1, 10, 4, 7, 4, 11, 10, 11, 4, -1}, + { 4, 11, 7, 9, 11, 4, 9, 2, 11, 9, 1, 2, -1, -1, -1, -1}, + { 9, 7, 4, 9, 11, 7, 9, 1, 11, 2, 11, 1, 0, 8, 3, -1}, + {11, 7, 4, 11, 4, 2, 2, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 4, 11, 4, 2, 8, 3, 4, 3, 2, 4, -1, -1, -1, -1}, + { 2, 9, 10, 2, 7, 9, 2, 3, 7, 7, 4, 9, -1, -1, -1, -1}, + { 9, 10, 7, 9, 7, 4, 10, 2, 7, 8, 7, 0, 2, 0, 7, -1}, + { 3, 7, 10, 3, 10, 2, 7, 4, 10, 1, 10, 0, 4, 0, 10, -1}, + { 1, 10, 2, 8, 7, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 1, 4, 1, 7, 7, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 1, 4, 1, 7, 0, 8, 1, 8, 7, 1, -1, -1, -1, -1}, + { 4, 0, 3, 7, 4, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 8, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 9, 3, 9, 11, 11, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 10, 0, 10, 8, 8, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + { 3, 1, 10, 11, 3, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 11, 1, 11, 9, 9, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 9, 3, 9, 11, 1, 2, 9, 2, 11, 9, -1, -1, -1, -1}, + { 0, 2, 11, 8, 0, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 2, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 3, 8, 2, 8, 10, 10, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + { 9, 10, 2, 0, 9, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 3, 8, 2, 8, 10, 0, 1, 8, 1, 10, 8, -1, -1, -1, -1}, + { 1, 10, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 3, 8, 9, 1, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 9, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 3, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1} +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelBiomeDefinition.h b/Standalone/Source/VoxelForge/Public/VoxelBiomeDefinition.h new file mode 100644 index 0000000..4b1d451 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelBiomeDefinition.h @@ -0,0 +1,159 @@ +// VoxelBiomeDefinition.h +// Data asset defining one biome: where it lives (climate), how it reshapes the base +// terrain (modulation), and what content/atmosphere it brings. +// +// HOW TO USE: +// ----------- +// 1. Content Browser → Miscellaneous → Data Asset → "VoxelBiomeDefinition". +// 2. Set its climate box (ReliefMin/Max, MoistureMin/Max), its terrain override, and its +// Decorations / atmosphere / water. +// 3. Add it to a UVoxelStrateDefinition's Biomes[] list. +// +// A biome is generator-agnostic: the same asset works inside any archetype strate +// (surface biomes today, cave biomes later). The strate's base archetype params are +// the baseline; this asset modulates on top. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "GameplayTagContainer.h" +#include "VoxelBiomeTypes.h" +#include "VoxelStrateTypes.h" // FStrateDecoration / FStrateAmbientActor +#include "VoxelBiomeDefinition.generated.h" + +// IWYU : utilisé en pointeur seulement ⇒ déclaration avant. Le PCH partagé ne la fournit pas : +// `FPSemantics = Precise` nous en sort. / Pointer-only use, so a forward declaration is enough. +// The shared PCH does not provide it (FPSemantics = Precise opts this module out of it). +class UMaterialInterface; + +/** + * UVoxelBiomeDefinition — one biome's identity, placement, terrain modulation and content. + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelBiomeDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + //========================================================================= + // IDENTITY + //========================================================================= + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Biome") + FText BiomeName; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Biome", meta = (MultiLine = true)) + FText BiomeDescription; + + // Colour used by the 2D biome-preview bake to identify this biome's regions. + // Pick something distinct per biome so the baked map is readable. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome") + FLinearColor DebugColor = FLinearColor(0.5f, 0.5f, 0.5f, 1.0f); + + //========================================================================= + // CLIMATE PLACEMENT + //========================================================================= + // A biome wins the Voronoi cells whose site climate (relief, moisture) falls inside + // — or, failing any match, nearest to — this box. Both axes are [0,1]. + // Mountains / snow → high ReliefMin. Lush forest → high Moisture. + // Desert → low Moisture. Plains → mid relief, any moisture. + // Boxes may overlap; ties are broken deterministically per cell. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ReliefMin = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ReliefMax = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float MoistureMin = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float MoistureMax = 1.0f; + + //========================================================================= + // TERRAIN OVERRIDE — a biome is a "mini-strate-variant" + //========================================================================= + // When enabled, this biome supplies its OWN archetype params for its regions, + // replacing the strate's. Heightfield archetypes (SurfaceWorld) blend the output + // across biome borders, so even frequency/shape differences stay seamless. + // Global/structural fields (strate Z bounds, boundary seal, base density, water + // level) are always forced from the strate — a biome can't break seals/connectivity. + // + // GeneratorType must match the strate this biome is used in; if it doesn't, the + // override is ignored and the strate's params are used. (Currently wired: SurfaceWorld. + // Other archetypes' param overrides arrive as each is hooked up — see CODEMAP §8.14.) + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain") + bool bOverrideTerrain = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain", + meta = (EditCondition = "bOverrideTerrain")) + ECaveGeneratorType GeneratorType = ECaveGeneratorType::SurfaceWorld; + + // Open-sky terrain override (used when bOverrideTerrain && GeneratorType == SurfaceWorld). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain", + meta = (EditCondition = "bOverrideTerrain && GeneratorType == ECaveGeneratorType::SurfaceWorld")) + FSurfaceGenerationParams SurfaceParams; + + //========================================================================= + // CONTENT PROFILE (consumed by the content / atmosphere managers) + //========================================================================= + + // Decorations placed in this biome's regions. When set, these REPLACE the strate's + // decorations for chunks whose dominant biome is this one (falls back to the strate + // list when empty). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Content") + TArray Decorations; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Content") + TArray AmbientActors; + + //========================================================================= + // ATMOSPHERE OVERRIDE (optional — beats the strate's atmosphere for this biome) + //========================================================================= + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere") + bool bOverrideAtmosphere = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere")) + FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere", ClampMin = "0.0", ClampMax = "1.0")) + float FogDensity = 0.3f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere")) + FLinearColor AmbientLightColor = FLinearColor(0.1f, 0.1f, 0.15f, 1.0f); + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere", ClampMin = "0.0")) + float AmbientLightIntensity = 0.5f; + + //========================================================================= + // WATER + //========================================================================= + + // Water material override for this biome (null = use the strate's WaterMaterial). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Water") + UMaterialInterface* WaterMaterial = nullptr; + + //========================================================================= + // MATERIAL — F6 vertex-colour triplanar palette + //========================================================================= + // Which palette layer this biome's terrain uses in the master triplanar material. + // It is baked into the mesh vertex colour (R channel) at mesh time, so ONE material + // re-skins the terrain per biome and cross-fades across biome borders (the neighbour + // biome's index + a blend weight ride in the A/B channels). The terrain material is + // still the strate's OverrideMaterial / Settings->VoxelMaterial — author it as the + // master palette material and switch its layers on this index. 0 = default layer. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Material", meta = (ClampMin = "0", ClampMax = "255")) + int32 MaterialPaletteIndex = 0; + + //========================================================================= + // GAMEPLAY TAGS + //========================================================================= + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Gameplay") + FGameplayTagContainer GameplayTags; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelBiomeTypes.h b/Standalone/Source/VoxelForge/Public/VoxelBiomeTypes.h new file mode 100644 index 0000000..275efa8 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelBiomeTypes.h @@ -0,0 +1,264 @@ +// VoxelBiomeTypes.h +// Shared vocabulary for the biome system (Stage 1). +// +// A biome is a "mini-strate-variant": it can carry a FULL archetype param override +// (e.g. its own FSurfaceGenerationParams) plus a content profile, placed across the +// world by a deterministic, window-invariant XY field (warped Voronoi + climate). +// +// Resolution discipline (protects CODEMAP §8.4 + §8.10): +// - The biome assigned at any XY is a PURE function of world coords + seed + the +// strate's biome context. No dependence on the chunk window. +// - Heightfield archetypes (Surface) resolve dominant + neighbour biome per voxel and +// blend the OUTPUT height — so ANY param difference (even frequencies) stays seamless, +// which per-param blending could never do. The expensive climate classification is +// cached once per chunk; per voxel is just a warp + 3x3 lookup. +// - Structural/global fields (Z bounds, seal, base density, water level) are always +// forced from the STRATE so seals / spine / passages / water plane stay intact. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelBiomeTypes.generated.h" + +class UVoxelBiomeDefinition; + +/** + * FBiomeMapParams — controls the world-XY biome field: how big biome regions are, + * how organic their borders look, and the two climate fields that ASSIGN a biome to + * each Voronoi cell. Lives on UVoxelStrateDefinition (one map per strate). + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FBiomeMapParams +{ + GENERATED_BODY() + + // Average biome cell size in voxels. Larger = bigger, sweeping biome regions. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "32.0")) + float CellSize = 800.0f; + + // Domain-warp the cell lookup by up to this many voxels → organic, non-hexagonal + // borders. 0 = raw Voronoi cells (straight-ish borders). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "0.0")) + float WarpStrength = 250.0f; + + // Frequency of the border-warp noise. Lower = broader, sweeping border bends. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map") + float WarpFrequency = 0.0018f; + + // Width (voxels) of the smooth blend band between neighbouring biomes. The density + // scalars cross-fade across this band so there is no hard wall at a biome border. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "0.0")) + float BorderBlend = 120.0f; + + // ----- Climate fields (assign each cell its biome) ----- + // + // IMPORTANT: for COHERENT geography (mountain ranges, desert regions — not confetti) + // the climate must vary much more SLOWLY than CellSize: aim for ~4-6 cells per climate + // feature, i.e. ClimateFrequency ≈ 1 / (5 * CellSize). If the climate wavelength is + // near or below CellSize, neighbouring cells sample unrelated climate → salt-and-pepper. + + // Relief ("elevation") field, [0,1]. Mountain / snow biomes live where this is high. + // Default tuned for CellSize≈800 (wavelength ~3300 ≈ 4 cells). Lower → bigger regions. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate") + float ReliefFrequency = 0.0003f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.25", ClampMax = "4.0")) + float ReliefContrast = 2.0f; + + // Moisture field, [0,1] — the second climate axis (wet lowlands vs arid). Same + // coherence rule as relief: keep the wavelength several cells wide. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate") + float MoistureFrequency = 0.0004f; +}; + +/** + * Channel selector for the 2D biome-preview bake (AVoxelWorld::BakeBiomePreview). + */ +UENUM(BlueprintType) +enum class EBiomePreviewChannel : uint8 +{ + Biome UMETA(DisplayName = "Biome (debug colours)"), + Relief UMETA(DisplayName = "Relief / elevation field"), + Moisture UMETA(DisplayName = "Moisture field") +}; + +/** + * FVoxelBiomeQuery — a Blueprint-readable snapshot of the biome field at one world point. + * Returned by AVoxelWorld::GetBiomeAtWorldLocation so BP can read "which biome is under the + * cursor" — the SAME resolution the decoration scatter uses per column. DecorationCount is the + * effective number of decorations the dominant biome would place (its own list, or the strate's + * when empty): 0 here explains a "band of nothing" — that biome simply has no decorations. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelBiomeQuery +{ + GENERATED_BODY() + + // False when the point's strate has no biome field (then only the climate fields are filled). + UPROPERTY(BlueprintReadOnly, Category = "Biome") bool bHasBiomes = false; + + // Dominant biome asset at this point (what a decoration column here primarily uses). May be null. + UPROPERTY(BlueprintReadOnly, Category = "Biome") UVoxelBiomeDefinition* DominantBiome = nullptr; + + // Nearest neighbouring biome (the one a border blend fades toward). May be null / same as dominant. + UPROPERTY(BlueprintReadOnly, Category = "Biome") UVoxelBiomeDefinition* NeighborBiome = nullptr; + + // Dominant biome display name (falls back to the asset name when BiomeName is unset). + UPROPERTY(BlueprintReadOnly, Category = "Biome") FText DominantName; + + // Dominant biome's preview/debug colour (handy to tint a BP debug draw to match the bake). + UPROPERTY(BlueprintReadOnly, Category = "Biome") FLinearColor DebugColor = FLinearColor::Black; + + // Border blend weight toward the neighbour: 0 deep inside a cell → ~0.5 at the shared border. + UPROPERTY(BlueprintReadOnly, Category = "Biome") float NeighborWeight = 0.0f; + + // Climate fields at this XY, both [0,1] (always filled, even when bHasBiomes is false). + UPROPERTY(BlueprintReadOnly, Category = "Biome") float Relief = 0.0f; + UPROPERTY(BlueprintReadOnly, Category = "Biome") float Moisture = 0.0f; + + // Effective decoration count for the dominant biome (its list, else the strate's). 0 ⇒ empty band. + UPROPERTY(BlueprintReadOnly, Category = "Biome") int32 DominantDecorationCount = 0; + + // Dominant biome's index in the strate's biome context (matches the decoration column tag). -1 = none. + UPROPERTY(BlueprintReadOnly, Category = "Biome") int32 DominantContextIndex = -1; + + //--- DECORATION STREAMING STATE of the region covering this point (debug "why is this chunk blank?") --- + // Discriminates the three failure modes for a visibly-bare patch: + // • bDecoRegionApplied && DecoAppliedInstances > 0 → instances EXIST + are uploaded; if still not + // visible it is a RENDER drop (proxy/culling), not data/streaming. + // • bDecoRegionApplied && DecoAppliedInstances == 0 → the region applied but the per-column MARCH + // produced no spawns here (config/determinism), not a render or streaming bug. + // • bDecoRegionBuilding (not applied) → region is STILL marching; if it never finishes + // (DecoCellsAccounted stuck < DecoCellsTotal) it is a streaming/accounting leak. + // • neither flag set → region was never requested (desired-set / range). + + // True if the region covering this XY is in DecoRegions (applied; HISMs built). + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") bool bDecoRegionApplied = false; + // Total HISM instances actually uploaded across the applied region's components. + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoAppliedInstances = 0; + // True if the region is in RegionBuilds (cells still being marched / merged, not yet applied). + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") bool bDecoRegionBuilding = false; + // Cells accounted vs the region's total (R*R). Building but accounted < total = in progress; if it + // never reaches total the region is stuck (the permanent-blank-until-regen signature). + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoCellsAccounted = 0; + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoCellsTotal = 0; + + // PER-CELL decisive probe: re-runs the decoration march for the EXACT cell (chunk footprint) under + // this point, synchronously, with the current strate context — i.e. how many spawns the deterministic + // scatter produces here RIGHT NOW. Region state above is R*R-cell coarse and can't see a single blank + // cell; this can. On a visibly-BLANK cell: >0 ⇒ the march works, the spawns were lost in merge/apply or + // not drawn (streaming/render bug); 0 ⇒ the march genuinely makes nothing here (data/config, or — if a + // regen brings grass back at the same spot — a non-determinism bug). -1 ⇒ couldn't run (no strate + // context / point not in the player's current strate). + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoLiveMarchSpawns = -1; + + // FINAL discriminator, paired with DecoLiveMarchSpawns. Counts the applied region's HISM instances + // that actually fall inside THIS cell's footprint (not the whole region). On a blank cell where the + // live march says N>0: + // • DecoInstancesInCell ≈ N → the instances ARE uploaded here but not visible → RENDER drop. + // • DecoInstancesInCell ≈ 0 → the march's spawns never reached the HISM → MERGE/apply loss. + UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoInstancesInCell = 0; +}; + +//============================================================================= +// RUNTIME STRUCTS (plain C++ — not USTRUCT; live in the thread-local hot path) +//============================================================================= + +/** + * One biome flattened from its asset for fast, asset-free evaluation. The biome field + * and the per-chunk cache only ever see these PODs — never a UObject — so they stay + * cheap to copy and safe to touch from worker threads. + */ +struct FBiomeResolved +{ + // Index back into the owning strate's Biomes[] array (for param / content lookup). + int32 Index = 0; + + // Climate placement box in (relief, moisture) space, both [0,1]. + float ReliefMin = 0.0f, ReliefMax = 1.0f; + float MoistureMin = 0.0f, MoistureMax = 1.0f; + + // Colour used by the 2D preview bake. + FColor DebugColor = FColor::White; + + // Palette layer this biome's terrain uses in the master triplanar material (F6). + // Baked into the mesh vertex colour so one material can re-skin per biome. 0 = default. + int32 MaterialPaletteIndex = 0; +}; + +/** + * Everything the biome field needs for one strate. Built once by StrateManager + * (GetBiomeContextForChunk) from the strate definition. Empty Biomes ⇒ biomes + * disabled for this strate (legacy behaviour, bit-identical output). + */ +struct FBiomeContext +{ + TArray Biomes; + FBiomeMapParams Map; + + bool IsValid() const { return Biomes.Num() > 0; } +}; + +/** + * Result of a single biome query at a world XY: the dominant biome cell, the nearest + * neighbour cell, and a blend weight toward that neighbour (0 deep inside a cell, + * → 0.5 at the shared border). Indices are positions into FBiomeContext::Biomes. + */ +struct FBiomeSample +{ + int32 DominantIndex = -1; + int32 NeighborIndex = -1; + float NeighborWeight = 0.0f; +}; + +/** + * Per-chunk biome cache for the density hot path. The EXPENSIVE part of a biome query + * (classifying each Voronoi cell by its climate — several noise samples per cell) is + * done ONCE here, into a small grid covering the chunk footprint + margin; per voxel + * the resolver then only warps + does a cheap 3x3 lookup + blend. + * + * VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed + owner identities), NOT a chunk key (CODEMAP §8.10). The + * grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk + * boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild. + * The box logic is identical in spirit to the SDF cache in GetDensityWithParams. + */ +struct FChunkBiomeCache +{ + // World-XY box (voxel coords) over which the cached grid answers correctly. + float ValidMinX = 1.0f, ValidMaxX = -1.0f; // start invalid (min > max) + float ValidMinY = 0.0f, ValidMaxY = 0.0f; + int32 ChunkZ = MIN_int32; // which strate slice this was built for + int32 Seed = MIN_int32; + uint64 OwnerId = 0; // generator/world identity + uint64 ManagerLifetimeId = 0; // strate-manager lifetime identity + bool bActive = false; // does this strate have biomes? + + FBiomeContext Ctx; // resolved biomes + map (for blending) + + // Cell grid (row-major, CellsX × CellsY), each entry = biome index into Ctx.Biomes. + int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0; + TArray CellBiome; + + bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed, + uint64 InOwnerId, uint64 InManagerLifetimeId) const + { + return InSeed == Seed && InChunkZ == ChunkZ + && InOwnerId == OwnerId && InManagerLifetimeId == ManagerLifetimeId + && X >= ValidMinX && X <= ValidMaxX + && Y >= ValidMinY && Y <= ValidMaxY; + } + + /** AUDIT C2 — force a rebuild on the next query. The validity box says nothing about the + * FBiomeContext the cells were classified AGAINST, so when the strate layout is rebuilt + * (RebuildStrates / an editor live edit) the grid is stale even though the box still + * covers the query. Callers that key on GetLayoutVersion() call this on a version change. + * Le box de validité ne dit rien du contexte de biome ayant servi à classer les cellules : + * après un rebuild de layout, la grille est périmée alors que la boîte couvre encore. */ + void Invalidate() + { + ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere + ChunkZ = MIN_int32; + OwnerId = 0; + ManagerLifetimeId = 0; + } +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Standalone/Source/VoxelForge/Public/VoxelCaveMorphology.h new file mode 100644 index 0000000..c5c47b9 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -0,0 +1,1196 @@ +// VoxelCaveMorphology.h +// Hash-based cave room/tunnel generation and SDF evaluation. +// +// HOW IT WORKS: +// ============= +// The world is divided into a coarse 2D grid (cell size = RoomSpacing). +// Each cell deterministically either contains a room or doesn't, based on +// a hash of (CellX, CellY, Seed, StrateIndex). No pre-computation needed — +// room positions are derived on-the-fly from the hash, so this works for +// infinite worlds without storing anything. +// +// ROOMS: +// - Origin room: guaranteed large room at (0,0) per strate — the future shaft landing +// - Hash rooms: ellipsoids, rounded boxes, or elongated capsules +// - All blended with smooth-min for organic junctions +// +// TUNNELS: +// - Connect pairs of rooms based on distance and probability +// - Tapered (variable min/max radius at each endpoint) +// - Curved wandering chains with deterministic perpendicular control-point jitter +// - Horizontal bias (vertical connections penalized) +// - Floor-aligned endpoints and variable radii preserve walkable room joins +// - The origin room is not joined to landings by radial connector roads +// +// PIPELINE POSITION: +// Step 3 (cave warp) bends the SDF query coordinates before we evaluate. +// This step (Step 4) builds the SDF skeleton of rooms + tunnels. +// Surface roughness (Step 4b), terrain ops, and worm tunnels layer on top. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelDensityProfile.h" + +// Forward declarations +struct FStrateGenerationParams; +struct FSlabGenerationParams; +struct FMazeGenerationParams; +struct FVerticalShaftParams; +struct FFloatingIslandParams; +struct FStrateTerrainOpEntry; +class UVoxelTerrainOpDefinition; +enum class ECaveGeneratorType : uint8; + +/** + * The geometry contract for one end of an inter-strate passage. + * + * All values are actor-space voxels. `StandingPoint` is the player-fit anchor: its floor is + * `FloorZ = StandingPoint.Z - 0.5`. The chamber is deliberately separate from the tube endpoint + * so a sloped/vertical tube never presents the player with a hole as its first standing position. + * + * A source query answer selects the landing's XY/Z anchor. The landing is deliberately local to + * that answer: it is a flat-floored room where the inter-strate tube meets a legal player-fit + * pose. The (0,0) landing is the only place reserved for the future straight surface shaft; + * progression mouths do not receive hidden radial roads to that spine room. + */ +struct VOXELFORGE_API FVoxelPassageLanding +{ + FVector StandingPoint = FVector::ZeroVector; + FVector DoorPoint = FVector::ZeroVector; + FVector DoorDirection = FVector(1.0f, 0.0f, 0.0f); + + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + float HalfWidth = 0.0f; + float FloorThickness = 3.0f; + + bool bSourcePlayerFit = false; +}; + +//============================================================================= +// SDF PRIMITIVES +//============================================================================= +// Signed Distance Field functions. +// Return value: negative = inside the shape, positive = outside. +// The "distance" is how far from the surface — 0.0 = exactly on the surface. + +namespace VoxelSDF +{ + // Distance from point P to the surface of a sphere at Center with Radius. + // Negative when P is inside the sphere. + FORCEINLINE float Sphere(const FVector& P, const FVector& Center, float Radius) + { + return FVector::Dist(P, Center) - Radius; + } + + // Distance from P to an ellipsoid (squished sphere). + // Radii = (RadiusX, RadiusY, RadiusZ) — different size per axis. + // We scale the point into a unit sphere, compute distance, then scale back. + // This isn't an exact SDF (distances are approximate near the surface), + // but it's good enough for marching cubes density evaluation. + FORCEINLINE float Ellipsoid(const FVector& P, const FVector& Center, const FVector& Radii) + { + // Transform to unit sphere space + FVector Scaled = (P - Center) / Radii; + float ScaledDist = Scaled.Size(); + // Approximate: scale the distance back by the average radius + float AvgRadius = (Radii.X + Radii.Y + Radii.Z) / 3.0f; + return (ScaledDist - 1.0f) * AvgRadius; + } + + // Distance from P to a capsule (line segment with thickness). + // A and B are the endpoints, Radius is the tube thickness. + // This is an EXACT SDF — used for tunnels. + FORCEINLINE float Capsule(const FVector& P, const FVector& A, const FVector& B, float Radius) + { + FVector AB = B - A; + FVector AP = P - A; + // Project P onto line AB, clamped to [0,1] (stays within segment) + float T = FMath::Clamp(FVector::DotProduct(AP, AB) / FMath::Max(FVector::DotProduct(AB, AB), KINDA_SMALL_NUMBER), 0.0f, 1.0f); + // Closest point on the segment + FVector Closest = A + AB * T; + return FVector::Dist(P, Closest) - Radius; + } + + // Distance from P to a rounded box (box with rounded edges). + // Center = box center, HalfExtent = half-size per axis, Rounding = edge radius. + // This creates angular chambers (rectangular rooms with smooth corners). + // Without rounding (Rounding=0), it's a sharp box. + // With rounding, edges and corners are smoothed — essential for MC quality. + FORCEINLINE float RoundedBox(const FVector& P, const FVector& Center, const FVector& HalfExtent, float Rounding) + { + // Vector from center to P, take absolute value (box symmetry) + FVector D = FVector( + FMath::Abs(P.X - Center.X), + FMath::Abs(P.Y - Center.Y), + FMath::Abs(P.Z - Center.Z) + ) - HalfExtent; + + // Distance outside the box (positive when outside) + float Outside = FVector( + FMath::Max(D.X, 0.0f), + FMath::Max(D.Y, 0.0f), + FMath::Max(D.Z, 0.0f) + ).Size(); + + // Distance inside the box (negative when inside) + float Inside = FMath::Min(FMath::Max(D.X, FMath::Max(D.Y, D.Z)), 0.0f); + + return Outside + Inside - Rounding; + } + + // Distance from P to a tapered capsule (cone-like tube with spherical caps). + // A and B are endpoints, RadiusA and RadiusB are the tube radius at each end. + // When RadiusA != RadiusB, the tube narrows/widens along its length. + // This creates natural-looking corridors that aren't perfectly uniform. + FORCEINLINE float TaperedCapsule(const FVector& P, const FVector& A, const FVector& B, float RadiusA, float RadiusB) + { + FVector AB = B - A; + FVector AP = P - A; + float LenSq = FVector::DotProduct(AB, AB); + // T = how far along AB the closest point is (0 = at A, 1 = at B) + float T = (LenSq > KINDA_SMALL_NUMBER) + ? FMath::Clamp(FVector::DotProduct(AP, AB) / LenSq, 0.0f, 1.0f) + : 0.0f; + FVector Closest = A + AB * T; + // Radius interpolates from RadiusA at A to RadiusB at B + float R = FMath::Lerp(RadiusA, RadiusB, T); + return FVector::Dist(P, Closest) - R; + } + + // Polynomial smooth minimum — blends two SDF shapes together. + // K controls the blend radius: higher K = rounder, smoother junctions. + // With K=0, this is just FMath::Min(A, B) (hard intersection). + // With K=4, room/tunnel junctions look organic and natural. + FORCEINLINE float SmoothMin(float A, float B, float K) + { + if (K <= 0.0f) return FMath::Min(A, B); + float H = FMath::Max(K - FMath::Abs(A - B), 0.0f) / K; + return FMath::Min(A, B) - H * H * H * K * (1.0f / 6.0f); + } + + // Polynomial smooth maximum — dual of SmoothMin, enforces the larger value softly. + // Used for soft SDF intersections (e.g., floor cut planes) so the boundary + // rounds off near tunnel/pit openings instead of creating a hard lip. + // With K=0, this is just FMath::Max(A, B). + FORCEINLINE float SmoothMax(float A, float B, float K) + { + return -SmoothMin(-A, -B, K); + } +} + +/** Build the deterministic, body-sized room geometry at one standing anchor. */ +VOXELFORGE_API FVoxelPassageLanding VF_BuildPassageLanding( + const FVector& StandingPoint, + float MouthRadius, + const FVector& DoorDirection, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + bool bSourcePlayerFit); + +/** Signed distance of the local landing room. Negative means passage air. */ +VOXELFORGE_API float VF_EvaluatePassageLandingSDF( + const FVector& Position, + const FVoxelPassageLanding& Landing); + +/** True when a point belongs to the guaranteed solid floor slab of the landing room. */ +VOXELFORGE_API bool VF_IsPassageLandingFloor( + const FVector& Position, + const FVoxelPassageLanding& Landing); + +//============================================================================= +// HASH FUNCTIONS +//============================================================================= +// Deterministic integer hashing for room placement. +// Given (CellX, CellY, Seed), always returns the same result. +// No randomness — fully reproducible across sessions and clients. + +namespace VoxelHash +{ + // Mix a single uint32 — scrambles bits to reduce patterns + FORCEINLINE uint32 Mix(uint32 X) + { + X ^= X >> 16; + X *= 0x45d9f3bu; + X ^= X >> 16; + X *= 0x45d9f3bu; + X ^= X >> 16; + return X; + } + + /** + * AUDIT §C1 — décalage de bruit BORNÉ et salé par site. Remplace le motif `SeedF * K`. + * + * LE BUG QUE ÇA CORRIGE : les sites de bruit s'écrivaient + * `WorldX * Freq + (float)Seed * 97.7f`. Le float a 24 bits de mantisse, donc à magnitude `V` + * l'ULP vaut `V · 2⁻²³`. Avec `Seed = 10⁷` le terme atteint 10⁹, où l'ULP vaut **117** — la + * coordonnée du voxel (qui avance de ~0.02 par voxel) est **entièrement absorbée** et le champ + * de bruit devient CONSTANT. Terrain plat. `ChangeSeed` est `BlueprintCallable`, donc un + * `FMath::Rand()` suffit à déclencher ça. Ça ne marchait que parce que les seeds restaient petits. + * + * ⚠️ LE CORRECTIF ÉVIDENT EST FAUX. Borner `SeedF` à 16383 en gardant le `· 97.7` laisse le + * terme atteindre 1.6e6, où l'ULP vaut 0.19 — **9.5× le pas par voxel**. Ça rend le bug moins + * spectaculaire tout en le laissant vivant, et referme le ticket. C'est le multiplicateur qu'il + * faut supprimer, pas le seed qu'il faut réduire. + * + * CE QUE FAIT CETTE FONCTION : le multiplicateur ne SERT plus à décorréler par amplification — + * il IDENTIFIE le site, et c'est le hash qui décorrèle. La sortie est déjà dans les unités + * finales, bornée à [0, 16383] : l'ULP y vaut 0.002, soit 10 % d'un pas de voxel. + * + * ET C'EST PLUS SÛR QU'UN SEEDF BORNÉ PARTAGÉ : avec un offset unique par monde, deux seeds qui + * collident donneraient un bruit identique PARTOUT. Salé par site, il faudrait qu'ils + * collident sur les ~50 sites à la fois — c'est-à-dire jamais. + * + * The multiplier does not decorrelate by amplifying — it IDENTIFIES the site, and the hash + * decorrelates. Output is already in final units and bounded, so the ULP is 10% of a voxel step. + * + * @param SiteKey la constante littérale d'origine (`7.3f`, `97.7f`, …). Gardée VISIBLE au site + * d'appel pour que la correspondance avec le code d'avant reste vérifiable à l'œil. + */ + FORCEINLINE float SeedOffset(uint32 Seed, float SiteKey) + { + // ×100 puis arrondi : les constantes ont au plus 2 décimales, donc `0.31f` → 31 et + // `3.1f` → 310 restent distincts. Le site est une identité entière, pas un flottant. + const uint32 Site = (uint32)(SiteKey * 100.0f + 0.5f); + return (float)(Mix(Seed ^ (Site * 2654435761u)) & 0x3FFFu); // [0, 16383] + } + + // Hash a 2D cell coordinate with a seed → deterministic uint32 + FORCEINLINE uint32 Cell(int32 CellX, int32 CellY, uint32 Seed) + { + uint32 H = Seed; + H ^= Mix((uint32)(CellX + 0x7FFFFFFF)); + H ^= Mix((uint32)(CellY + 0x7FFFFFFF) * 2654435761u); + return Mix(H); + } + + // Hash a pair of cells (for tunnel connectivity decisions). + // Order-independent: Cell(A,B) == Cell(B,A) so each tunnel is evaluated once. + FORCEINLINE uint32 Pair(int32 AX, int32 AY, int32 BX, int32 BY, uint32 Seed) + { + // Sort the pair so (A,B) and (B,A) give the same hash + int32 MinX = FMath::Min(AX, BX), MinY = FMath::Min(AY, BY); + int32 MaxX = FMath::Max(AX, BX), MaxY = FMath::Max(AY, BY); + uint32 H = Seed ^ 0xDEADBEEF; + H ^= Mix((uint32)(MinX + 0x7FFFFFFF)); + H ^= Mix((uint32)(MinY + 0x7FFFFFFF) * 2654435761u); + H ^= Mix((uint32)(MaxX + 0x7FFFFFFF) * 374761393u); + H ^= Mix((uint32)(MaxY + 0x7FFFFFFF) * 668265263u); + return Mix(H); + } + + // Convert a hash to a float in [0, 1) — uniform distribution + FORCEINLINE float ToFloat01(uint32 Hash) + { + return (float)(Hash & 0xFFFFFF) / (float)0x1000000; + } + + // Convert a hash to a float in [-1, 1) — for centering + FORCEINLINE float ToFloatSigned(uint32 Hash) + { + return ToFloat01(Hash) * 2.0f - 1.0f; + } +} + +//============================================================================= +// MAZE TOPOLOGY — ORIGIN-DIRECTED SPANNING TREE +//============================================================================= +// A Maze node never waits for a probabilistic edge to connect it to the network. Every node +// except the origin chooses exactly one parent among the coordinate axes that point toward the +// origin. The chosen parent lowers |X|+|Y|+|Z| by one, so following parents terminates at (0,0,0) +// and the undirected parent edges are a spanning tree of the infinite lattice. +// +// The optional loop rolls below are deliberately capped and are never used for the parent edge. +// They add alternate routes and visual irregularity, but deleting every loop still leaves the +// complete tree. This is the locality contract: the parent is a function of the child node and +// seed only; an emitted voxel cell needs the eight child nodes in its one-cell {-1,0} halo. +namespace VoxelMazeTopology +{ + enum class EAxis : uint8 + { + X = 0, + Y = 1, + Z = 2, + }; + + // These are authoring knobs for optional loops, not connectivity probabilities. The scales + // keep old authored BranchProbability/Verticality values useful without allowing a lattice + // full of redundant parallel edges to turn the tree back into a grid. + constexpr float HorizontalLoopScale = 0.18f; + constexpr float VerticalLoopScale = 0.10f; + + FORCEINLINE uint32 NodeHash(int32 X, int32 Y, int32 Z, uint32 Salt, uint32 DomainSalt) + { + uint32 H = VoxelHash::Cell(X, Y, Salt ^ DomainSalt); + H ^= VoxelHash::Mix(static_cast(Z) * 73856093u ^ DomainSalt); + return VoxelHash::Mix(H); + } + + FORCEINLINE uint32 AxisSalt(EAxis Axis) + { + switch (Axis) + { + case EAxis::X: return 0xA1u; + case EAxis::Y: return 0xB2u; + case EAxis::Z: return 0xC3u; + default: return 0xA1u; + } + } + + FORCEINLINE bool TryGetParent(int32 X, int32 Y, int32 Z, uint32 Salt, + FIntVector& OutParent) + { + if (X == 0 && Y == 0 && Z == 0) + { + return false; + } + + EAxis Available[3]; + int32 Steps[3]; + int32 NumAvailable = 0; + if (X != 0) + { + Available[NumAvailable] = EAxis::X; + Steps[NumAvailable++] = X > 0 ? -1 : 1; + } + if (Y != 0) + { + Available[NumAvailable] = EAxis::Y; + Steps[NumAvailable++] = Y > 0 ? -1 : 1; + } + if (Z != 0) + { + Available[NumAvailable] = EAxis::Z; + Steps[NumAvailable++] = Z > 0 ? -1 : 1; + } + + const uint32 H = NodeHash(X, Y, Z, Salt, 0xD4E1A5E1u); + const int32 ParentIndex = static_cast(H % static_cast(NumAvailable)); + const EAxis ParentAxis = Available[ParentIndex]; + const int32 ParentStep = Steps[ParentIndex]; + OutParent = FIntVector(X, Y, Z); + switch (ParentAxis) + { + case EAxis::X: OutParent.X += ParentStep; break; + case EAxis::Y: OutParent.Y += ParentStep; break; + case EAxis::Z: OutParent.Z += ParentStep; break; + default: break; + } + return true; + } + + FORCEINLINE FIntVector AxisNeighbour(int32 X, int32 Y, int32 Z, EAxis Axis) + { + switch (Axis) + { + case EAxis::X: return FIntVector(X + 1, Y, Z); + case EAxis::Y: return FIntVector(X, Y + 1, Z); + case EAxis::Z: return FIntVector(X, Y, Z + 1); + default: return FIntVector(X, Y, Z); + } + } + + FORCEINLINE bool IsTreeEdge(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt) + { + const FIntVector A(X, Y, Z); + const FIntVector B = AxisNeighbour(X, Y, Z, Axis); + FIntVector Parent; + return (TryGetParent(A.X, A.Y, A.Z, Salt, Parent) && Parent == B) + || (TryGetParent(B.X, B.Y, B.Z, Salt, Parent) && Parent == A); + } + + FORCEINLINE float LoopProbability(EAxis Axis, float BranchProbability, float Verticality) + { + return Axis == EAxis::Z + ? FMath::Clamp(Verticality, 0.0f, 1.0f) * VerticalLoopScale + : FMath::Clamp(BranchProbability, 0.0f, 1.0f) * HorizontalLoopScale; + } + + FORCEINLINE bool IsLoopEdgeOpen(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt, + float BranchProbability, float Verticality) + { + if (IsTreeEdge(X, Y, Z, Axis, Salt)) + { + return false; + } + + const float Probability = LoopProbability(Axis, BranchProbability, Verticality); + return Probability > 0.0f + && VoxelHash::ToFloat01(NodeHash(X, Y, Z, Salt, AxisSalt(Axis))) < Probability; + } + + FORCEINLINE bool IsOpenEdge(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt, + float BranchProbability, float Verticality) + { + return IsTreeEdge(X, Y, Z, Axis, Salt) + || IsLoopEdgeOpen(X, Y, Z, Axis, Salt, BranchProbability, Verticality); + } +} + +//============================================================================= +// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D +//============================================================================= +// ⚠️ POURQUOI CE CORPS VIT ICI ET NON DANS VoxelNoise.h. +// Il a besoin de `VoxelHash::Mix` / `ToFloat01`, qui vivent dans CE fichier. Faire dépendre +// VoxelNoise.h (le socle bas niveau, inclus partout) du header de morphologie de grotte serait une +// inversion de dépendance ; dupliquer les 50 lignes serait un FORK d'une fonction pure — exactement +// le motif qui a produit `AUDIT §C1` (un correctif appliqué à une copie sur deux). Il monte donc au +// point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent. +// +// Il vit ici pour que le générateur et la pile d'opérateurs partagent la même implémentation. +// The hot overload uses FVector3f: noise coordinates are floats, so no double round-trip is paid +// before the hash/lattice arithmetic. Output compatibility with a double-precision evaluation is +// not a contract. +// +// Algorithme : distance au point-feature le plus proche dans une grille hachée. +// 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 − F1), normalisé ~[-1, 1] +// F2−F1 donne des frontières de cellules lisses avec des arêtes entre elles. +namespace VoxelNoise +{ + FORCEINLINE float Cellular3D(const FVector3f& Position) + { + // Integer cell coordinates + int32 CellX = FMath::FloorToInt(Position.X); + int32 CellY = FMath::FloorToInt(Position.Y); + int32 CellZ = FMath::FloorToInt(Position.Z); + + // Fractional position within cell + float FracX = Position.X - CellX; + float FracY = Position.Y - CellY; + float FracZ = Position.Z - CellZ; + + float F1 = FLT_MAX; // Distance to nearest feature point + float F2 = FLT_MAX; // Distance to 2nd nearest + + // Search 3x3x3 neighborhood + for (int32 DZ = -1; DZ <= 1; DZ++) + { + for (int32 DY = -1; DY <= 1; DY++) + { + for (int32 DX = -1; DX <= 1; DX++) + { + int32 NX = CellX + DX; + int32 NY = CellY + DY; + int32 NZ = CellZ + DZ; + + // Hash the neighbor cell to get a feature point position [0,1) + // Using three different hash mixes for X, Y, Z offsets + uint32 H = VoxelHash::Mix( + (uint32)(NX + 0x7FFFFFFF) + ^ VoxelHash::Mix((uint32)(NY + 0x7FFFFFFF) * 2654435761u) + ^ VoxelHash::Mix((uint32)(NZ + 0x7FFFFFFF) * 374761393u) + ); + + float FPX = (float)DX + VoxelHash::ToFloat01(H) - FracX; + float FPY = (float)DY + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) - FracY; + float FPZ = (float)DZ + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) - FracZ; + + float DistSq = FPX * FPX + FPY * FPY + FPZ * FPZ; + + // Track closest two distances + if (DistSq < F1) + { + F2 = F1; + F1 = DistSq; + } + else if (DistSq < F2) + { + F2 = DistSq; + } + } + } + } + + // F2 - F1: smooth cell boundaries with ridges between cells + // Sqrt for actual distance, then normalize to ~[-1, 1] + float Result = FMath::Sqrt(F2) - FMath::Sqrt(F1); + // Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types. + return Result * 2.0f - 1.0f; + } + + FORCEINLINE float Cellular3D(const FVector& Position) + { + return Cellular3D(FVector3f((float)Position.X, (float)Position.Y, (float)Position.Z)); + } +} + +//============================================================================= +// PER-CHUNK SDF CACHE +//============================================================================= +// The room list and tunnel connections are IDENTICAL for all voxels in a chunk. +// Without caching, a per-voxel evaluation would rebuild these 32,768 times per 32³ chunk: +// - Hash every grid cell in the search area +// - Determine room existence, position, size +// - Run O(N²) nearest-neighbor backbone +// - Decide O(N²) tunnel connections with hash lookups +// - Compute tunnel radii, floor-aligned endpoints, and wandering control chains +// +// With caching: build once, evaluate 32K times with just SDF math. +// This is the single biggest CPU performance win for cave generation. + +// Room-relative detail geometry whose hash/trigonometry is constant for a cached room. +// These are built once with the room's terrain-op parameters and then only evaluated per voxel. +struct FCachedArch +{ + bool bActive = false; + FVector EndpointA = FVector::ZeroVector; + FVector EndpointB = FVector::ZeroVector; + float Radius = 0.0f; + float BaseDensity = 0.0f; +}; + +struct FCachedPinch +{ + bool bActive = false; + float CenterX = 0.0f; + float CenterY = 0.0f; + float CenterZ = 0.0f; + float CosAngle = 1.0f; + float SinAngle = 0.0f; + float MaxExtent = 0.0f; + float HalfLength = 0.0f; + float HalfNarrow = 0.0f; + float HalfVertical = 0.0f; + float BaseDensity = 0.0f; +}; + +// A build-time room-floor bench authored from one tunnel mouth. The room evaluator only reads +// these immutable descriptors; it never searches the graph or decides a correction per sample. +struct FCachedRoomMouthRise +{ + FVector Mouth = FVector::ZeroVector; + float TargetFloorZ = 0.0f; + float BlendRadius = 8.0f; + float Strength = 0.0f; +}; + +// A room in the cache — everything needed for per-voxel SDF evaluation. +// Internal details (CellX, CellY) used during cache building are NOT stored here. +struct FCachedRoom +{ + FVector Center; // World position of room center + float RadiusXY; // Horizontal radius (used for SDF + shape selection) + float RadiusZ; // Vertical radius (usually squished: RadiusXY * HeightRatio) + uint32 Hash; // Cell hash — used for deterministic shape selection per voxel + bool bIsOrigin; // True = origin room at (0,0), always uses ellipsoid shape + float CullRadiusSq; // Squared distance beyond which this room can't affect a voxel + + // Flat floor cut: base world Z of the soft floor plane. + // Applied via SmoothMax so tunnels/pits pass through without a hard lip seam. + // Set to -FLT_MAX when the hash-rolled floor cut = 1.0 (full bubble, no cut). + float FloorCutZ; + + // Floor relief: noise amplitude (voxels) and frequency for floor undulation. + // 0 strength = perfectly flat floor (just the cut plane, no variation). + // Stored per-room so EvaluateSDFCached can apply it without a params lookup. + float FloorReliefStrength; + float FloorReliefFrequency; + uint32 FloorSeed; // Deterministic seed offset for this room's floor noise + + // Optional build-time floor benches leading to higher tunnel mouths. Empty in the default + // compatibility profile, so the baseline field is unchanged until an archetype opts in. + TArray MouthRises; + + // Per-room terrain operation, hash-rolled during BuildChunkCache from the + // strate's probability pool (FStrateTerrainOpEntry::Probability). + // null = this room has no terrain op (the "no op" slot in the probability pool). + // Raw pointer is safe — assets stay loaded for the entire generation session. + const UVoxelTerrainOpDefinition* RoomOp = nullptr; + + // Intensity scale for this room's op (from FStrateTerrainOpEntry::Weight). + // 1.0 = use op as configured, 0.5 = half intensity, 2.0 = double. + float RoomOpWeight = 1.0f; + + // PRE-BAKED SHAPE (BuildChunkCache). The shape roll, variety thresholds and the capsule's + // Cos/Sin direction are constants of the room: re-deriving them PER VOXEL inside + // EvaluateSDFCached would put hash mixes + trig in the hottest loop of the plugin. + // Bit-identical to a per-voxel roll (same hashes, same math, done once per chunk). + // 0 = ellipsoid → ShapeA = radii (x=y=RadiusXY, z=RadiusZ) + // 1 = rounded box → ShapeA = half-extents, ShapeR = corner rounding + // 2 = capsule → ShapeA/ShapeB = world endpoints, ShapeR = tube radius + uint8 ShapeType = 0; + FVector ShapeA = FVector::ZeroVector; + FVector ShapeB = FVector::ZeroVector; + float ShapeR = 0.0f; + + // Hash-derived room details. The old evaluator rebuilt these hashes and angles for every + // surface sample; the cache build is the only place where their geometry can change. + FCachedArch Arches[3]; + FCachedPinch Pinches[3]; +}; + +// A local flat-floor bridge over the overlap of two room bodies. It is not a new graph edge: it +// only gives an existing spherical-room intersection one shared walkable floor level, removing +// the high lip that otherwise appears when the two room floor cuts disagree. +struct FCachedRoomFloorJoin +{ + FVector Start; + FVector End; + float Radius = 0.0f; + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + FVector BoundCenter; + float BoundRadiusSq = 0.0f; +}; + +// One immutable floor segment authored while the tunnel chain is built. The floor is continuous +// unless bLedgeTransition is set; that marker means the segment terminates at a deliberate +// dramatic ledge and the vertical riser is placed at the next control-point boundary. A steep +// floor is never quantised into a staircase. ReliefScale is baked with a derivative bound, +// so evaluation only reads this profile and never decides a floor shape per voxel. +struct FTunnelFloorSegmentProfile +{ + float StartFloorZ = 0.0f; + float EndFloorZ = 0.0f; + // The unmodified tapered-capsule bottom at the same two control points. Keeping these + // alongside the authored floor lets evaluation translate the arch to the baked floor without + // re-deriving a second profile (and makes the build-time floor/arch relationship explicit). + float NaturalStartFloorZ = 0.0f; + float NaturalEndFloorZ = 0.0f; + float ReliefScale = 0.0f; + bool bLedgeTransition = false; +}; + +// A pre-computed wandering tunnel chain — all connection decisions and hash-derived properties +// (radii, Z offsets, control points) are resolved during cache build. +struct FCachedTunnel +{ + FVector EndpointA; // First room's floor-tangent connection point in SDF coordinates + FVector EndpointB; // Second room's floor-tangent connection point in SDF coordinates + float RadiusA; // Tube radius at endpoint A + float RadiusB; // Tube radius at endpoint B + // Compatibility summary of the chain's first/middle/last points. These fields are retained + // for diagnostics and old cache consumers; EvaluateSDFCached uses ControlPoints when present. + FVector Midpoint; + float RadiusMid; + bool bHasMidpoint; + TArray ControlPoints; + TArray ControlRadii; + // World-space copy used by the final structural air backstop. Evaluating this copy avoids + // turning a gentle authored floor ramp into a vertical break when the cave warp is nonlinear. + TArray WorldControlPoints; + TArray WorldControlRadii; + // Build-time floor authoring. SDF and world chains each keep their own profile because cave + // warp changes the control-point Z values. The normal path has one entry per chain segment; + // malformed/legacy caches fall back to the old local evaluator. + TArray FloorProfiles; + TArray WorldFloorProfiles; + // For a dramatic ledge, these monotonic levels identify the control-point boundaries at which + // the few large drops occur. Both SDF and world profiles share the boundaries, while each + // coordinate space supplies its own endpoint heights. + TArray FloorLedgeLevels; + bool bHasCompleteFloorProfile = false; + bool bHasCompleteWorldFloorProfile = false; + // The corridor floor is a swept SmoothMax cut, not a later slab. These are copied from the + // same strate fields used by room floors; the seed is derived once from the tunnel pair hash. + float FloorReliefStrength = 0.0f; + float FloorReliefFrequency = 0.015f; + uint32 FloorSeed = 0; + // Only the two rooms that actually form this edge may own its mouth floor. A nearby unrelated + // room must not clip a tunnel merely because the two shapes overlap in a crowded graph. + bool bHasFloorRoomOwnership = false; + uint32 FloorRoomHashA = 0; + uint32 FloorRoomHashB = 0; + // World-space mouth ownership is resolved from the baked chain, not by comparing a warped + // query against an SDF-space room. These values make the post-disturbance hand-off exact in + // the same coordinate space in which the player and the support backstop are evaluated. + // The room's full radius is deliberately not used as the ownership region. A tunnel's + // floor should hand off to the room over a short mouth apron, not overwrite the room floor + // across the entire chamber. These are the build-time capped hand-off radii in both query + // coordinate spaces. + float SDFMouthBlendRadiusA = 0.0f; + float SDFMouthBlendRadiusB = 0.0f; + float WorldMouthBlendRadiusA = 0.0f; + float WorldMouthBlendRadiusB = 0.0f; + float SDFMouthFloorZA = -FLT_MAX; + float SDFMouthFloorZB = -FLT_MAX; + float WorldMouthFloorZA = -FLT_MAX; + float WorldMouthFloorZB = -FLT_MAX; + bool bTunnelFloorTerracingEnabled = true; + float TunnelFloorTerraceStepHeight = 1.71f; + float TunnelFloorMaxLedgeHeight = 12.0f; + // Cached internal gradient derived from the authored degree value. This is not serialized. + float TunnelFloorGentleSlopeGradient = 0.9656888f; + int32 TunnelFloorLedgeCountPreference = 0; + int32 TunnelFloorMaxLedges = 4096; + bool bDramaticLedge = false; + bool bLedgeGraphEligible = false; + bool bFloorRouteWasWound = false; + int32 TunnelFloorAuthoredLedgeCount = 0; + FVector WorldBoundCenter = FVector::ZeroVector; + float WorldBoundRadiusSq = 0.0f; + // Centerline AABBs and scalar influence radii used by the immutable broad phase. The + // AABB test is a cheap lower bound on distance to the whole chain; the existing squared + // sphere test remains the exact conservative second-stage reject. + FVector SDFCenterlineMin = FVector::ZeroVector; + FVector SDFCenterlineMax = FVector::ZeroVector; + float SDFInfluenceRadius = 0.0f; + FVector WorldCenterlineMin = FVector::ZeroVector; + FVector WorldCenterlineMax = FVector::ZeroVector; + float WorldInfluenceRadius = 0.0f; + // Bounding sphere for quick per-voxel rejection + FVector BoundCenter; // Center of the bounding sphere + float BoundRadiusSq; // Squared radius — if voxel is further, skip this tunnel +}; + +struct FTunnelSupportFloorInterval +{ + int32 TunnelIndex = INDEX_NONE; + float FloorZ = 0.0f; + float MinZ = 0.0f; + float MaxZ = 0.0f; +}; + +struct FTunnelSupportFloorColumn +{ + TArray> Intervals; + + FTunnelSupportFloorColumn() = default; + + FTunnelSupportFloorColumn(const FTunnelSupportFloorColumn& Other) + : Intervals(Other.Intervals) + { + } + + FTunnelSupportFloorColumn& operator=(const FTunnelSupportFloorColumn& Other) + { + Intervals = Other.Intervals; + return *this; + } + + FTunnelSupportFloorColumn(FTunnelSupportFloorColumn&& Other) + : Intervals(MoveTemp(Other.Intervals)) + { + } + + FTunnelSupportFloorColumn& operator=(FTunnelSupportFloorColumn&& Other) + { + Intervals = MoveTemp(Other.Intervals); + return *this; + } + + void Reset() + { + Intervals.Reset(); + } +}; + +// A pre-baked pit shaft — position and dimensions resolved during BuildChunkCache. +// +// Pits are included in the main SDF evaluation (SmoothMin alongside rooms and +// tunnels) rather than as a separate density subtraction. This lets SmoothMin +// handle the pit-to-room junction organically — same mechanism as tunnel junctions. +// No more hard seam at PitTopZ. BlendK drives the junction roundness and comes +// from the strate's SDFBlendRadius, making it tweakable in the data asset. +struct FCachedPit +{ + float CenterX, CenterY; // XY center in world coords (unwarped) + float TopZ; // World Z where the pit starts (anchored inside room air) + float Radius; // Shaft cylinder radius + float Depth; // Max depth the pit reaches below TopZ + float FlareDist; // Over how many voxels the opening flares (Radius * 2) + float FlareExtra; // Extra radius at the lip (Radius * 1) + float BaseDensity; // Carving strength — matches the strate's BaseDensity + float BlendK; // SmoothMin blend radius — set from Params.SDFBlendRadius + float BoundXYRadiusSq; // XY rejection: skip if (dx^2+dy^2) > this +}; + +// A pre-baked chimney shaft — inverse of a pit (carves upward from room ceiling). +struct FCachedChimney +{ + float CenterX, CenterY; + float BottomZ; // World Z where the chimney starts (anchored inside room air) + float Radius; + float Height; // How far the chimney reaches above BottomZ + float FlareDist; + float FlareExtra; + float BaseDensity; + float BlendK; // SmoothMin blend radius — set from Params.SDFBlendRadius + float BoundXYRadiusSq; +}; + +// A pre-baked column — vertical solid cylinder inside a room. +// Pre-baking fixes the NearestRoomIdx ownership issue for columns too: +// a column's XY position is fixed, but which room "owns" the voxel can change +// with depth, which would cause columns to appear/disappear mid-height. +struct FCachedColumn +{ + float CenterX, CenterY; + float Radius; + float BaseDensity; + float BoundXYRadiusSq; +}; + +// Build-site attribution is diagnostic only. It is deliberately passed to BuildChunkCache +// rather than inferred from the caller's stack so the game report can separate the generator, +// ordinary op-source, shared op-source, and classifier fallback windows without changing the +// cache key or the generated field. +enum class ERoomGraphBuildSite : uint8 +{ + Unknown, + GeneratorTile, + GeneratorTunnelCore, + OpShared, + OpLocal, + ClassifierShared, + ClassifierLocal, +}; + +// Immutable XY broad phase for one cached primitive array. Each bucket stores source-array +// indices in ascending order, so switching from a full scan to a bucket scan cannot change the +// order of SmoothMin/Min reductions or the selected nearest-room owner. Invalid/oversized +// bounds leave bValid false and callers deliberately fall back to the exact full scan. +struct FChunkSDFSpatialIndex +{ + int32 MinChunkX = 0; + int32 MinChunkY = 0; + int32 NumCellsX = 0; + int32 NumCellsY = 0; + bool bValid = false; + TArray CellOffsets; + TArray ItemIndices; + + void Reset(); + void Release(); + SIZE_T GetAllocatedSize() const; + bool GetRange(float WorldX, float WorldY, int32& OutBegin, int32& OutEnd) const; +}; + +// Research-only 3D broad phase used by the step-2 region evaluator. It uses 8-voxel bins and +// keeps source indices in ascending order so SmoothMin / ownership reductions retain the live +// evaluator's order. Invalid or oversized grids fall back to the existing XY index. +struct FChunkSDFBrickIndex +{ + int32 MinBrickX = 0; + int32 MinBrickY = 0; + int32 MinBrickZ = 0; + int32 NumBricksX = 0; + int32 NumBricksY = 0; + int32 NumBricksZ = 0; + bool bValid = false; + TArray CellOffsets; + TArray ItemIndices; + + void Reset(); + void Release(); + SIZE_T GetAllocatedSize() const; + bool GetRange(float WorldX, float WorldY, float WorldZ, + int32& OutBegin, int32& OutEnd) const; +}; + +template +FORCEINLINE int32 VF_ForEachChunkSDFSpatialCandidate( + const FChunkSDFSpatialIndex& Index, int32 ItemCount, + float WorldX, float WorldY, FVisit&& Visit, + bool bUseSpatialIndex = true) +{ + int32 Begin = 0; + int32 End = 0; + int32 CandidateCount = 0; + if (bUseSpatialIndex && Index.GetRange(WorldX, WorldY, Begin, End)) + { + for (int32 Cursor = Begin; Cursor < End; ++Cursor) + { + const int32 ItemIndex = Index.ItemIndices[Cursor]; + if (ItemIndex >= 0 && ItemIndex < ItemCount) + { + ++CandidateCount; + Visit(ItemIndex); + } + } + return CandidateCount; + } + + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + ++CandidateCount; + Visit(ItemIndex); + } + return CandidateCount; +} + +// The complete SDF cache for a chunk region. +// Built once per chunk by BuildChunkCache(), then passed to EvaluateSDFCached() +// for every voxel in the chunk. Typically contains 5-15 rooms and 10-30 tunnels. +struct FChunkSDFCache +{ + TArray Rooms; + TArray RoomFloorJoins; + TArray Tunnels; + TArray Pits; + TArray Chimneys; + TArray Columns; + + // Per-primitive XY filters. The bounds are conservative unions of every exact evaluator + // broad phase, so a failed index build is a performance fallback, never a density fallback. + FChunkSDFSpatialIndex RoomSpatialIndex; + FChunkSDFSpatialIndex RoomFloorJoinSpatialIndex; + FChunkSDFSpatialIndex TunnelSpatialIndex; + FChunkSDFSpatialIndex PitSpatialIndex; + FChunkSDFSpatialIndex ChimneySpatialIndex; + FChunkSDFSpatialIndex ColumnSpatialIndex; + +#if WITH_EDITOR + FChunkSDFBrickIndex RoomBrickIndex; + FChunkSDFBrickIndex RoomFloorJoinBrickIndex; + FChunkSDFBrickIndex TunnelBrickIndex; +#endif + + // Kept with the cache because the world-space structural evaluator has no params argument. + // A zero value is a valid fallback for legacy/empty caches. + float SDFBlendRadius = 0.0f; + + // Allocator-backed bytes owned by this cache, including the nested tunnel chains. The returned + // value excludes sizeof(FChunkSDFCache) itself so callers can add the enclosing entry's inline + // storage exactly once. + // Reset keeps the allocated backing storage and clears the immutable contents. + // It is used when a classifier proves that the current search window has no + // possible room/tunnel feature. + void Reset(); + // Release all backing storage. Used when a worker has moved from a populated + // classifier window to a feature-free one and the retained capacity is no + // longer useful; this is intentionally separate from hot-path Reset(). + void Release(); + SIZE_T GetAllocatedSize() const; + VoxelDensityProfile::FCacheMemoryBreakdown GetAllocatedSizeBreakdown() const; +}; + +/** World-space tunnel shape query. */ +struct FTunnelCoreWorldEvaluation +{ + float SDF = FLT_MAX; + // Room geometry owns the lower side while the world-space tunnel is inside a room. The + // tunnel SDF may still reopen air there, but its swept bottom must not turn that room air back + // into a shelf or wall. + bool bRoomFloor = false; + // Shape-level floor ownership. This is not an authored slab: it identifies the bottom side + // of the swept tunnel SDF so a room that is already air cannot erase the tunnel's own floor. + bool bHasSweptFloor = false; + float SweptFloorZ = -FLT_MAX; + float SweptFloorRadius = 0.0f; +}; + +//============================================================================= +// CAVE MORPHOLOGY EVALUATOR +//============================================================================= +// Two-phase evaluation: BuildChunkCache (once per chunk) + EvaluateSDFCached (per voxel). + +namespace VoxelCaveMorphology +{ +#if WITH_EDITOR + /** + * Enable the isolated step-2 8^3-cell SDF binning path on the current worker thread. The + * default is off and gameplay callers never construct or select these indices. + */ + VOXELFORGE_API void SetResearchBrickBinningEnabled(bool bEnabled); + VOXELFORGE_API bool IsResearchBrickBinningEnabled(); + VOXELFORGE_API void SetResearchBrickRoomReachScale(float Scale); +#endif + // The player-fit memo is process-wide and uses a fixed-size table. These controls are kept + // beside the cave source so the command-line value is applied before generation workers start, + // while the CVar remains available for an in-process A/B switch. + VOXELFORGE_API void ConfigurePlayerFitMemoFromCommandLine(); + VOXELFORGE_API void ResetPlayerFitMemoStats(); + VOXELFORGE_API void LogPlayerFitMemoStats(); + + // The room graph's seed is the world seed salted by the strate index. Keep this formula + // shared by BuildChunkCache and landing-site queries so a query cannot inspect another + // strate's room layout. / La seed du graphe des salles est la seed monde salée par l'index + // de strate. La formule est partagée entre BuildChunkCache et les requêtes d'atterrissage. + FORCEINLINE uint32 MakeStrateSeed(uint32 WorldSeed, int32 StrateIndex) + { + return VoxelHash::Mix(WorldSeed ^ (uint32)(StrateIndex * 7919 + 104729)); + } + + // Map a world-space query to the SDF coordinate space used by the room/tunnel cache. Keeping + // this transform in the morphology module lets the generator's post-disturbance structural + // check share the exact same warp as the source and the landing query. + VOXELFORGE_API FVector ApplyCaveWarp( + const FVector& WorldPoint, + const FStrateGenerationParams& Params, + uint32 Seed + ); + + // PHASE 1: Build the SDF cache for a rectangular region. + // Collects all rooms, computes nearest-neighbor backbone, decides tunnel + // connections, and pre-computes all tunnel geometry (radii, offsets, midpoints). + // Also hash-rolls a terrain op per room from the optional probability pool. + // + // Call once per chunk before the voxel loop. The region bounds should cover + // the chunk's XY extent PLUS CaveWarpStrength (warped queries can shift) + // PLUS a small margin for gradient normal sampling (~2 voxels). + // MaxInfluence (room/tunnel reach) is computed internally from Params. + // + // @param OutCache — filled with rooms and tunnels for this region + // @param SearchMinX/Y, SearchMaxX/Y — XY world bounds to search (expanded by caller) + // @param Params — strate generation parameters + // @param Seed — world seed + // @param StrateIndex — which strate (offsets hash for variety) + // @param TerrainOps — optional probability pool; null = no per-room ops + VOXELFORGE_API void BuildChunkCache( + FChunkSDFCache& OutCache, + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + const TArray* TerrainOps = nullptr, + ERoomGraphBuildSite BuildSite = ERoomGraphBuildSite::Unknown + ); + + // Conservative, geometry-free preflight for BuildChunkCache. It recreates + // the deterministic room candidates and edge decisions, but does not build + // player-fit points or tunnel control chains. False means that the same + // cache window is guaranteed to contain no room, join, decoration, or + // connected tunnel. Any malformed or unbounded input returns true. + VOXELFORGE_API bool MayHaveFeatureInSearchBox( + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + bool bUseZ = false, + float SearchMinZ = 0.0f, float SearchMaxZ = 0.0f, + const TArray* TerrainOps = nullptr + ); + + // True only for the finite parameter envelope for which BuildChunkCache's fixed collect + // margin proves window invariance. Callers may widen the XY store window to a whole tile only + // when this returns true; malformed or unbounded authored input must retain the legacy path. + VOXELFORGE_API bool IsRoomGraphWindowInvariant( + const FStrateGenerationParams& Params, + const TArray* TerrainOps = nullptr + ); + + // PHASE 2: Evaluate the SDF at a single world position using cached data. + // Loops through cached rooms and tunnels, computes SDF primitives, applies + // SmoothMin. Includes per-room/tunnel distance culling to skip primitives + // that are too far to contribute. + // + // @param WorldX, WorldY, WorldZ — room/query position in voxel coordinates (may be warped) + // @param Cache — pre-built cache from BuildChunkCache + // @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius) + // @param OutNearestRoomIdx — optional out: index of the room with minimum SDF + // contribution. -1 if no room passed the cull test. + // Used by the terrain ops system to look up the + // per-room terrain op assigned to this voxel's room. + // @param WorldTunnelPosition — optional unwarped position for the tunnel primitive. When + // supplied, rooms remain at the query position but the tunnel + // uses its authored world chain. Null preserves the legacy + // all-query behavior. + // @param OutRoomOwnsBottom — optional out: true when the query position is inside any + // cached room's raw authored shape. When the caller evaluates + // the tunnel core for this same sample, pass this result back + // to EvaluateTunnelCoreWorld to avoid a second room query. + // (Room shape variety is baked into FCachedRoom by BuildChunkCache — no per-voxel roll.) + // @return negative = inside cave, positive = solid rock + VOXELFORGE_API float EvaluateSDFCached( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + float SDFBlendRadius, + int32* OutNearestRoomIdx = nullptr, + bool bUseSpatialIndex = true, + const FVector* WorldTunnelPosition = nullptr, + bool* OutRoomOwnsBottom = nullptr + ); + + // Evaluate the final structural air contract against the world-space wandering chain. Room + // SDFs remain evaluated in warped coordinates; only this post uses world coordinates so its + // walkable floor follows the authored chain exactly. + VOXELFORGE_API float EvaluateTunnelCoreWorldSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + bool bUseSpatialIndex = true + ); + + VOXELFORGE_API FTunnelCoreWorldEvaluation EvaluateTunnelCoreWorld( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + // Deprecated legacy argument; graph-tunnel evaluation ignores the old support column. + const FTunnelSupportFloorColumn* SupportColumn = nullptr, + bool bUseSpatialIndex = true, + // The room graph is evaluated in warped coordinates while the tunnel core is evaluated in + // the authored world frame. Pass the former when available so room ownership is tested in + // the same domain as the generic room field. + const FVector* RoomQueryPosition = nullptr, + // Optional result from EvaluateSDFCached for this exact sample. When supplied, it avoids + // repeating the room-shape containment query; null preserves standalone-call behavior. + const bool* CachedRoomOwnsBottom = nullptr + ); + + // Per-tile broad reach for the world-space core/floor post. The bound includes the swept + // chain, floor band, relief envelope, and the finite room-mouth ownership apron. + VOXELFORGE_API bool AnyTunnelCoreWorldNearLattice( + const FChunkSDFCache& Cache, + const FBox& VoxelBox, + float ReachScale = 1.0f + ); + +} + +/** + * Suggest a deterministic player-fit landing point for an inter-strate passage mouth. + * + * This is deliberately a pure source query: it does not touch a generator, an operator stack, + * a strate manager, a cache, or mutable state. DesiredX/DesiredY is the passage's deliberate + * placement around the (0,0) spine. Sparse sources may move laterally, but never farther than + * MaxLateralSnap; false means no confident player-fit pose exists inside that bound. A successful + * point has a locally supported floor within player step height, a walkable support patch, and a + * clear capsule volume. The local source stencil deliberately does not claim flood-fill + * connectivity or a measured component size; it selects a topology-backed feature core instead. + * For TunnelNetwork/Underwater, Seed must be the strate-specific room seed returned by + * VoxelCaveMorphology::MakeStrateSeed; the optional WorldSeed selects the production cave-warp + * field and should be the generator world seed. If omitted, the room seed is used for both for + * backward-compatible standalone queries. For slab, maze, shaft, and island archetypes, Seed is the + * generator world seed consumed by their source. SurfaceWorld deliberately has no answer here: + * its production height can be selected by manager-owned biome context and per-chunk state, + * which this re-entrant query cannot inspect. + * + * Propose un point d'atterrissage déterministe où la capsule du joueur tient réellement pour la + * bouche d'un passage inter-strates. Cette requête reste pure et le déplacement latéral est + * strictement borné par MaxLateralSnap; elle ne prétend pas prouver une composante connectée. + * Pour VerticalShafts, la réponse s'appuie sur l'axe d'un puits sélectionné et peut se déplacer + * vers une banquette déterministe (+X/+Y) pour obtenir un support; ce n'est pas un point ouvert + * arbitraire à l'intérieur de son disque. + * + * @return true when the archetype can provide a confident point; false when it cannot. + */ +VOXELFORGE_API bool VF_SuggestLandingPoint( + ECaveGeneratorType Archetype, + const FStrateGenerationParams& CaveParams, + const FSlabGenerationParams& SlabParams, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float DesiredX, + float DesiredY, + float MaxLateralSnap, + FVector& OutPoint, + int32 WorldSeed = MIN_int32 +); + +/** + * Complete player-fit source-query overload for archetypes whose placement parameters are not part of + * FStrateGenerationParams/FSlabGenerationParams. The extra structs are read-only inputs only; + * the function remains pure and SurfaceWorld still returns false for the reason above. The + * returned point is either at the requested XY or within MaxLateralSnap of it, and is accepted + * only after the local player capsule/support stencil passes. VerticalShafts uses a selected + * shaft's deterministic feature core and ledge-side pose; the axis identifies the topology but + * is not itself claimed to be a standable point. + */ +VOXELFORGE_API bool VF_SuggestLandingPoint( + ECaveGeneratorType Archetype, + const FStrateGenerationParams& CaveParams, + const FSlabGenerationParams& SlabParams, + const FMazeGenerationParams& MazeParams, + const FVerticalShaftParams& VerticalShaftParams, + const FFloatingIslandParams& FloatingIslandParams, + int32 Seed, + float StrateTopZ, + float StrateBottomZ, + float DesiredX, + float DesiredY, + float MaxLateralSnap, + FVector& OutPoint, + int32 WorldSeed = MIN_int32 +); diff --git a/Standalone/Source/VoxelForge/Public/VoxelDensityAblation.h b/Standalone/Source/VoxelForge/Public/VoxelDensityAblation.h new file mode 100644 index 0000000..2667303 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelDensityAblation.h @@ -0,0 +1,60 @@ +// VoxelDensityAblation.h +// Development-only, world-changing measurement overrides for TunnelNetwork density stages. + +#pragma once + +#include "CoreMinimal.h" + +#include + +namespace VoxelDensityAblation +{ + /** + * A bit in this mask means that the named stage is forced off. These are measurement + * overrides, not gameplay settings: they change the generated field, are resolved once per + * process, and must match across peers and regenerated worlds. + */ + enum class EStage : uint32 + { + CaveWarp = 1u << 0, + DetailOps = 1u << 1, + RoomSDF = 1u << 2, + TunnelSDF = 1u << 3, + TunnelCore = 1u << 4, + PassageCarving = 1u << 5, + PassageStructuralPosts= 1u << 6, + NativeFloor = 1u << 7, + Disturbances = 1u << 8, + OriginSpine = 1u << 9, + BoundarySeal = 1u << 10, + LandingPosts = 1u << 11, + XYEdgeSeal = 1u << 12, + PitChimneySDF = 1u << 13, + }; + + /** Resolve command-line/CVar values once and return the process-wide mask. */ + VOXELFORGE_API uint32 GetResolvedMask(); + + FORCEINLINE bool IsForcedOff(EStage Stage) + { + return (GetResolvedMask() & static_cast(Stage)) != 0; + } + + FORCEINLINE bool IsCaveWarpOff() { return IsForcedOff(EStage::CaveWarp); } + FORCEINLINE bool IsDetailOpsOff() { return IsForcedOff(EStage::DetailOps); } + FORCEINLINE bool IsRoomSDFOff() { return IsForcedOff(EStage::RoomSDF); } + FORCEINLINE bool IsTunnelSDFOff() { return IsForcedOff(EStage::TunnelSDF); } + FORCEINLINE bool IsTunnelCoreOff() { return IsForcedOff(EStage::TunnelCore); } + FORCEINLINE bool IsPassageCarvingOff() { return IsForcedOff(EStage::PassageCarving); } + FORCEINLINE bool IsPassageStructuralPostsOff() + { + return IsForcedOff(EStage::PassageStructuralPosts); + } + FORCEINLINE bool IsNativeFloorOff() { return IsForcedOff(EStage::NativeFloor); } + FORCEINLINE bool IsDisturbancesOff() { return IsForcedOff(EStage::Disturbances); } + FORCEINLINE bool IsOriginSpineOff() { return IsForcedOff(EStage::OriginSpine); } + FORCEINLINE bool IsBoundarySealOff() { return IsForcedOff(EStage::BoundarySeal); } + FORCEINLINE bool IsLandingPostsOff() { return IsForcedOff(EStage::LandingPosts); } + FORCEINLINE bool IsXYEdgeSealOff() { return IsForcedOff(EStage::XYEdgeSeal); } + FORCEINLINE bool IsPitChimneySDFOff() { return IsForcedOff(EStage::PitChimneySDF); } +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelDensityOp.h b/Standalone/Source/VoxelForge/Public/VoxelDensityOp.h new file mode 100644 index 0000000..a25aaa2 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelDensityOp.h @@ -0,0 +1,934 @@ +// VoxelDensityOp.h +// LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT. +// Design : Docs/archive/OPSTACK-PLAN.md. The contract is implemented by the stack path in +// UVoxelGenerator::GetDensityAt, which generates every strate. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// POURQUOI / WHY +// ───────────────────────────────────────────────────────────────────────────────────────── +// Chaque archétype (ECaveGeneratorType) est une composition d'opérateurs, pas une fonction de +// densité monolithique qui posséderait le voxel entier. Une idée de monde s'exprime en empilant +// sources, combinateurs et modificateurs — et les idées peuvent donc SE COMBINER : « une strate de +// surface dont les montagnes contiennent un réseau de salles, avec des îles flottantes au-dessus ». +// +// Each archetype (ECaveGeneratorType) is a composition of operators, not a monolithic density +// function that owns the whole voxel. A world idea is expressed by stacking sources, combiners and +// modifiers — so ideas can COMBINE. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// ⚠️ CE N'EST PAS L'ANCIEN SYSTÈME DE « ROOM OPERATIONS » / THIS IS NOT THE OLD ROOM-OPS SYSTEM +// ───────────────────────────────────────────────────────────────────────────────────────── +// UVoxelTerrainOpDefinition (Terrace, Ribbing, Cliff, Scallop, Overhang, Arch, Column, Pit…) ne +// sait que PERTURBER une densité près d'une surface qui existe déjà. Il ne décide jamais ce que le +// champ EST — c'est le rôle des sources de champ ci-dessous. +// +// D'où QUATRE RÔLES, dont l'ancien système n'occupait que le troisième : +// +// 1. FIELD SOURCE — fabrique un champ À PARTIR DE RIEN. C'est ce qui fait qu'une grotte est +// une grotte et qu'un monde ouvert est un monde ouvert. Chaque archétype +// est fondamentalement l'une de ces sources. +// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui +// achète la composition ; sans lui il n'y a pas de refactor. +// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé. +// 4. STRUCTURAL POST — spine (0,0) → seal vertical → passages → seal XY → diff layer. Des INVARIANTS de monde, +// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais +// omissibles par l'auteur. +// +// The old system only ever had role 3. Roles 1 and 2 are the new thing, and role 4 is what keeps +// the descent structure intact no matter what an author assembles. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// LA CLÉ DE VOÛTE : EffectOverBox + intervalle SDF / THE KEYSTONE: EffectOverBox + SDF interval +// ───────────────────────────────────────────────────────────────────────────────────────── +// Un verdict de boîte doit être une preuve. Rendre `Both` coûte du CPU ; rendre `Identity` ou une +// direction fausse supprime de la géométrie et peut laisser un joueur tomber au travers du monde. +// En cas de doute, `Mixed`. +// +// Un opérateur qui écrit seulement le canal SDF ne peut pas honnêtement répondre pour le convertisseur +// qui viendra peut-être après lui : il doit propager un intervalle SDF, et le convertisseur doit plier +// cet intervalle avec sa propre formule. Chaque opérateur répond ainsi pour lui-même, et la composition +// reste sûre même pour une combinaison que personne n'a écrite à la main auparavant. +// +// A box verdict is a proof. `Both` costs CPU; a false `Identity` or direction removes geometry and can +// let a player fall through the world. When in doubt, return `Mixed`. +// An SDF-only writer reports an SDF interval; a later converter folds that interval through its own +// response. Each operator answers for itself, so an unverified composition remains sound. +// +// Presque tout opérateur est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir. La direction +// suffit donc à exprimer GÉNÉRIQUEMENT les gardes de ClassifyTile : +// +// « passages ⇒ bCanSolid = false » EST CarveOnly +// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly +// « aucun passage près de cette boîte » EST Identity +// +// La direction reste le repli conservateur par défaut pour les opérateurs qui ne connaissent pas +// d'intervalle ; elle ne permet jamais à un écrivain SDF d'usurper la réponse d'un voisin. +// +// Direction remains the conservative fallback for operators without an interval implementation; +// it never lets an SDF writer impersonate a downstream neighbour. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// CLASSIFYTILE +// ───────────────────────────────────────────────────────────────────────────────────────── +// ClassifyTile garde ses preuves exactes pour les gaps de bedrock et SurfaceWorld ; les slots de +// cave passent par le pliage `ClassifyBox` de la pile même qui produit leur densité. Le post XY +// fournit en plus une preuve globale de coque : une tuile entièrement dans la bande forcée peut être +// sautée sans connaître l'archétype ou le Z. +// +// ClassifyTile keeps its exact proofs for bedrock gaps and SurfaceWorld; cave slots go through the +// `ClassifyBox` fold of the same stack that produces their density. The XY post adds a global +// shell proof: a tile entirely inside the forced band is skipped without knowing archetype or Z. +// +// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h). +// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et +// auront besoin d'être édités dans l'éditeur. Les promouvoir plus tôt n'achèterait rien et +// ajouterait une étape UHT à chaque itération. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" // CHUNK_SIZE, EVoxelTileClass + +struct FBiomeContext; +struct FTunnelCoreWorldEvaluation; +struct FChunkSDFCache; + +//============================================================================= +// LES QUATRE RÔLES / THE FOUR ROLES +//============================================================================= +// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les +// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage → +// seal XY → diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit +// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur +// gagnent toujours. +enum class EVoxelOpRole : uint8 +{ + // Rôle 1 — fabrique un champ à partir de rien. Une pile en a au moins un (sa racine). + FieldSource, + + // Rôle 2 — fusionne le champ précédent avec le suivant. Voir EVoxelOpCombine. + Combiner, + + // Rôle 3 — perturbe un champ existant près de sa surface (l'ancien UVoxelTerrainOpDefinition). + DetailModifier, + + // Rôle 4 — invariants de monde. Ajoutés automatiquement, ordre fixe, non omissibles. + StructuralPost, +}; + +//============================================================================= +// CANAUX DE L'ÉCHANTILLON / SAMPLE CHANNELS +//============================================================================= +// `FVoxelOpSample` is deliberately a small, explicit state record. Keep its channel set here, +// next to the interface that declares which fields an operator consumes and publishes. The enum +// values are bits so a declaration can name more than one channel without introducing another +// per-voxel object or a dynamic container. +// +// `Density` is the internal positive=solid field; `Sdf` is the standard negative=inside field. +// If a future field is added to `FVoxelOpSample`, it must also be added here and to every operator +// declaration before the composer can treat that field as part of the dependency graph. +enum class EVoxelOpChannel : uint8 +{ + None = 0, + Density = 1 << 0, + Sdf = 1 << 1, +}; + +using EVoxelOpChannelMask = uint8; + +namespace VoxelOpChannels +{ + static constexpr EVoxelOpChannelMask None = 0; + static constexpr EVoxelOpChannelMask Density = static_cast(EVoxelOpChannel::Density); + static constexpr EVoxelOpChannelMask Sdf = static_cast(EVoxelOpChannel::Sdf); + static constexpr EVoxelOpChannelMask All = Density | Sdf; +} + +//============================================================================= +// OPERATOR RESOURCES / RESSOURCES D'OPERATEUR +//============================================================================= +// Sample-channel declarations describe the FVoxelOpSample only. Some operators also consume +// stateful geometry published by an earlier source (the room graph, shaft field, or surface +// column). Keep that dependency explicit as a second, tiny graph so the composer cannot place a +// room modifier on a stack that has no room source merely because its Density/Sdf masks happen to +// fit. +enum class EVoxelOpResource : uint8 +{ + None = 0, + RoomGeometry = 1 << 0, + ShaftGeometry = 1 << 1, + SurfaceColumn = 1 << 2, +}; + +using EVoxelOpResourceMask = uint8; + +namespace VoxelOpResources +{ + static constexpr EVoxelOpResourceMask None = 0; + static constexpr EVoxelOpResourceMask RoomGeometry = static_cast(EVoxelOpResource::RoomGeometry); + static constexpr EVoxelOpResourceMask ShaftGeometry = static_cast(EVoxelOpResource::ShaftGeometry); + static constexpr EVoxelOpResourceMask SurfaceColumn = static_cast(EVoxelOpResource::SurfaceColumn); + static constexpr EVoxelOpResourceMask All = RoomGeometry | ShaftGeometry | SurfaceColumn; +} + +//============================================================================= +// COMPOSITION / COMBINERS +//============================================================================= +// Vocabulaire délibérément petit, et il réutilise ce qui existe déjà +// (VoxelSDF::SmoothMin / SmoothMax). +// +// ⚠️⚠️ RAPPEL DE SIGNE — LA source n°1 de confusion du plugin, et il y a DEUX conventions en jeu. +// Lire ceci en entier avant d'écrire un opérateur. +// +// • CANAL DENSITÉ, à l'intérieur de la pile : convention INTERNE, **POSITIF = SOLIDE**. +// C'est celle dans laquelle CHAQUE fonction d'archétype est écrite aujourd'hui. La négation +// vers la convention marching-cubes (négatif = solide) se fait UNE FOIS, tout à la fin, par +// l'appelant. Donc ici : « ajouter du solide » = MAX, « creuser de l'air » = MIN. +// +// • CANAL SDF : convention SDF standard, **NÉGATIF = À L'INTÉRIEUR de la primitive**. +// Réunir deux formes = MIN (c'est `SmoothMin`, ce que fait déjà le code pour salle+puits). +// Le sens de « min » est donc l'INVERSE d'un canal à l'autre. Ce n'est pas une incohérence : +// un SDF décrit une FORME, une densité décrit de la MATIÈRE. +// +// DENSITY channel inside the stack: INTERNAL convention, **POSITIVE = SOLID** (what every +// archetype body already uses; the MC negate happens once, at the end, in the caller). So +// "add solid" is max(), "carve air" is min(). +// SDF channel: standard SDF, **NEGATIVE = INSIDE the primitive**; unioning shapes is min(). +// The meaning of min() is therefore opposite between the two channels — an SDF describes a +// SHAPE, a density describes MATTER. +enum class EVoxelOpCombine : uint8 +{ + Replace, // ignore l'entrée — racine de pile (heightfield, densité de base) + Union, // max() sur la DENSITÉ — ajoute du solide : ponts, îles, colonnes + Subtract, // min() sur la DENSITÉ — creuse de l'air : salles, tunnels, passages, spine + SmoothUnion, // VoxelSDF::SmoothMin(k) — jonctions organiques + SmoothSubtract, // VoxelSDF::SmoothMax(k) + Add, // accumulation scalaire — termes de bruit / rugosité + Mask, // met à l'échelle l'opérateur SUIVANT par un champ [0,1] + // (poids de biome, porte de pente, relief, profondeur). + // C'est Mask qui achète le plus d'expressivité : « cet opérateur, mais + // seulement dans les régions à fort relief » devient de la composition + // au lieu d'une garde codée en dur dans chaque opérateur. +}; + +//============================================================================= +// EFFET SUR UNE BOÎTE / EFFECT OVER A BOX +//============================================================================= +// CONSERVATIF PAR CONSTRUCTION. Rendre `Both` est TOUJOURS SÛR (ça ne coûte que du CPU) ; +// rendre le mauvais est un TROU — pas de géométrie, pas de collision, invisible jusqu'à ce +// qu'un joueur tombe au travers. En cas de doute : `Both`. +// +// CONSERVATIVE BY CONSTRUCTION. Returning `Both` is ALWAYS SAFE (it only costs CPU); returning +// the wrong one is a HOLE. When unsure: `Both`. +enum class EVoxelOpEffect : uint8 +{ + // Prouvablement aucun effet sur cette boîte. C'est le early-out qui rend la pile rapide, + // et c'est ce qui rendra le bedrock profond sautable pour les strates de grotte. + Identity, + + // Ne peut que pousser la densité vers l'AIR ⇒ tue l'hypothèse « tout solide ». + CarveOnly, + + // Ne peut que pousser la densité vers le SOLIDE ⇒ tue l'hypothèse « tout air ». + FillOnly, + + // Non contraint ⇒ tue les deux hypothèses. + Both, +}; + +//============================================================================= +// CONTEXTE DE CHUNK / CHUNK CONTEXT +//============================================================================= +// Miroir de ce que le bloc thread_local CP_* résout aujourd'hui dans GetDensityAt. +// +// ⚠️ LayoutVersion est ici PAR CONSTRUCTION, pas par politesse. AUDIT C2 : trois caches +// existants (CP_Chunk, OC_Chunk, BM_Chunk) sont clés sur ChunkCoord SEUL, donc après un +// RebuildStrates ou une édition à chaud un worker dont le cache est encore chaud pour ce chunk +// saute le refetch et génère avec les ANCIENS params. En faisant porter LayoutVersion par le +// contexte, un nouvel opérateur ne PEUT PAS oublier de l'inclure dans sa clé. +// +// LayoutVersion is here BY CONSTRUCTION, not by politeness — see AUDIT C2. Carrying it in the +// context means a new op CANNOT forget to put it in its cache key. +struct FVoxelOpContext +{ + FIntVector ChunkCoord = FIntVector::ZeroValue; + + // Pas d'échantillonnage LOD (1/2/4…). Un opérateur a le droit de se simplifier quand Step + // est grand — c'est le contrat T2.b : le bruit volumétrique par voxel perd des octaves au + // loin, le bruit de champ XY délibérément non (il alimente des caches box-validés partagés). + int32 Step = 1; + + uint32 Seed = 0; + + // Compteur de génération du layout (UVoxelStrateManager::GetLayoutVersion()). + // DOIT faire partie de toute clé de cache. Voir AUDIT C2. + uint32 LayoutVersion = 0; + + // Global XY world-edge invariant. These are copied from UVoxelSettings by the generator and + // travel with the chunk context so the automatically appended edge op cannot be omitted by a + // builder. Radius 0 is the legacy unbounded/no-op mode. + float WorldRadiusVoxels = 8192.0f; + float EdgeSealThickness = 128.0f; + + // Bornes Z de la strate en coords VOXEL (pas cm). + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; + + // ClassifyTile may ask box bounds about the exact marching-cubes lattice rather than the + // continuous volume between samples. Production voxel evaluation leaves this disabled; + // when enabled, operators may tighten a conservative bound only for points + // LatticeOriginVoxels + n * Step that lie inside the queried box. + bool bUseLatticeProof = false; + FIntVector LatticeOriginVoxels = FIntVector::ZeroValue; + + // A mixed tile may opt into a tighter, still conservative finite-box bound for domain-warped + // sources. The normal classifier leaves this off; ClassifyTile retries only the few boxes + // that the cheap global proof could not resolve. + bool bTightenWarpProof = false; + + // null = cette strate n'a pas de champ de biome. + const FBiomeContext* Biome = nullptr; +}; + +//============================================================================= +// L'ÉTAT QUI TRAVERSE LA PILE / THE STATE THE STACK THREADS THROUGH +//============================================================================= +// DEUX canaux, pas un. Ce n'est pas de la généralité gratuite — c'est ce que le code fait déjà : +// +// CaveSDF = EvaluateSDFCached(salles + tunnels) ← espace SDF +// CaveSDF = SmoothMin(CaveSDF, PitSDF, BlendK) ← espace SDF +// CaveSDF = SmoothMin(CaveSDF, ChimneySDF, BlendK) ← espace SDF +// → UN SEUL carve à la fin : Density -= CarveFactor · BaseDensity · 2 +// +// Maze, VerticalShafts et FloatingIslands ont la même forme, et TROIS d'entre eux appliquent la +// rugosité au **SDF** (`MazeSDF += bruit·Rough`), pas à la densité. Sur la densité, le même bruit +// est mis à l'échelle par le gradient local : effet visiblement différent. +// +// Avec un seul canal, un opérateur ne peut qu'ÉCRASER le précédent — les jonctions SmoothMin +// (salle↔puits, et demain « un graphe de salles creusé DANS une montagne ») sont impossibles. +// Un `SmoothMin` entre deux SOURCES différentes est précisément ce qui fait qu'une idée composée +// a l'air d'appartenir au lieu au lieu d'y avoir été percée. Coût : un float. +// +// Two channels, not one — because that is what the code already does, and because SmoothMin between +// two different SOURCES is precisely what makes a composed idea look like it belongs there rather +// than like a hole punched in something else. Cost: one float. +struct FVoxelOpSample +{ + // Convention INTERNE : POSITIF = SOLIDE. Négation vers MC une seule fois, par l'appelant. + // INTERNAL convention: POSITIVE = SOLID. Negated to MC once, by the caller. + float Density = 0.0f; + + // Convention SDF standard : NÉGATIF = à l'intérieur de la primitive. + // FLT_MAX = « aucune surface à proximité » (l'état initial, et le early-out des sources + // placées quand aucune primitive n'atteint ce voxel). + // FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use. + float Sdf = FLT_MAX; + + // Block evaluation keeps the room source's per-sample selection explicit. The scalar path + // still uses the source's worker-local state, but a later block-capable modifier must never + // observe the state belonging to the last sample of the preceding operator. + const FChunkSDFCache* RoomCache = nullptr; + int32 NearestRoomIndex = INDEX_NONE; + + // The common generator tail consumes the continuous swept-shape metadata after the block has + // left the stack. Keeping it in the sample avoids re-running the world-space tunnel scan or + // consulting a last-sample TLS value after an op-major evaluation. + bool bHasTunnelCoreWorldEvaluation = false; + float TunnelCoreWorldSDF = FLT_MAX; + bool bTunnelCoreRoomFloor = false; + bool bHasTunnelCoreSweptFloor = false; + float TunnelCoreSweptFloorZ = -FLT_MAX; + float TunnelCoreSweptFloorRadius = 0.0f; +}; + +/** + * A regular world lattice handed to one operator at a time. + * + * The default implementation is deliberately scalar and therefore source-compatible with custom + * operators. An operator may override EvalBlock only when it preserves the exact per-sample + * semantics of Eval. Samples are laid out X-fastest, then Y, then Z; all coordinates are voxel + * coordinates and are formed as Origin + index * Step using the same integer arithmetic as the + * mesher's scalar path. + */ +struct FVoxelOpBlock +{ + FIntVector OriginVoxels = FIntVector::ZeroValue; + int32 Step = 1; + int32 SizeX = 0; + int32 SizeY = 0; + int32 SizeZ = 0; + FVoxelOpSample* Samples = nullptr; + + int32 NumSamples() const { return SizeX * SizeY * SizeZ; } + + FORCEINLINE FVoxelOpSample& At(int32 X, int32 Y, int32 Z) const + { + return Samples[(Z * SizeY + Y) * SizeX + X]; + } +}; + +/** + * Intervalle conservateur du canal SDF sur une boîte. + * + * `Min` et `Max` sont les bornes SUPRÊMES de toutes les valeurs que l'opérateur peut produire dans + * la boîte. `[FLT_MAX, FLT_MAX]` est l'identité exacte : aucune surface connue. Un intervalle inconnu + * est volontairement large ; il force le fold à rester `Mixed` plutôt que de risquer un faux tile + * uniforme. Les setters refusent toute borne non finie ou inversée : un opérateur qui ne peut pas + * prouver son intervalle perd le skip, jamais la géométrie. + * + * Conservative SDF interval over a box. `Min`/`Max` bound every value the operator can produce. + * `[FLT_MAX, FLT_MAX]` is the exact "no surface" identity. Unknown is deliberately wide: it costs + * a skip, never a hole. Invalid bounds become unknown instead of being guessed. + */ +struct FVoxelBoxSdfInterval +{ + bool bKnown = true; + float Min = FLT_MAX; + float Max = FLT_MAX; + + bool IsKnown() const { return bKnown; } + + void SetUnknown() + { + bKnown = false; + Min = -FLT_MAX; + Max = FLT_MAX; + } + + void Set(float InMin, float InMax) + { + if (!VoxelMath::IsFinite(InMin) || !VoxelMath::IsFinite(InMax) || InMin > InMax) + { + SetUnknown(); + return; + } + bKnown = true; + Min = InMin; + Max = InMax; + } + + /** Output of `min(Input, Other)` when both intervals are known. */ + void MinWith(const FVoxelBoxSdfInterval& Other) + { + if (!bKnown || !Other.bKnown) + { + SetUnknown(); + return; + } + Min = FMath::Min(Min, Other.Min); + Max = FMath::Min(Max, Other.Max); + } +}; + +// Forward declaration: the interval-aware EffectOverBox overloads consume the hypotheses that +// have already been folded, while the hypotheses themselves are declared immediately below the +// interface. +struct FVoxelBoxHypotheses; + +//============================================================================= +// L'INTERFACE / THE INTERFACE +//============================================================================= +// Trois méthodes, et elles FORMALISENT CE QUE LE CODE FAIT DÉJÀ À LA MAIN : chaque archétype +// hisse déjà son travail constant-par-chunk dans un cache thread_local (= PrepareChunk), évalue +// à bas coût par voxel (= Eval), et possède déjà dans ClassifyTile une déclaration écrite à la +// main de ce qu'il peut faire à une tuile (= EffectOverBox). Ce n'est pas une nouvelle +// discipline, c'est la discipline existante, nommée. +class IVoxelDensityOp +{ +public: + virtual ~IVoxelDensityOp() = default; + + virtual EVoxelOpRole GetRole() const = 0; + + /** + * Channel contract for the composer. A read is a field inspected from `InOut` before this op + * publishes its result; a write is a field whose value may differ when the op returns. A + * read/write declaration therefore means "transform the current version". `+=` and `-=` are + * both reads and writes; an assignment that ignores the old field is a write-only producer / + * replacement. Context, coordinates, caches and referenced objects are not sample channels. + * + * These are graph metadata, not voxel work: the stack snapshots them while it is assembled and + * the validator reads the snapshot. They must never be called from `Eval`. + */ + virtual EVoxelOpChannelMask ChannelReads() const = 0; + virtual EVoxelOpChannelMask ChannelWrites() const = 0; + + /** + * Non-sample state required by this op. A declaration is satisfied only by a provider that + * appeared earlier in the stack. Defaults are deliberately empty so existing independent + * operators and external test doubles remain source-compatible. + */ + virtual EVoxelOpResourceMask RequiredResources() const + { + return VoxelOpResources::None; + } + + /** State published for later consumers, e.g. FRoomGraphSource's room geometry cache. */ + virtual EVoxelOpResourceMask ProvidedResources() const + { + return VoxelOpResources::None; + } + + /** + * True when this op contributes a delta to its written channels without replacing, clamping, + * selecting, or otherwise depending on the previous value of those written channels. Such an + * op may be shuffled with another compatible additive delta; a false result means its position + * is explicit. A gate on a channel the op itself writes is transformative even if its final + * arithmetic contains `+=`. + */ + virtual bool IsAdditive() const = 0; + + /** + * Hisser ici TOUT le travail constant sur le chunk : listes de salles, grilles de biome, + * caches de colonnes, cuissons de treillis. Appelé une fois par chunk et par worker. + * C'est ici que déménagent les caches thread_local d'aujourd'hui. + * + * THREADING : appelé sur des workers. L'opérateur ne doit écrire QUE son propre état + * par-chunk ; le Generator / le Mesher / le StrateManager restent en LECTURE SEULE + * (invariant ARCHITECTURE §8.10). Toute clé de cache DOIT inclure Ctx.LayoutVersion. + */ + virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0; + + /** + * Par voxel. `InOut` est l'état que la pile a produit jusqu'ici (voir FVoxelOpSample). + * Coordonnées en VOXELS, pas en cm. + * + * INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (coords monde, seed, layout). + * Le même point évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le + * float BIT-IDENTIQUE. Pas « proche » : 1 ULP d'écart entre deux fenêtres est une couture + * visible, et en multijoueur une divergence de monde. Le test + * VoxelForge.Determinism.DensityPurity vérifie cela. + */ + virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0; + + /** + * Op-major evaluation seam. The conservative default is intentionally not clever: it calls + * the canonical scalar Eval for every sample. This lets the stack and mesher be converted in + * reviewable stages while byte-identical output remains the acceptance test. Stateful ops + * may restore their per-sample hand-off from FVoxelOpSample in PrepareBlockSample; the default + * invokes that hook before each scalar fallback call. + */ + virtual void EvalBlock(const FVoxelOpBlock& Block) const + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + for (int32 Z = 0; Z < Block.SizeZ; ++Z) + { + for (int32 Y = 0; Y < Block.SizeY; ++Y) + { + for (int32 X = 0; X < Block.SizeX; ++X) + { + FVoxelOpSample& Sample = Block.At(X, Y, Z); + PrepareBlockSample(Sample); + Eval( + static_cast(Block.OriginVoxels.X + X * Block.Step), + static_cast(Block.OriginVoxels.Y + Y * Block.Step), + static_cast(Block.OriginVoxels.Z + Z * Block.Step), + Sample); + } + } + } + } + + /** Restore a source-owned per-sample context before a scalar fallback in EvalBlock. */ + virtual void PrepareBlockSample(const FVoxelOpSample&) const {} + + /** Optional hand-off for the common structural tail. The default keeps custom operators + * conservative: the caller uses its canonical cache path when no source publishes a result. */ + virtual bool TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const + { + return false; + } + + /** + * CONSERVATIF. The two-argument method is the intrinsic fallback. The state-aware overload + * below is what the stack calls: it sees the SDF interval already produced by earlier ops. + * Returning Both is always safe; returning the wrong direction is a hole. + * + * Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT + * D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour + * SurfaceWorld — il évalue ComputeSurfaceColumn sur le treillis EXACT du mesher, mêmes + * fonctions, mêmes floats, donc verdict exact plutôt qu'estimé. Cela DOIT survivre au portage. + * + * The contract is "conservative", not "closed-form": an op MAY sample to answer. + * + * An SDF-only source MUST NOT answer for a converter that happens to follow it. It reports its + * own interval through `PropagateSdfOverBox`; the converter's state-aware overload then folds + * the interval through its own formula. This is the isolation rule that makes free composition + * safe. + */ + virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0; + + /** State-aware effect. The default preserves old custom operators' conservative direction. */ + virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses&) const + { + return EffectOverBox(VoxelBox, Ctx); + } + + /** + * ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de + * perf du plan. + * + * La direction seule ne suffit pas pour les opérateurs FIELDÉS. Un carve à seuil de bruit (les + * vers de TunnelNetwork, la rugosité de paroi) n'a AUCUNE borne spatiale : il rend `CarveOnly` + * sur CHAQUE boîte de CHAQUE strate qui l'active, donc il tue l'hypothèse `AllSolid` partout et + * l'archétype ne saute pas une tuile. Aucun raffinement de `EffectOverBox` ne peut le récupérer, + * parce que la réponse « oui, je peux creuser ici » est VRAIE. + * + * **Mais son AMPLITUDE est bornée, et souvent triviale** : pour un ver, `t ∈ [0,1]` et + * `Mask ∈ [0,1]`, donc il ne peut déplacer la densité vers l'air que de `WormStrength` au plus. + * Si le roc est solide d'une marge SUPÉRIEURE à la somme de tous les carves restants, la boîte + * est prouvablement pleine — quel que soit le bruit. + * + * D'où deux nombres, en unités de DENSITÉ (convention interne, positif = solide) : + * • `MaxCarveOverBox` — de combien AU PLUS cet opérateur peut baisser la densité sur la boîte, + * • `MaxFillOverBox` — de combien AU PLUS il peut la monter. + * + * **`FLT_MAX` = « je ne sais pas », et c'est le DÉFAUT.** Un opérateur qui ne redéfinit rien se + * comporte donc EXACTEMENT comme avant ce changement : le pliage retire `FLT_MAX` à la marge, + * elle passe sous zéro, l'hypothèse meurt. Les treize tests d'équivalence et + * `VoxelForge.OpStack.BoxVerdictFold` ne bougent pas d'un verdict. + * + * ⚠️ SENS DE L'ERREUR : SUR-estimer une amplitude coûte du CPU (une tuile maillée pour rien) ; + * SOUS-estimer produit un TROU. Comme partout ailleurs dans ce fichier, en cas de doute rendre + * `FLT_MAX`. Ce n'est pas une borne « raisonnable », c'est une borne PROUVÉE ou rien. + * + * The fold carries NUMBERS, not just directions. A fielded noise carve has no spatial bound but + * its AMPLITUDE is bounded, so "the rock is solid by more than the sum of every remaining carve" + * becomes provable. FLT_MAX means "unknown" and is the default, so every existing op is + * unchanged. Over-estimating costs CPU; under-estimating is a hole. + */ + virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return FLT_MAX; + } + + /** State-aware amplitude. The default is the old, conservative amplitude. */ + virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses&) const + { + return MaxCarveOverBox(VoxelBox, Ctx); + } + + virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return FLT_MAX; + } + + /** State-aware amplitude. The default is the old, conservative amplitude. */ + virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses&) const + { + return MaxFillOverBox(VoxelBox, Ctx); + } + + /** + * Propagate this operator's own SDF result through a box query. Only call this for an operator + * whose cached channel declaration writes `Sdf`. The default is unknown by construction: a new + * SDF writer cannot accidentally make a false uniform verdict merely because it forgot this + * method. Implementations that assign SDF replace the interval; min/accumulating writers use + * `FVoxelBoxSdfInterval::MinWith`. + */ + virtual void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox, + const FVoxelOpContext&) const + { + InOut.SetUnknown(); + } + + /** + * Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien + * la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ? + * + * C'est l'autre moitié du pliage numérique. `MaxCarveOverBox` dit ce qu'on peut RETIRER ; ceci + * dit ce qu'il y avait à retirer. Sans les deux, la soustraction n'a pas de premier terme. + * + * Exemple, et c'est LE cas qui compte : `FConstantFieldSource` pose `Density = BaseDensity` + * partout. Sa marge est donc exactement `BaseDensity`. Un ver à `WormStrength = 0.6` sur un roc + * à `BaseDensity = 1.0` laisse 0.4 de marge ⇒ la boîte reste prouvablement pleine. + * + * **0 = « je ne sais pas », et c'est le DÉFAUT** : la marge tombe à zéro, le premier carve la + * fait passer sous zéro, l'hypothèse meurt — le comportement d'avant, à l'identique. + */ + virtual float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return 0.0f; + } + + /** + * OPÉRATEURS FORÇANTS. Certains opérateurs ne « déplacent » pas la densité d'entrée : ils + * l'ÉCRASENT. La question « dans quelle direction peux-tu bouger ce champ ? » n'a alors pas de + * sens ; la bonne question est « sais-tu prouver que toute cette boîte est d'un seul côté, + * QUELLE QUE SOIT l'entrée ? ». + * + * Deux familles répondent autre chose que Mixed : + * • les SOURCES (rôle 1) — elles posent le champ, donc elles le savent par construction ; + * • les op STRUCTURELS forçants — ApplyBoundarySeal et ApplyXYEdgeSeal — qui, dans leur bande + * prouvée, font `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est + * solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode + * aujourd'hui avec « bande de seal ⇒ bCanAir = false » — et un simple FillOnly ne suffirait + * PAS à le reproduire (voir VF_FoldOp plus bas). + * + * Par défaut Mixed = « je ne sais pas », toujours sûr. Une source à primitives placées (graphe + * de salles, îles, puits) répond en testant ses bornes ; une source heightfield répond en + * échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui. + * + * FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed = + * "I don't know", always safe. The vertical and XY edge seals are the non-source examples, and + * they are the reason this method exists at all rather than being folded into EffectOverBox. + */ + virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + return EVoxelTileClass::Mixed; + } + + /** + * Déclare si Eval dépend de Z. Les opérateurs purs en XY reçoivent le traitement du cache de + * colonnes T1.a de façon GÉNÉRIQUE, au lieu que SurfaceWorld en ait un sur mesure. + * + * ⚠️ Le cache de colonnes est clé sur (boîte XY, StrateKey, Seed) SANS ChunkZ et il est + * partagé sur TOUTE la pile verticale de chunks. Mettre une donnée dépendante de Z dans un + * opérateur qui se déclare XY-pur corrompt silencieusement chaque chunk de la colonne, et + * ValidateDeterminism — qui échantillonne le long d'une frontière en X — ne le verrait pas. + */ + virtual bool IsXYPure() const { return false; } + + /** + * DIAGNOSTIC UNIQUEMENT — le nom que les rapports de test impriment pour cet opérateur. + * + * ⚠️ POURQUOI CETTE MÉTHODE EXISTE. Sans nom par opérateur, un rapport « 0 tuile prouvée sur + * 40 » ne peut dire que « ou bien les tuiles traversent toutes une grotte, ou bien la source + * n'atteint pas sa branche `Identity` » — deux causes, zéro nombre pour les départager, alors + * que la vraie cause peut être un TROISIÈME opérateur (p. ex. un ver qui rend `CarveOnly` + * partout). Avec un nom par opérateur, `ClassifyBoxAttributed` répond « c'est celui-là » au + * lieu de laisser deviner. + * + * N'entre dans AUCUNE clé de cache, dans aucun hash, dans aucune décision de génération : le + * changer ne peut pas changer le monde. Le défaut est volontairement laconique — un opérateur + * sans nom se repère à son index, ce qui suffit à savoir où regarder. + * + * Diagnostic only: the first build of the spatial EffectOverBox came back green with 0 tiles + * proved, and the report could not name which operator was killing the hypothesis. It was a + * third one nobody was looking at. Never part of a cache key or any generation decision. + */ + virtual const TCHAR* DebugName() const { return TEXT("(unnamed op)"); } +}; + +//============================================================================= +// LE PLIAGE : comment la pile devient un verdict de tuile +// THE FOLD: how a stack becomes a tile verdict +//============================================================================= +// C'est l'algorithme générique qui remplace le ClassifyTile écrit à la main, et il REPRODUIT +// EXACTEMENT le comportement d'aujourd'hui — vérifié ligne à ligne contre VoxelGenerator.cpp : +// +// source gap bedrock → ClassifyBox = AllSolid +// source SurfaceWorld → ClassifyBox échantillonne les colonnes sur le treillis exact +// ApplyPassageCarving → CarveOnly (tue AllSolid) ≡ « AnyPassageNearBox ⇒ bCanSolid=false » +// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY +// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant), +// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false » +// ApplyXYEdgeSeal → ClassifyBox = AllSolid DANS sa bande radiale (forçant) ; il gagne +// après les passages, donc la coque extérieure reste fermée. +// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false » +// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false » +// diff layer → Both si des mods touchent la boîte, sinon Identity +// ≡ « HasAnyModInChunkRange ⇒ Mixed » +// +// et le verdict final « exactement une hypothèse survit, sinon Mixed » est littéralement le +// `if (bCanSolid == bCanAir) return Mixed;` de la fin de ClassifyTile. +// +// This fold reproduces today's hand-written ClassifyTile exactly — verified line by line against +// VoxelGenerator.cpp. That correspondence is the evidence that the abstraction fits this codebase +// rather than being imposed on it. + +/** Les deux hypothèses que ClassifyTile poursuit, sous forme d'état pliable. */ +struct FVoxelBoxHypotheses +{ + bool bCanBeAllSolid = true; + bool bCanBeAllAir = true; + + /** + * ⚠️ LES DEUX NOMBRES DU PLIAGE (`OPSTACK-DECOMPOSITION §0.2`). + * `SolidMargin` = de combien la densité est encore garantie AU-DESSUS de zéro partout dans la + * boîte, SOUS l'hypothèse « tout solide ». Un opérateur forçant la pose ; chaque carve en retire + * son amplitude maximale ; quand elle n'est plus strictement positive, l'hypothèse meurt. + * `AirMargin` est son miroir. + * + * **Elles valent 0 sur un état neuf, et c'est ce qui rend le changement rétro-compatible :** + * sans opérateur forçant qui déclare une marge, le premier carve fait `0 − FLT_MAX < 0` et tue + * l'hypothèse — le comportement exact d'avant le pliage numérique. + */ + float SolidMargin = 0.0f; + float AirMargin = 0.0f; + + /** SDF interval after all SDF writers folded so far. It starts at the exact no-surface identity. */ + FVoxelBoxSdfInterval Sdf; + + bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; } + + /** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */ + EVoxelTileClass Resolve() const + { + if (bCanBeAllSolid == bCanBeAllAir) { return EVoxelTileClass::Mixed; } + return bCanBeAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; + } +}; + +/** Poser l'état depuis un verdict FORÇANT (source, ou seal dans sa bande) : l'opérateur écrase + * l'entrée, donc il écrase aussi tout ce que la pile avait conclu avant lui. Un verdict « tout + * air » affirme du même coup « pas tout solide », et réciproquement. */ +/** @param Margin de combien la densité est garantie du bon côté de zéro dans toute la boîte. + * 0 (le défaut) = « je ne sais pas » ⇒ comportement d'avant le pliage numérique. */ +FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass ForcedVerdict, + float Margin = 0.0f) +{ + switch (ForcedVerdict) + { + case EVoxelTileClass::AllSolid: H.bCanBeAllSolid = true; H.bCanBeAllAir = false; + H.SolidMargin = Margin; H.AirMargin = 0.0f; break; + case EVoxelTileClass::AllAir: H.bCanBeAllSolid = false; H.bCanBeAllAir = true; + H.SolidMargin = 0.0f; H.AirMargin = Margin; break; + case EVoxelTileClass::Mixed: + default: H.bCanBeAllSolid = false; H.bCanBeAllAir = false; + H.SolidMargin = 0.0f; H.AirMargin = 0.0f; break; + } +} + +/** + * Plier l'effet d'un opérateur dans l'état. **Monotone : ne fait que tuer**, jamais ressusciter — + * c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que + * DESCENDRE, jamais remonter, en dehors d'un opérateur forçant. + * + * ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». Une borne finie + * nulle signifie au contraire « identité prouvée » pour cette direction : l'opérateur peut être + * géométriquement présent mais ne modifie aucun échantillon de la boîte. La soustraction + * fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la + * version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict. + * (Arithmétique volontairement laissée en float sans garde : `0 − FLT_MAX` vaut `−FLT_MAX`, + * `−FLT_MAX − FLT_MAX` sature à `−inf`, et `−inf > 0` est faux. Pas de NaN possible, les deux + * termes étant de même signe.) + */ +FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect, + float MaxCarve = FLT_MAX, float MaxFill = FLT_MAX) +{ + switch (Effect) + { + case EVoxelOpEffect::Identity: + break; + + case EVoxelOpEffect::CarveOnly: + // A finite zero bound is a proved identity for this direction. This matters for + // state-aware structural guards: a passage may be geometrically present in a box while + // its exact lattice carve factor is zero, and that must not kill an otherwise valid + // AllSolid hypothesis merely because its margin is zero/unknown. + if (MaxCarve == 0.0f) + { + break; + } + if (!(MaxCarve >= 0.0f)) + { + H.bCanBeAllSolid = false; + H.SolidMargin = -FLT_MAX; + break; + } + H.SolidMargin -= MaxCarve; + if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } + break; + + case EVoxelOpEffect::FillOnly: + if (MaxFill == 0.0f) + { + break; + } + if (!(MaxFill >= 0.0f)) + { + H.bCanBeAllAir = false; + H.AirMargin = -FLT_MAX; + break; + } + H.AirMargin -= MaxFill; + if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } + break; + + case EVoxelOpEffect::Both: + default: + if (MaxCarve != 0.0f) + { + if (!(MaxCarve >= 0.0f)) + { + H.bCanBeAllSolid = false; + H.SolidMargin = -FLT_MAX; + } + else + { + H.SolidMargin -= MaxCarve; + if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } + } + } + if (MaxFill != 0.0f) + { + if (!(MaxFill >= 0.0f)) + { + H.bCanBeAllAir = false; + H.AirMargin = -FLT_MAX; + } + else + { + H.AirMargin -= MaxFill; + if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } + } + } + break; + } +} + +/** + * Plier UN opérateur. L'ORDRE COMPTE ICI, et c'est délibéré : un opérateur forçant écrase ce que + * la pile avait conclu AVANT lui, tandis que les opérateurs qui suivent continuent de s'appliquer. + * + * Exemple à garder en tête, parce qu'il est le piège : sur une boîte entièrement dans la bande de + * seal, la source dit peut-être « tout air » (on est au-dessus du terrain), puis le seal FORCE + * « tout solide » — verdict AllSolid, comme aujourd'hui. Si le seal ne savait dire que FillOnly, + * on obtiendrait « les deux hypothèses mortes ⇒ Mixed » : pas un trou, mais la perte pure et + * simple d'une des tuiles triviales que T1.d sait sauter. C'est pour cela que ClassifyBox existe. + * + * Réciproquement, un passage qui traverse cette même boîte rend CarveOnly APRÈS le seal et retue + * l'hypothèse solide ⇒ Mixed. Identique au code actuel, où la garde passage et la garde seal se + * neutralisent en `bCanSolid == bCanAir`. + * + * ORDER MATTERS HERE, deliberately: a forcing op overwrites what the stack concluded before it, + * while ops after it still apply. This is what lets the seal recover an AllSolid verdict that a + * pure FillOnly would have thrown away, while still letting a passage take it back. + */ +FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op, + const FBox& VoxelBox, const FVoxelOpContext& Ctx, + bool bWritesSdf) +{ + // Soundness bias: an unknown SDF interval is allowed to kill a skip, never to manufacture a + // uniform tile. The interval is propagated only after this op's own effect has been folded, so + // the next op sees exactly the SDF version that Eval would expose at this point in the stack. + const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx); + if (Forced != EVoxelTileClass::Mixed) + { + VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx)); + if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } + return; + } + VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx, H), + Op.MaxCarveOverBox(VoxelBox, Ctx, H), + Op.MaxFillOverBox(VoxelBox, Ctx, H)); + if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } +} + +/** Backward-compatible helper for unit tests and callers with no cached declaration metadata. */ +FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op, + const FBox& VoxelBox, const FVoxelOpContext& Ctx) +{ + VF_FoldOp(H, Op, VoxelBox, Ctx, + (Op.ChannelWrites() & VoxelOpChannels::Sdf) != 0); +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Standalone/Source/VoxelForge/Public/VoxelDensityOpStack.h new file mode 100644 index 0000000..73cc481 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -0,0 +1,730 @@ +// VoxelDensityOpStack.h +// La PILE : un conteneur ordonné d'opérateurs, plus les fabriques d'opérateurs concrets. +// The STACK: an ordered container of operators, plus the concrete-operator factories. +// +// ⚠️ CECI EST LE CHEMIN DE GÉNÉRATION DU JEU. +// `UVoxelGenerator::GetDensityAt` construit la pile par chunk pour chaque strate : +// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, +// Underwater (8 sur 8)**. `ClassifyTile` construit la même pile pour les archétypes de cave et plie +// `ClassifyBox`; les gaps et SurfaceWorld gardent leurs preuves exactes dédiées. +// +// THIS IS THE GAME'S GENERATION PATH. GetDensityAt builds the stack per chunk for every strate. +// ClassifyTile uses the same stack for cave archetypes; gaps and SurfaceWorld retain their exact +// hand-written proofs. +// +// POURQUOI CETTE FORME / WHY THIS SHAPE +// La question à laquelle cette forme répond n'est pas « est-ce que ça marche ? » mais +// **« est-ce que la séparation source / modifier tombe naturellement du code existant ? »** +// (OPSTACK-PLAN §4). Chaque archétype a été porté puis comparé bit à bit à sa fonction +// d'origine avant qu'elle ne soit retirée : la réponse a été MESURÉE plutôt qu'une opinion. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelDensityOp.h" +#include "VoxelDensityProfile.h" +#include "VoxelStrateTypes.h" // FMazeGenerationParams +#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one + +class UVoxelGenerator; +class UVoxelStrateManager; +struct FVoxelOpStackRecipe; +struct FVoxelStrateArchetypeParams; +struct FVoxelStrateRegionManifest; +enum class EVoxelStrateOpClass : uint8; + +/** The declarations the offline structure roller reads before it ever materialises an op. */ +struct VOXELFORGE_API FVoxelStrateOpContract +{ + EVoxelOpRole Role = EVoxelOpRole::DetailModifier; + EVoxelOpChannelMask Reads = VoxelOpChannels::None; + EVoxelOpChannelMask Writes = VoxelOpChannels::None; + bool bAdditive = false; + EVoxelOpResourceMask RequiredResources = VoxelOpResources::None; + EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None; +}; + +/** + * A lowered evaluator selected when a stack has a proven, canonical implementation. + * + * The authored operator list remains present for classification, diagnostics, tests, and the + * fallback evaluator. This is a plan choice made while the stack is assembled, not a runtime + * CPU-feature dispatch and not a second semantic graph. + */ +enum class EVoxelOpFusedEvaluator : uint8 +{ + None, + TunnelNetwork, +}; + +/** + * FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte. + * + * PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et + * l'ordre spine → seal → passage → seal XY est garanti par cette fonction, pas par l'auteur. Un auteur ne + * peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester + * possible, les seals doivent tenir, les passages doivent percer). + * + * PROPRIÉTÉ (threading) : la pile est LUE par les workers. Les opérateurs concrets qui ont besoin + * d'un cache par cellule/chunk le tiennent en `thread_local` à l'intérieur de leur `Eval`, comme le + * fait déjà chaque fonction d'archétype. En Phase 3, quand les opérateurs deviendront des assets + * partagés, il faudra un objet d'état PAR WORKER — noté ici pour que ça ne surprenne personne. + * + * THREADING: the stack is READ by workers. Concrete ops that need a per-cell/per-chunk cache keep it + * thread_local inside Eval, exactly as every archetype function already does. Phase 3 (ops as shared + * assets) will need a per-worker state object — flagged here so it is not a surprise. + */ +class FVoxelOpStack +{ +private: + /** Metadata is snapshotted when an op enters the stack so no declaration virtual is ever + * dispatched from `EvalSample`'s per-voxel loop. */ + struct FOpEntry + { + TUniquePtr Op; + EVoxelOpChannelMask Reads = VoxelOpChannels::None; + EVoxelOpChannelMask Writes = VoxelOpChannels::None; + bool bAdditive = false; + EVoxelOpResourceMask RequiredResources = VoxelOpResources::None; + EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None; + const TCHAR* ProfileName = nullptr; + VoxelDensityProfile::EBucket ProfileBucket = VoxelDensityProfile::EBucket::OtherOp; + }; + +public: + // DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur : + // une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs + // polymorphes, ce qui n'a pas de sens ici (une pile n'existe qu'une fois par strate). + // + // ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur + // une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur + // d'affectation par copie — impossible à générer pour un `TArray`, d'où + // l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne. + // + // MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack + // uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC, + // dllexport forces instantiation of every implicit member including copy-assignment, which + // cannot be generated for a TArray (error C2280). Export the out-of-line + // method instead. + FVoxelOpStack() = default; + FVoxelOpStack(FVoxelOpStack&&) = default; + FVoxelOpStack& operator=(FVoxelOpStack&&) = default; + FVoxelOpStack(const FVoxelOpStack&) = delete; + FVoxelOpStack& operator=(const FVoxelOpStack&) = delete; + + void Add(TUniquePtr Op) + { + // Adding an op invalidates any previously selected lowered plan. Factories mark the plan + // only after the complete authored graph has been assembled. + FusedEvaluator = EVoxelOpFusedEvaluator::None; + FOpEntry Entry; + Entry.Op = MoveTemp(Op); + if (Entry.Op.Get() != nullptr) + { + // Assembly-time metadata only. ValidateChannelOrder reuses this snapshot, and Eval + // never asks an operator for its declaration. + Entry.Reads = Entry.Op->ChannelReads(); + Entry.Writes = Entry.Op->ChannelWrites(); + Entry.bAdditive = Entry.Op->IsAdditive(); + Entry.RequiredResources = Entry.Op->RequiredResources(); + Entry.ProvidedResources = Entry.Op->ProvidedResources(); + Entry.ProfileName = Entry.Op->DebugName(); + Entry.ProfileBucket = VoxelDensityProfile::BucketFromName(Entry.ProfileName); + } + Ops.Add(MoveTemp(Entry)); + } + + int32 Num() const { return Ops.Num(); } + + /** Select the lowered evaluator for this complete authored graph. */ + void SetFusedEvaluator(EVoxelOpFusedEvaluator InEvaluator) + { + FusedEvaluator = InEvaluator; + } + + /** The lowered plan, or None when the general interpreted/block path must be used. */ + EVoxelOpFusedEvaluator GetFusedEvaluator() const + { + return FusedEvaluator; + } + + /** True when this stack publishes the requested immutable geometry resource. */ + bool ProvidesResource(EVoxelOpResourceMask Resource) const + { + if (Resource == VoxelOpResources::None) + { + return false; + } + for (const FOpEntry& Entry : Ops) + { + if ((Entry.ProvidedResources & Resource) == Resource) + { + return true; + } + } + return false; + } + + // Allocator-backed bytes owned by the stack, including each concrete operator allocation. + // The enclosing cache entry adds sizeof(FVoxelOpStack) separately. + SIZE_T GetAllocatedSize() const; + + /** Hoist chunk-constant work for every op. Une fois par chunk et par worker. + * Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un + * mensonge utile qui finirait par masquer une course. */ + void PrepareChunk(const FVoxelOpContext& Ctx) + { + PreparedContext = Ctx; + bHasPreparedContext = true; + for (const FOpEntry& Entry : Ops) { Entry.Op->PrepareChunk(Ctx); } + } + + /** + * Évalue la pile complète en un point. Rend la densité en convention INTERNE + * (positif = solide) — l'appelant négate UNE FOIS pour le marching cubes. + * + * Returns INTERNAL-convention density (positive = solid). The caller negates once for MC. + */ + float EvalInternal(float WorldX, float WorldY, float WorldZ) const + { + return EvalSample(WorldX, WorldY, WorldZ).Density; + } + + /** L'état COMPLET (densité + SDF) après toute la pile. Diagnostic : quand deux évaluations + * divergent, c'est le canal SDF qui dit si l'écart naît avant ou après la conversion. + * / The full state after the stack — the SDF channel is what says whether a divergence is + * born before or after the carve. */ + FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const + { + FVoxelOpSample S; + // Fine operation timers are a diagnostic mode. Keeping their construction out of the + // ordinary scalar path matters for callers that still use fractional samples or an + // archetype without a block session; the block path already times one scope per op/block. + const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; + for (const FOpEntry& Entry : Ops) + { + if (bProfileOps) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket); + Entry.Op->Eval(WorldX, WorldY, WorldZ, S); + } + else + { + Entry.Op->Eval(WorldX, WorldY, WorldZ, S); + } + } + + // The room-graph source publishes the authored floor ownership while evaluating the + // shape, but it must not be reasserted here. This stack is followed by the common + // disturbance/passage writers; composing the floor at this point would make every authored + // floor sample look like a post-hoc clamp (and counted 100% of them). The generator + // consumes the same immutable core result after those writers and composes the floor once, + // at the final MC boundary. The metadata remains on the sample for that hand-off and for + // diagnostics, but it is not allowed to modify density inside the operator stack. + return S; + } + + /** + * Evaluate one regular lattice in operator order. The caller owns and initializes the sample + * array (normally to FVoxelOpSample defaults); each operator transforms that same array before + * the next operator runs. The timer is intentionally one scope per operator/block rather than + * one scope per operator/sample. Operators without a block implementation use the conservative + * scalar fallback supplied by IVoxelDensityOp, so this entry point is safe to introduce before + * converting the concrete operators. + */ + void EvalBlock(const FVoxelOpBlock& Block) const + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; + for (const FOpEntry& Entry : Ops) + { + if (bProfileOps) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket); + Entry.Op->EvalBlock(Block); + } + else + { + Entry.Op->EvalBlock(Block); + } + } + } + + /** Evaluate only the entries that the interval fold proved non-identity for this block. */ + void EvalBlock(const FVoxelOpBlock& Block, const TArray& ActiveOps) const + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; + for (const int32 OpIndex : ActiveOps) + { + if (!Ops.IsValidIndex(OpIndex)) { continue; } + const FOpEntry& Entry = Ops[OpIndex]; + if (bProfileOps) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket); + Entry.Op->EvalBlock(Block); + } + else + { + Entry.Op->EvalBlock(Block); + } + } + } + + /** + * Build a conservative active list for one exact mesher lattice block. An operator may be + * omitted only when its state-aware box contract says Identity and it does not publish SDF. + * SDF writers remain in the list even when their density effect is Identity: their SDF is a + * channel consumed by a later converter, and an unchanged-looking interval is not by itself a + * pointwise proof. Unknown costs the operator visit, never the geometry. + */ + int32 BuildActiveOpList(const FBox& VoxelBox, int32 InStep, + FIntVector InLatticeOrigin, TArray& OutActiveOps) const + { + OutActiveOps.Reset(); + if (!bHasPreparedContext) + { + for (int32 Index = 0; Index < Ops.Num(); ++Index) { OutActiveOps.Add(Index); } + return OutActiveOps.Num(); + } + + FVoxelOpContext Ctx = PreparedContext; + Ctx.Step = FMath::Max(InStep, 1); + Ctx.LatticeOriginVoxels = InLatticeOrigin; + Ctx.bUseLatticeProof = true; + + FVoxelBoxHypotheses H; + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + const bool bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0; + const bool bPublishesResource = Entry.ProvidedResources != VoxelOpResources::None; + const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx); + bool bCanSkip = false; + + if (Forced != EVoxelTileClass::Mixed) + { + VF_ForceHypotheses(H, Forced, Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx)); + if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } + } + else + { + const EVoxelOpEffect Effect = + Entry.Op->EffectOverBox(VoxelBox, Ctx, H); + bCanSkip = Effect == EVoxelOpEffect::Identity + && !bWritesSdf + && !bPublishesResource; + VF_FoldEffect(H, + Effect, + Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H), + Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H)); + if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } + } + + if (!bCanSkip) { OutActiveOps.Add(Index); } + } + return OutActiveOps.Num(); + } + + /** Le même, négaté pour le mesher (négatif = solide). */ + float EvalMC(float WorldX, float WorldY, float WorldZ) const + { + return -EvalInternal(WorldX, WorldY, WorldZ); + } + + /** Return a same-sample structural-core result published by a source in this stack. */ + bool TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const; + + /** + * Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile. + * Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un + * opérateur FORÇANT (les seals dans leurs bandes) écrase ce que la pile avait conclu avant lui. + */ + EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const + { + FVoxelBoxHypotheses H; + for (const FOpEntry& Entry : Ops) + { + VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx, + (Entry.Writes & VoxelOpChannels::Sdf) != 0); + // Do not early-out on a dead hypothesis: a later forcing structural post may + // deliberately overwrite it (the XY edge seal is appended after passage tube/landing + // carving and support). + } + return H.Resolve(); + } + + /** + * LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests. + * + * ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même ordre, + * y compris le fait de laisser un post forçant ultérieur ressusciter une hypothèse morte. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique + * est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change, + * l'autre change avec lui. + * + * `OutSolidKiller` / `OutAirKiller` reçoivent l'INDEX du premier opérateur qui fait passer + * l'hypothèse correspondante de vraie à fausse, ou `INDEX_NONE` si elle a survécu. Le nom + * lisible s'obtient par `GetOpDebugName(index)`. + * + * Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to + * ClassifyBox, including later forcing posts that may overwrite a dead hypothesis — a + * diagnostic that takes a slightly different path sends you hunting in the wrong place. + */ + EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + int32& OutSolidKiller, int32& OutAirKiller) const + { + OutSolidKiller = INDEX_NONE; + OutAirKiller = INDEX_NONE; + + FVoxelBoxHypotheses H; + for (int32 i = 0; i < Ops.Num(); ++i) + { + const bool bSolidBefore = H.bCanBeAllSolid; + const bool bAirBefore = H.bCanBeAllAir; + + VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx, + (Ops[i].Writes & VoxelOpChannels::Sdf) != 0); + + if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; } + if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; } + + } + return H.Resolve(); + } + + /** + * Per-operator box-bound audit. This deliberately replays the same fold as ClassifyBox while + * also brute-forcing Eval on the queried lattice. It is report/test infrastructure only: it + * must never be used to make a generation decision. + */ + struct FOpBoxDiagnostic + { + int32 Index = INDEX_NONE; + FString Name; + EVoxelOpEffect Effect = EVoxelOpEffect::Identity; + EVoxelTileClass ForcedVerdict = EVoxelTileClass::Mixed; + bool bForced = false; + bool bWritesSdf = false; + float ForcedMargin = 0.0f; + float MaxCarve = 0.0f; + float MaxFill = 0.0f; + bool bHasDensityDelta = false; + float ActualDensityDeltaMin = FLT_MAX; + float ActualDensityDeltaMax = -FLT_MAX; + bool bHasSdfValue = false; + float ActualSdfMin = FLT_MAX; + float ActualSdfMax = -FLT_MAX; + bool bHasSdfBound = false; + float SdfBoundMin = FLT_MAX; + float SdfBoundMax = FLT_MAX; + bool bCarveBoundViolated = false; + bool bFillBoundViolated = false; + uint64 SampleCount = 0; + }; + + VOXELFORGE_API void DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + int32 SampleStep, TArray& OutDiagnostics, + EVoxelTileClass* OutVerdict = nullptr) const; + + /** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */ + const TCHAR* GetOpDebugName(int32 Index) const + { + return Ops.IsValidIndex(Index) ? Ops[Index].Op->DebugName() : TEXT("(none)"); + } + + /** + * Validate the stack's channel dependency DAG without evaluating a voxel. + * + * Rule: each channel has a current version. A channel is unavailable until an earlier op + * writes it. The one deliberate exception is a root `FieldSource` that reads and writes the + * same channel as an identity fold (for example `min(FLT_MAX, Sdf)`); it may consume that + * channel's `FVoxelOpSample` initial identity while publishing the first real version. An op + * may read only the version visible on entry; a read/write op consumes that version and + * publishes the next one. A write-only op is a replacement/producer, so it is legal only as + * the first producer of that channel; allowing a second write-only producer would silently + * clobber the earlier field. An additive op must read every channel it writes, because an + * additive delta cannot introduce or replace a channel. All producer-to-consumer edges + * therefore point forward in the list, and the list is a legal topological ordering of the + * channel DAG. A later read deliberately sees the writer's post-op version; a consumer that + * needs a pre-write value must be placed before that writer. The declarations do not invent + * dependencies from names, roles, or Eval side effects. + * + * This is assembly/diagnostic work only. It must not be called from Eval or any voxel loop. + * Returns false and optionally describes the first violation. + */ + VOXELFORGE_API bool ValidateChannelOrder(FString* OutError = nullptr) const; + + /** + * RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile. + * spine (0,0) → seal de frontière → carve de passage → [posts spécifiques] → seal de limite XY. + * + * ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la + * négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront + * portées et que la question de convention MC-vs-interne sera tranchée pour de bon + * (OPSTACK-DECOMPOSITION §10.2). Tant que la pile n'alimente pas le jeu, c'est sans effet. + * + * The diff layer is NOT here: it lives in GetDensityAt, AFTER the MC negate, with disturbances. + * It joins the stack when disturbances are ported. Harmless while the stack feeds nothing. + * + * @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel). + * Le seal XY reste toujours présent ; son rayon/thickness viennent de Ctx + * au moment de PrepareChunk (0 = monde non borné). + */ + VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, + float SealThickness, float BaseDensity, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendEdgeSeal = true); + +private: + TArray Ops; + FVoxelOpContext PreparedContext; + bool bHasPreparedContext = false; + EVoxelOpFusedEvaluator FusedEvaluator = EVoxelOpFusedEvaluator::None; +}; + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelDensityOps +{ + /** Query the concrete op declaration used by the offline recipe roller. */ + VOXELFORGE_API bool GetStrateOpContract(EVoxelStrateOpClass OpClass, + FVoxelStrateOpContract& OutContract); + + /** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit. + * Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */ + VOXELFORGE_API TUniquePtr MakeConstantRockSource(float BaseDensity); + + /** Rôle 1 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert. + * `ClassifyBox` → **AllAir**, ce qu'aucune source n'avait encore su rendre — c'est ce qui rend + * une strate d'îles flottantes (surtout vide) sautable là où aucune île n'arrive. Racine de + * FloatingIslands. */ + VOXELFORGE_API TUniquePtr MakeConstantVoidSource(float BaseDensity); + + /** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D. + * Écrit le canal SDF uniquement. Chaque nœud hors origine choisit un parent vers l'origine; + * les arêtes canoniques (nœud inférieur + axe) testent localement les deux parents. La source + * met en cache les décisions de la fenêtre {-1,0}³ par cellule; les chunks adjacents voient + * donc exactement le même graphe, sans collect global ni couture. */ + /** The source answers only for its own SDF field. Its box query publishes an SDF interval; + * each later converter or modifier consumes that interval through the generic fold. It does + * not know, and must not answer for, any downstream operator. */ + VOXELFORGE_API TUniquePtr MakeLatticeCorridorSource(const FMazeGenerationParams& P, + int32 Seed); + + /** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands). + * `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est + * un gain d'authoring gratuit, et §2.6 autorise explicitement le re-tune. */ + VOXELFORGE_API TUniquePtr MakeSdfRoughnessMod(float Strength, float Frequency, + int32 BaseOctaves, float ApplyWithin); + + /** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur. + * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. + * @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original + * écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où + * `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`, + * donc ce paramètre est une vraie différence, pas une précaution. */ + VOXELFORGE_API TUniquePtr MakeSdfCarve(float Blend, float BaseDensity, + float MinDivisor = 0.0f); + + /** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur. + * C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1 + * étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */ + VOXELFORGE_API TUniquePtr MakeSdfFill(float Blend, float BaseDensity); + + /** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis + * OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un + * `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z + * bornées par le supremum prouvé de FBM (1.5, not the nominal parameter label). Sert + * FlatPlain **et** CrystalChamber. */ + VOXELFORGE_API TUniquePtr MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed); + + /** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒ + * `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */ + VOXELFORGE_API TUniquePtr MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed); + + /** + * SurfaceWorld, COMPLET : colonne (sol + voûte) → densité, overhang 3D, post structurel, et le + * mélange de biomes quand `PerBiomeParams` est non vide. + * + * @param PerBiomeParams vide ⇒ pas de biomes, chemin d'origine strictement inchangé. Non vide + * ⇒ une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte + * sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`). + * @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes + * indices que `PerBiomeParams`. `nullptr` avec des params non vides ⇒ + * biome 0 partout (dégradation sûre, pas un crash). + */ + VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + const TArray& PerBiomeParams = + TArray(), + TUniquePtr BiomeField = nullptr, + bool bAppendStructuralPosts = true); + + /** + * FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** : + * SlabVoidSource → GridColumnMod → [structural post ×4] + * + * Deux des huit archétypes tiennent dans un opérateur (OPSTACK-PLAN §4) : leur différence est + * un jeu de valeurs par défaut, pas du code. + */ + VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); + + /** + * VerticalShafts — 11 ops, dont les connecteurs air/sol et les quatre structural posts : + * ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge + * → ShaftConnectorAir → ShaftConnectorFloor → [structural post ×4] + * + * Trois de ces ops sont ceux de Maze (`§2.5`), avec une source différente et d'autres réglages + * (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`). + */ + VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); + + /** + * TunnelNetwork — **COMPLET** : + * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b) + * → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d) + * → Dome(4g) → Pinch(4h) → FloorBias → Worms → [spine, boundary, passage, graph-air, XY seal] + * + * L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie + * `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et + * ONZE des douze modificateurs y lisent leurs champs. La rugosité (4b) lit les params de la + * STRATE, parce que dans l'original elle précède la déclaration du shadow. + * + * ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit + * pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE + * §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est + * d'avoir UNE définition de chaque idée. + */ + VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, + const FStrateGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); + + /** + * DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle + * de `FRoomGraphSource` évaluée sur ce thread. **Tests uniquement. N'entre dans aucune décision + * de génération.** + * + * ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut + * pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une + * DEUXIÈME définition du critère, qui dériverait de la vraie et mentirait exactement le jour où + * on la croirait. C'est la même raison qui a fait exister `VF_BuildOpStackForChunk`. On expose + * donc ce que l'opérateur a réellement calculé. + * + * `Hit*` = combien de primitives de cette classe atteignent la boîte (0 partout ⇒ `Identity`). + * `Num*` = combien le cache en contenait, ce qui distingue « aucune n'atteint » de « il n'y en + * avait aucune » — deux zéros de sens opposé. + * + * Reads back what the operator actually computed, rather than letting the test re-derive the + * criterion: a second copy would drift and would lie on the day it was believed. + */ + struct FRoomBoxDiagnostic + { + int32 HitRooms = 0, HitTunnels = 0, HitPits = 0, HitChimneys = 0; + int32 NumRooms = 0, NumTunnels = 0, NumPits = 0, NumChimneys = 0; + + /** Les mêmes comptes si la dilatation de warp valait ZÉRO, et de combien de voxels la boîte + * est effectivement dilatée. `Hit* - Hit*NoWarp` = la part du blocage due à MA boîte plutôt + * qu'à la géométrie. Cette mesure manquait, et son absence a coûté trois builds de + * resserrement autour du mauvais terme. */ + int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0; + float WarpDilation = 0.0f; + + /** Conservative candidate for the world-space tunnel-core post that runs after EvalMC. + * This is derived from the final post's own world-chain bounds, not from the warped SDF + * source's broader threshold hit count. */ + bool bMayHaveTunnelCoreAir = false; + bool bMayHaveTunnelSupportFloor = false; + bool bMayHaveTunnelCoreTail = false; + uint32 ExactTailQueries = 0; + uint32 ExactTailEvaluated = 0; + uint64 ExactTailCycles = 0; + uint64 PropagateCycles = 0; + uint64 ExactPrimitiveCycles = 0; + uint64 CacheWindowCycles = 0; + int32 NumRoomFloorJoins = 0; + }; + + VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic(); + + /** Current worker-local room-graph cache footprint; profiling/diagnostic use only. */ + VOXELFORGE_API void ReportWorkerRoomGraphCacheFootprint(); + + /** Current process-wide immutable room-graph proof-cache footprint; diagnostic use only. */ + VOXELFORGE_API void ReportSharedRoomGraphCacheFootprint(); + + /** Drop completed proof caches from older manager lifetimes, or all of them when zero. */ + VOXELFORGE_API void TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId = 0); + + /** + * FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** : + * ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4] + * + * Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et + * REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et + * `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans + * une ligne de changement (4ᵉ archétype). Seul le blob d'île est nouveau. + * + * The stack that runs backwards: void source + fill instead of rock source + carve, using the + * SAME operators with the opposite sign. + */ + VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); + + /** + * La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique : + * ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post] + * + * C'est le test de la Phase 1 : si Maze ne se décompose pas ainsi, l'abstraction est mauvaise + * pour ce domaine (OPSTACK-PLAN §4, déclencheur d'arrêt). + */ + VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + bool bAppendStructuralPosts = true); +} + +/** Materialise an offline recipe. Structural posts are appended by default; lateral region cores + * deliberately pass false so the parent stack can append the global posts exactly once. */ +VOXELFORGE_API bool VF_BuildStackFromRecipe(const FVoxelOpStackRecipe& Recipe, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr, + bool bAppendStructuralPosts = true); + +/** Build the region combiner stack. Creative cores are independent; structural posts are global. */ +#if WITH_EDITOR +VOXELFORGE_API bool VF_BuildStrateRegionStack( + const FVoxelStrateRegionManifest& Manifest, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr); +#endif diff --git a/Standalone/Source/VoxelForge/Public/VoxelDensityPrimitives.h b/Standalone/Source/VoxelForge/Public/VoxelDensityPrimitives.h new file mode 100644 index 0000000..7fb17f4 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelDensityPrimitives.h @@ -0,0 +1,382 @@ +// VoxelDensityPrimitives.h +// Les quatre post-traitements STRUCTURELS partagés par chaque archétype. +// The four STRUCTURAL post-processes every archetype shares. +// +// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS +// Les trois primitives verticales étaient `static` dans VoxelGenerator.cpp et appelées à l'identique +// par les six fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : +// UNE copie partagée. Le scellement XY est ajouté dans ce même fichier pour que les deux chemins +// partagent aussi la nouvelle invariance — les invariants de monde ne sont pas des choix créatifs. +// +// The three vertical primitives were `static` in VoxelGenerator.cpp and called identically by all six +// density functions. The operator stack needs the same ones, so they move here: ONE shared copy. The +// XY seal is added beside them so both paths share the new invariant too. +// +// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin. +// Ces primitives travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**. +// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la +// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`. +// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC +// convention happens ONCE, at the caller's return. +// +// Les trois corps déplacés restent identiques; le quatrième est la nouvelle frontière globale. +// The three moved bodies remain identical; the fourth is the new global boundary. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" // SmoothStep01 +#include "VoxelPassageGeometry.h" // shared body-sized landing/tunnel geometry +#include "VoxelDensityAblation.h" // development-only world-changing measurements +#include "VoxelTilePostReach.h" // worker-local landing/structural post reach bits + +//============================================================================= +// SEAL DE FRONTIÈRE / BOUNDARY SEAL +//============================================================================= +// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur `Thickness` voxels depuis +// chaque bord. N'AJOUTE que de la densité (FMath::Max), jamais n'en enlève → le joueur ne peut +// jamais percer le seal "par accident", seulement via les passages. +// +// ⚠️ C'est un opérateur FORÇANT, pas seulement un FillOnly : à l'intérieur de la bande, avec +// SealFactor > 0 et BaseDensity > 0, le résultat est solide GARANTI quelle qu'ait été l'entrée. +// C'est exactement ce qu'encode `IVoxelDensityOp::ClassifyBox` (voir VoxelDensityOp.h), et la +// raison pour laquelle cette méthode existe. +FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ, + float StrateTopZ, float StrateBottomZ, + float Thickness, float BaseDensity) +{ + if (VoxelDensityAblation::IsBoundarySealOff() || Thickness <= 0.0f) return; + + const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond + const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol + + if (DistTop >= 0.0f && DistTop < Thickness) + { + float SealFactor = 1.0f - (DistTop / Thickness); + SealFactor = SmoothStep01(SealFactor); + Density = FMath::Max(Density, SealFactor * BaseDensity); + } + if (DistBot >= 0.0f && DistBot < Thickness) + { + float SealFactor = 1.0f - (DistBot / Thickness); + SealFactor = SmoothStep01(SealFactor); + Density = FMath::Max(Density, SealFactor * BaseDensity); + } +} + +//============================================================================= +// CARVE DE PASSAGE / PASSAGE CARVING +//============================================================================= +// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages puissent percer à +// travers le bouchon solide. Le rayon de blend est hard-codé à 4.0f — à exposer via +// UVoxelSettings si on veut pouvoir le tweaker. +FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF, + float BaseDensity, float SealThickness) +{ + if (VoxelDensityAblation::IsPassageCarvingOff()) return; + constexpr float PASSAGE_BLEND_RADIUS = 4.0f; + if (ModSDF >= PASSAGE_BLEND_RADIUS) return; + + float CarveFactor = FMath::Clamp( + (PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + + // FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in + // its path (seals, columns, surface roughness, terrain ops). A plain subtraction can + // be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is + // why the shaft "didn't go all the way through". Lerp toward a strongly negative target + // and take the min so we only ever make it MORE air (never refill an existing cave). + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor)); +} + +// Landing air is also reasserted after the MC-space disturbance post. Unlike the legacy tube +// carve above, this threshold is independent of the incoming density: applying it a second time +// is therefore bit-stable. The structural passage op and the post-disturbance backstop can share +// the same operation without turning an otherwise unchanged live-vs-direct op-stack comparison +// into a second, deeper smooth carve. The tube intentionally retains its old current-density +// interpolation because that is part of the legacy structural path's contract. +FORCEINLINE void VF_ApplyPassageLandingCarving(float& Density, float LandingSDF, + float BaseDensity, float SealThickness) +{ + constexpr float PASSAGE_BLEND_RADIUS = 4.0f; + if (LandingSDF >= PASSAGE_BLEND_RADIUS) return; + + float CarveFactor = FMath::Clamp( + (PASSAGE_BLEND_RADIUS - LandingSDF) / (PASSAGE_BLEND_RADIUS * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + const float LandingThreshold = FMath::Lerp(BaseDensity, AirTarget, CarveFactor); + Density = FMath::Min(Density, LandingThreshold); +} + +//============================================================================= +// LANDING ROOM ORIGINE (0,0) / ORIGIN LANDING ROOM +//============================================================================= +// The old implementation carved a full-height cylinder. That made the origin visually open but +// left no floor anywhere except at the bottom, so it was a fall shaft rather than a landing. The +// origin primitive is now a top-anchored rounded room with a real flat floor in every strate. +// It never crosses either vertical seal. The inter-strate seal remains solid until a passage (or +// the player) opens it; there is no default vertical bore between rooms. +FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (VoxelDensityAblation::IsOriginSpineOff() || Radius <= 0.0f) return; + + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, Radius); + if (!Geometry.bValid) return; + + const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF( + FVector(WorldX, WorldY, WorldZ), Geometry); + VF_ApplyPassageLandingCarving( + Density, RoomSDF, BaseDensity, SealThickness); + + // The floor is intentionally written after the room carve. It is also reasserted after the + // passage/disturbance posts by the callers below; doing it here makes the standalone primitive + // itself a complete landing-room operation. + if (WorldZ <= Geometry.FloorZ + KINDA_SMALL_NUMBER + && WorldZ > Geometry.FloorZ - Geometry.FloorThickness + && FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f) + && FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)) + { + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + Density = FMath::Max(Density, BaseDensity); + } + } +} + +/** Reassert only the origin-room air volume after a post-process that may add rock. */ +FORCEINLINE void VF_ApplyOriginLandingAir(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (VoxelDensityAblation::IsLandingPostsOff() || Radius <= 0.0f) return; + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, Radius); + if (!Geometry.bValid) return; + const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF( + FVector(WorldX, WorldY, WorldZ), Geometry); + VF_ApplyPassageLandingCarving( + Density, RoomSDF, BaseDensity, SealThickness); +} + +/** Reassert only the origin-room support slab after an air carve/post-process. */ +FORCEINLINE void VF_ApplyOriginLandingFloor(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (VoxelDensityAblation::IsLandingPostsOff() || Radius <= 0.0f) return; + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, Radius); + if (!Geometry.bValid) return; + if (WorldZ <= Geometry.FloorZ + KINDA_SMALL_NUMBER + && WorldZ > Geometry.FloorZ - Geometry.FloorThickness + && FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f) + && FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)) + { + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + Density = FMath::Max(Density, BaseDensity); + } + } +} + +/** MC-facing origin-room air backstop for the post-disturbance path. */ +FORCEINLINE void VF_ApplyOriginLandingAirMC(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (!VoxelGenLOD::IsOriginLandingReachable() + && !VoxelGenLOD::IsTilePostReachBypassActive()) + { + VoxelGenLOD::CompareSkippedPost(Density, [&]() + { + VF_ApplyOriginLandingAirMC( + Density, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); + }, VoxelGenLOD::ETilePostComparisonKind::OriginAir); + return; + } + float InternalDensity = -Density; + VF_ApplyOriginLandingAir(InternalDensity, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); + Density = -InternalDensity; +} + +/** MC-facing origin-room floor backstop for the post-disturbance path. */ +FORCEINLINE void VF_ApplyOriginLandingFloorMC(float& Density, + float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (!VoxelGenLOD::IsOriginLandingReachable() + && !VoxelGenLOD::IsTilePostReachBypassActive()) + { + VoxelGenLOD::CompareSkippedPost(Density, [&]() + { + VF_ApplyOriginLandingFloorMC( + Density, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); + }, VoxelGenLOD::ETilePostComparisonKind::OriginFloor); + return; + } + float InternalDensity = -Density; + VF_ApplyOriginLandingFloor(InternalDensity, WorldX, WorldY, WorldZ, + StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius); + Density = -InternalDensity; +} + +//============================================================================= +// XY EDGE SEAL / SCELLEMENT DE LA LIMITE XY +//============================================================================= +// The bounded-world rim is a radial solid ramp in actor-space XY. This helper uses the same +// internal convention as VF_ApplyBoundarySeal (positive = solid); the MC-facing wrapper below +// negates it so the output convention remains negative = solid. +// +// Radius == 0 is deliberately checked before any arithmetic: it is a true legacy no-op, not a +// nearly-invisible setting-dependent perturbation. The common interior case also returns from a +// squared-distance test and pays no sqrt. +FORCEINLINE void VF_ApplyXYEdgeSeal(float& Density, float WorldX, float WorldY, + float WorldRadiusVoxels, float Thickness, float BaseDensity) +{ + if (VoxelDensityAblation::IsXYEdgeSealOff() + || !(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f) || !(BaseDensity > 0.0f)) return; + if (!VoxelMath::IsFinite(WorldX) || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(WorldRadiusVoxels) || !VoxelMath::IsFinite(Thickness) + || !VoxelMath::IsFinite(BaseDensity)) return; + + const float InnerRadius = WorldRadiusVoxels - Thickness; + const float InnerRadiusSq = InnerRadius * InnerRadius; + const float DistSq = WorldX * WorldX + WorldY * WorldY; + const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels; + + // The overwhelmingly common case: well inside the bounded world. + if (InnerRadius > 0.0f && DistSq <= InnerRadiusSq) return; + + // Keep the outer-boundary comparison in squared space too. This makes the exact force branch + // and the ClassifyBox proof use the same predicate: at or beyond R the result is BaseDensity, + // with no sqrt-rounding ambiguity in the forced-solid margin. + if (DistSq >= RadiusSq) + { + Density = FMath::Max(Density, BaseDensity); + return; + } + + const float DistXY = FMath::Sqrt(DistSq); + // 0 at the inner edge, 1 at the world radius; smoothstep removes a cylindrical cliff. + const float SealFactor = SmoothStep01(FMath::Clamp( + (DistXY - InnerRadius) / Thickness, 0.0f, 1.0f)); + + // At/beyond WorldRadiusVoxels the branch above already forced exactly BaseDensity. + Density = FMath::Max(Density, SealFactor * BaseDensity); +} + +/** MC-facing form used after post-processes that already operate in the output convention. */ +FORCEINLINE void VF_ApplyXYEdgeSealMC(float& Density, float WorldX, float WorldY, + float WorldRadiusVoxels, float Thickness, float BaseDensity) +{ + if (VoxelDensityAblation::IsXYEdgeSealOff() + || !(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)) return; + + float InternalDensity = -Density; + VF_ApplyXYEdgeSeal(InternalDensity, WorldX, WorldY, + WorldRadiusVoxels, Thickness, BaseDensity); + Density = -InternalDensity; +} + +//============================================================================= +// XY EDGE PROOF / PREUVE DE BOITE +//============================================================================= +// The closest point of an AABB to the XY origin gives the minimum radius over the whole box. +// The helpers below are shared by ClassifyBox, ClassifyTile's global shell early-out, and the +// soundness test. They intentionally require one voxel of radial margin in the ramp: at the +// mathematical inner edge the helper is a true no-op, so claiming AllSolid there would be false. +namespace VoxelDensityReach +{ + constexpr float PassageBlend = 4.0f; + constexpr float EdgeProofMargin = 1.0f; +} + +FORCEINLINE bool VF_IsValidXYEdgeSealProofInput(const FBox& VoxelBox, + float WorldRadiusVoxels, float Thickness, float BaseDensity) +{ + return VoxelBox.IsValid + && VoxelMath::IsFinite(VoxelBox.Min.X) && VoxelMath::IsFinite(VoxelBox.Min.Y) + && VoxelMath::IsFinite(VoxelBox.Max.X) && VoxelMath::IsFinite(VoxelBox.Max.Y) + && VoxelMath::IsFinite(WorldRadiusVoxels) && VoxelMath::IsFinite(Thickness) + && VoxelMath::IsFinite(BaseDensity) + && WorldRadiusVoxels > 0.0f && Thickness > 0.0f && BaseDensity > 0.0f; +} + +FORCEINLINE float VF_XYEdgeClosestRadiusSq(const FBox& VoxelBox) +{ + const float ClosestX = (VoxelBox.Min.X > 0.0f) ? VoxelBox.Min.X + : (VoxelBox.Max.X < 0.0f) ? VoxelBox.Max.X : 0.0f; + const float ClosestY = (VoxelBox.Min.Y > 0.0f) ? VoxelBox.Min.Y + : (VoxelBox.Max.Y < 0.0f) ? VoxelBox.Max.Y : 0.0f; + return ClosestX * ClosestX + ClosestY * ClosestY; +} + +FORCEINLINE float VF_XYEdgeFarthestRadiusSq(const FBox& VoxelBox) +{ + const float FarthestX = FMath::Max(FMath::Abs(VoxelBox.Min.X), FMath::Abs(VoxelBox.Max.X)); + const float FarthestY = FMath::Max(FMath::Abs(VoxelBox.Min.Y), FMath::Abs(VoxelBox.Max.Y)); + return FarthestX * FarthestX + FarthestY * FarthestY; +} + +/** + * Return the actual positive lower-bound margin imposed by the edge seal over a box, or 0 when + * the box is not provably forced. The ramp bound is intentionally reduced by 2× after the + * one-voxel proof margin, so float evaluation error can only make this estimate too small. + */ +FORCEINLINE float VF_XYEdgeSealForcedMarginOverBox(const FBox& VoxelBox, + float WorldRadiusVoxels, float Thickness, float BaseDensity) +{ + if (!VF_IsValidXYEdgeSealProofInput(VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity)) return 0.0f; + + const float ClosestRadiusSq = VF_XYEdgeClosestRadiusSq(VoxelBox); + const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels; + if (ClosestRadiusSq >= RadiusSq) + { + // The per-voxel helper takes the >= WorldRadiusVoxels branch and forces BaseDensity. + return BaseDensity; + } + + const float InnerRadius = WorldRadiusVoxels - Thickness; + const float ProofRadius = InnerRadius + VoxelDensityReach::EdgeProofMargin; + if (!(Thickness > VoxelDensityReach::EdgeProofMargin) + || !(ClosestRadiusSq >= ProofRadius * ProofRadius)) return 0.0f; + + const float GuaranteedFactor = SmoothStep01( + FMath::Clamp(VoxelDensityReach::EdgeProofMargin / Thickness, 0.0f, 1.0f)); + return BaseDensity * GuaranteedFactor * 0.5f; +} + +FORCEINLINE bool VF_XYEdgeSealBoxIsForcedSolid(const FBox& VoxelBox, + float WorldRadiusVoxels, float Thickness, float BaseDensity) +{ + return VF_XYEdgeSealForcedMarginOverBox( + VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity) > 0.0f; +} + +FORCEINLINE bool VF_XYEdgeSealBoxTouchesBand(const FBox& VoxelBox, + float WorldRadiusVoxels, float Thickness) +{ + if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f) + || !VoxelMath::IsFinite(WorldRadiusVoxels) || !VoxelMath::IsFinite(Thickness)) return false; + + const float InnerRadius = WorldRadiusVoxels - Thickness; + return VF_XYEdgeFarthestRadiusSq(VoxelBox) > InnerRadius * InnerRadius; +} + +//============================================================================= +// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin +//============================================================================= +// The constants are named above so a future bounds test cannot let them drift from the hot path. diff --git a/Standalone/Source/VoxelForge/Public/VoxelDensityProfile.h b/Standalone/Source/VoxelForge/Public/VoxelDensityProfile.h new file mode 100644 index 0000000..810e208 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -0,0 +1,459 @@ +// Opt-in density-path profiling used by the editor commandlet and targeted PIE diagnostics. +// Disabled by default so the shipping density path pays only one predictable branch in the +// scoped timers below. + +#pragma once + +#include "CoreMinimal.h" +#include "HAL/PlatformTime.h" + +#include + +namespace VoxelDensityProfile +{ + enum class EMode : uint8 + { + Disabled, + Sampled, + // Low-overhead attribution: keeps the parent and stage ledger trustworthy, samples + // hot component scopes, and collects classifier detail without the old full-scope storm. + Attribution, + Full, + }; + + // Count-based sampling is deterministic with respect to the profiler's own counters, but the + // report is intentionally diagnostic only. It never participates in density or mesh output. + constexpr uint32 DefaultSampleInterval = 128; + + enum class EBucket : uint8 + { + GetDensityAt, + ApplyDisturbances, + PassageModifier, + PassageLandingAir, + PassageLandingFloor, + PassageLandingRoomFloor, + PassageTunnelAir, + PassageStructuralPosts, + TunnelCoreSupport, + TunnelCoreSdf, + TunnelCorePosts, + StructuralTail, + RoomGraphSource, + SdfCarve, + CaveRoughnessMod, + CaveTerraceMod, + LayerLineMod, + RibbingMod, + CaveOverhangMod, + CaveCliffMod, + ScallopMod, + CaveArchMod, + RoomColumnMod, + DomeMod, + PinchMod, + FloorBiasMod, + WormFieldSource, + OriginSpineOp, + BoundarySealOp, + PassageCarveOp, + XYEdgeSealOp, + OtherOp, + // Attribution components. These are nested diagnostics; the non-overlapping phase + // buckets below remain the density accounting ledger. + FusedEvaluator, + InterpretedOpStack, + OperatorBlock, + RoomGraphBuild, + RoomGraphSdf, + TunnelCoreWorld, + FusedDetail, + FusedWorm, + FusedStructural, + ClassifierTotal, + ClassifierIntervalProof, + ClassifierExactCore, + ClassifierExactFinal, + ClassifierRoomTail, + RuntimeStreamBuilding, + // Non-overlapping top-level phases. The named operation buckets above are intentionally + // nested diagnostics; these phase buckets are the accounting ledger that must add up to + // GetDensityAt without double-counting a stack op inside its enclosing phase. + DensityPrologue, + DensityCore, + DensityDisturbances, + DensityStructuralPosts, + DensityBoundarySeal, + DensityDiffLayer, + DensityTail, + // Mesher-side accounting. GenerateMesh is the inclusive total; the following buckets + // cover its regular-grid work and its non-grid setup/finalisation. + MesherGenerateMesh, + MesherDensityGrid, + MesherCellClassification, + MesherGradientNormals, + MesherVertexInterpolation, + MesherStreamBuilding, + MesherOther, + Count + }; + + constexpr int32 BucketCount = static_cast(EBucket::Count); + + enum class ECounter : uint8 + { + TunnelCacheLookup, + TunnelCacheHit, + TunnelCacheMiss, + SdfCacheBuild, + SdfSpatialIndexBuild, + SdfBrickIndexBuild, + RoomGraphBuildGeneratorTile, + RoomGraphBuildGeneratorTunnelCore, + RoomGraphBuildOpShared, + RoomGraphBuildOpLocal, + RoomGraphBuildClassifierShared, + RoomGraphBuildClassifierLocal, + RoomGraphBuildUnknown, + CaveRoomCandidates, + CaveRoomEvaluated, + CaveTunnelCandidates, + CaveTunnelEvaluated, + TunnelCoreCandidates, + TunnelCoreEvaluated, + TunnelSupportColumnBuilds, + TunnelSupportColumnCandidates, + TunnelSupportFloorQueries, + TunnelSupportFloorChecks, + TunnelSupportFloorBackstopFires, + TunnelRoomFloorBackstopFires, + TunnelAuthoredFloorSamples, + TunnelAuthoredFloorProtectionClamps, + TunnelAuthoredFloorDisturbanceSkips, + TunnelFloorProfileFallbacks, + TunnelFloorProfileBuilds, + TunnelFloorProfileSegments, + TunnelFloorProfileLedges, + TunnelFloorProfileLedgesBelow2Voxels, + TunnelFloorProfileLedges2To4Voxels, + TunnelFloorProfileLedges4To8Voxels, + TunnelFloorProfileLedges8PlusVoxels, + // One count per authored graph edge. MultiStepEdges is an invariant guard: it must stay + // zero now that old per-segment staircase quantisation is removed. + TunnelFloorSteepEdges, + TunnelFloorWindingEdges, + TunnelFloorDropEdges, + TunnelFloorMultiStepEdges, + PassageSupportFloorBackstopFires, + PassageNativeFloorCompositions, + PassageCandidates, + PassageEvaluated, + MesherBlockTests, + MesherBlockAllSolid, + MesherBlockAllAir, + TileClassifyCalls, + TileClassifyAllSolid, + TileClassifyAllAir, + TileClassifyMixed, + FusedTunnelSamples, + FusedTunnelDetailSamples, + FusedTunnelDetailSkipped, + WormEligibleSamples, + WormBlockProofs, + WormBlockSkippedSamples, + OpBlockBuilds, + OpBlockSamples, + OpBlockOperators, + OpBlockActiveOperators, + OpBlockPrunedOperators, + MesherDensityGridBytes, + MesherSharedGridReadBytes, + MesherOutputArrayBytes, + MesherOutputAllocatedBytes, + OpBlockScratchBytes, + OpBlockCopyBytes, + Count + }; + + constexpr int32 CounterCount = static_cast(ECounter::Count); + + // Export the one fast-path bit so callers in the editor module can skip instrumentation + // without crossing the DLL boundary for every scoped timer or counter. It is diagnostic + // state only; it never participates in density or mesh decisions. + VOXELFORGE_API extern std::atomic GEnabled; + VOXELFORGE_API extern std::atomic GMode; + inline thread_local bool GSampledScopeActive = false; + + // Resident allocator-backed bytes for one worker's generation cache family. The profiler + // aggregates this by worker; the fields intentionally describe allocations owned by the + // cache, not the inline size of the enclosing C++ object. SlotStorageBytes is the one + // exception: it is the inline/static storage reserved for the worker's fixed cache table. + struct FCacheMemoryBreakdown + { + uint64 SlotStorageBytes = 0; + uint64 OpStackBytes = 0; + uint64 RoomsBytes = 0; + uint64 RoomFloorJoinsBytes = 0; + uint64 TunnelsBytes = 0; + uint64 PitsBytes = 0; + uint64 ChimneysBytes = 0; + uint64 ColumnsBytes = 0; + uint64 SupportColumnEntriesBytes = 0; + uint64 SupportColumnsBytes = 0; + uint64 SupportColumnIntervalsBytes = 0; + uint64 SpatialIndexBytes = 0; + uint64 TotalBytes() const + { + return SlotStorageBytes + OpStackBytes + + RoomsBytes + RoomFloorJoinsBytes + TunnelsBytes + PitsBytes + + ChimneysBytes + ColumnsBytes + SupportColumnEntriesBytes + + SupportColumnsBytes + SupportColumnIntervalsBytes + SpatialIndexBytes; + } + + uint64 DynamicBytes() const + { + return TotalBytes() - SlotStorageBytes; + } + + FCacheMemoryBreakdown& operator+=(const FCacheMemoryBreakdown& Other) + { + SlotStorageBytes += Other.SlotStorageBytes; + OpStackBytes += Other.OpStackBytes; + RoomsBytes += Other.RoomsBytes; + RoomFloorJoinsBytes += Other.RoomFloorJoinsBytes; + TunnelsBytes += Other.TunnelsBytes; + PitsBytes += Other.PitsBytes; + ChimneysBytes += Other.ChimneysBytes; + ColumnsBytes += Other.ColumnsBytes; + SupportColumnEntriesBytes += Other.SupportColumnEntriesBytes; + SupportColumnsBytes += Other.SupportColumnsBytes; + SupportColumnIntervalsBytes += Other.SupportColumnIntervalsBytes; + SpatialIndexBytes += Other.SpatialIndexBytes; + return *this; + } + }; + + struct FSnapshot + { + uint64 Calls[BucketCount]{}; + // Sum of sampled worker time. Useful for CPU-work accounting, but not directly comparable + // with an export wall clock when the mesher runs in parallel. + uint64 Cycles[BucketCount]{}; + uint64 Samples[BucketCount]{}; + // Largest estimated worker contribution. This is the report-facing parallel wall + // estimate; it avoids mistaking several parallel workers for several exports. + uint64 WallCycles[BucketCount]{}; + uint32 ActiveWorkers[BucketCount]{}; + uint64 Counters[CounterCount]{}; + + // Classifier detail is sampled separately from the timer buckets. Calls is the exact + // number of classifier invocations; the remaining fields are worker-local samples + // expanded at Snapshot() using Calls / SampledCalls. + struct FClassifierStats + { + uint64 Calls = 0; + uint64 SampledCalls = 0; + uint64 RefineNodes = 0; + uint64 StackBoxCalls = 0; + uint64 WholeMixedNodes = 0; + uint64 WholeSolidNodes = 0; + uint64 WholeAirNodes = 0; + uint64 NeedsFinalFieldNodes = 0; + uint64 SplitNodes = 0; + uint64 MaxRefinementDepth = 0; + uint64 ExactCoreSamples = 0; + uint64 ExactFinalSamples = 0; + uint64 ExactCoreCacheHits = 0; + uint64 ExactFinalCacheHits = 0; + uint64 ExactCoreLeaves = 0; + uint64 ExactFinalLeaves = 0; + uint64 StackBoxCycles = 0; + uint64 ExactCoreCycles = 0; + uint64 ExactFinalCycles = 0; + uint64 RoomTailQueries = 0; + uint64 RoomTailEvaluated = 0; + uint64 RoomTailCycles = 0; + uint64 RoomPropagateCycles = 0; + uint64 RoomExactPrimitiveCycles = 0; + uint64 RoomCacheWindowCycles = 0; + uint64 RoomNumRooms = 0; + uint64 RoomNumTunnels = 0; + uint64 RoomNumRoomFloorJoins = 0; + uint64 RoomNumPits = 0; + uint64 RoomNumChimneys = 0; + }; + FClassifierStats Classifier; + + uint64 TimerPairCycles = 0; + uint32 SampleInterval = DefaultSampleInterval; + EMode Mode = EMode::Disabled; + + // Opt-in worker-cache footprint snapshot. These are current per-thread values (not + // allocation-event counters), so Snapshot() reports the resident cache footprint at the + // end of a profiling run rather than multiplying bytes by cache churn. + uint64 TunnelCacheWorkers = 0; + uint64 TunnelCacheCapacityEntries = 0; + uint64 TunnelCacheValidEntries = 0; + uint64 TunnelCacheStaticBytes = 0; + uint64 TunnelCacheDynamicBytes = 0; + uint64 TunnelCacheEntryBytes = 0; + uint64 TunnelCacheLargestEntryBytes = 0; + uint64 TunnelCacheLargestWorkerValidEntries = 0; + uint64 TunnelCacheLargestWorkerBytes = 0; + FCacheMemoryBreakdown TunnelCacheBreakdown; + + uint64 RoomGraphCacheWorkers = 0; + uint64 RoomGraphCacheCapacityEntries = 0; + uint64 RoomGraphCacheValidEntries = 0; + uint64 RoomGraphCacheStaticBytes = 0; + uint64 RoomGraphCacheDynamicBytes = 0; + uint64 RoomGraphCacheEntryBytes = 0; + uint64 RoomGraphCacheLargestEntryBytes = 0; + uint64 RoomGraphCacheLargestWorkerValidEntries = 0; + uint64 RoomGraphCacheLargestWorkerBytes = 0; + FCacheMemoryBreakdown RoomGraphCacheBreakdown; + }; + + VOXELFORGE_API void SetMode(EMode Mode, uint32 SampleInterval = DefaultSampleInterval); + // Counters are collected only while a diagnostic mode is enabled. In the ordinary clean + // path this is a single relaxed load, so the measurement hooks stay dormant. + FORCEINLINE bool IsEnabledFast() + { + return GEnabled.load(std::memory_order_relaxed); + } + FORCEINLINE bool AreCountersEnabled() { return IsEnabledFast(); } + FORCEINLINE bool ShouldProbeScopeFast(EBucket Bucket) + { + const EMode Mode = GMode.load(std::memory_order_relaxed); + if (Mode == EMode::Full) + { + return true; + } + if (Mode == EMode::Sampled) + { + return Bucket == EBucket::GetDensityAt + || Bucket == EBucket::MesherGenerateMesh + || Bucket == EBucket::MesherDensityGrid + || Bucket == EBucket::MesherOther; + } + if (Mode == EMode::Attribution) + { + switch (Bucket) + { + case EBucket::GetDensityAt: + case EBucket::MesherGenerateMesh: + case EBucket::MesherDensityGrid: + case EBucket::MesherCellClassification: + case EBucket::MesherGradientNormals: + case EBucket::MesherVertexInterpolation: + case EBucket::MesherStreamBuilding: + case EBucket::MesherOther: + case EBucket::RoomGraphBuild: + return true; + default: + return GSampledScopeActive; + } + } + return false; + } + VOXELFORGE_API bool IsEnabled(); + VOXELFORGE_API bool IsCycleTimingEnabled(); + VOXELFORGE_API EMode GetMode(); + VOXELFORGE_API uint32 GetSampleInterval(); + VOXELFORGE_API uint64 GetTimerPairCycles(); + VOXELFORGE_API void CalibrateTimer(); + VOXELFORGE_API void Reset(); + VOXELFORGE_API FSnapshot Snapshot(); + // Worker-local snapshot for a tile profile. GenerateTileResult is single-owner on its worker, + // so this avoids attributing another worker's concurrent tile to the current tile diagnostic. + VOXELFORGE_API FSnapshot SnapshotCurrentThread(); + VOXELFORGE_API void AddCounter(ECounter Counter, uint64 Amount = 1); + VOXELFORGE_API void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls = 1); + VOXELFORGE_API void RecordCall(EBucket Bucket); + VOXELFORGE_API void RecordClassifierCall(bool bSampled); + VOXELFORGE_API void AddClassifierStats(const FSnapshot::FClassifierStats& Stats); + VOXELFORGE_API bool ShouldSample(EBucket Bucket); + VOXELFORGE_API void AddSampledMeasurement(EBucket Bucket, uint64 RawCycles); + + VOXELFORGE_API void SetWorkerTunnelCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries, + const FCacheMemoryBreakdown& Breakdown); + VOXELFORGE_API void SetWorkerRoomGraphCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries, + const FCacheMemoryBreakdown& Breakdown); + VOXELFORGE_API const TCHAR* CounterName(ECounter Counter); + VOXELFORGE_API EBucket BucketFromName(const TCHAR* Name); + VOXELFORGE_API const TCHAR* BucketName(EBucket Bucket); + + struct FScopeToken + { + bool bEntered; + bool bSampled; + bool bBlockTiming; + bool bPreviousSampled; + bool bPreviousSampledScopeActive; + uint64 StartCycles; + }; + + VOXELFORGE_API FScopeToken BeginScope(EBucket Bucket); + VOXELFORGE_API void EndScope(EBucket Bucket, const FScopeToken& Token); + + class FScopedTimer final + { + public: + explicit FScopedTimer(EBucket InBucket) + { + if (IsEnabledFast() && ShouldProbeScopeFast(InBucket)) + { + Bucket = InBucket; + Token = BeginScope(InBucket); + bActive = Token.bEntered; + } + } + + explicit FScopedTimer(const TCHAR* InName) + { + if (IsEnabledFast()) + { + Bucket = BucketFromName(InName); + if (ShouldProbeScopeFast(Bucket)) + { + Token = BeginScope(Bucket); + bActive = Token.bEntered; + } + } + } + + FORCEINLINE ~FScopedTimer() + { + End(); + } + + // End a range before its lexical scope ends. This lets GetDensityAt split its enclosing + // total into disjoint prologue/core/post phases without moving the large cache block. + FORCEINLINE void End() + { + if (!bActive) + { + return; + } + EndScope(Bucket, Token); + Token.bEntered = false; + bActive = false; + } + + FScopedTimer(const FScopedTimer&) = delete; + FScopedTimer& operator=(const FScopedTimer&) = delete; + + private: + EBucket Bucket; + FScopeToken Token; + bool bActive = false; + }; +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelDiffLayer.h b/Standalone/Source/VoxelForge/Public/VoxelDiffLayer.h new file mode 100644 index 0000000..21bb834 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelDiffLayer.h @@ -0,0 +1,315 @@ +// VoxelDiffLayer.h +// Runtime terrain modification system (player carving & filling). +// +// HOW IT WORKS: +// ============= +// The procedural density function generates cave terrain deterministically. +// When the player carves or fills terrain, we DON'T modify the procedural +// output — instead, we store modifications as a "diff layer" on top. +// +// During density evaluation: +// Final density = Procedural density + Diff offset +// +// This keeps procedural generation deterministic while supporting freeform editing. +// Modifications are stored spatially (per-chunk) for O(1) lookup. +// +// MODIFICATION LIFECYCLE: +// 1. Player uses a tool at position P with radius R and strength S +// 2. ApplyModification() stores the mod in all overlapping chunks +// 3. Returns the list of affected chunk coords → world re-meshes those chunks +// 4. During re-meshing, GetDensityOffset() returns the combined diff at each voxel +// +// PERSISTENCE: +// Diffs are stored in memory. The game layer is responsible for saving/loading +// them to disk (server-side) and clearing them on season reset. + +#pragma once + +#include "CoreMinimal.h" +#include "HAL/CriticalSection.h" // FRWLock +#include "Misc/ScopeRWLock.h" // FReadScopeLock / FWriteScopeLock +#include "VoxelTypes.h" +#include +#include "VoxelDiffLayer.generated.h" + +/** + * EVoxelBrushShape — geometry of a modification brush. + * Sphere : Center + Radius. Classic round brush. + * Box : Center + BoxExtent (half-extents) + Falloff band. + * Capsule : segment Center→CapsuleEnd + Radius (tube) + Falloff band. Good for tunnels. + */ +UENUM(BlueprintType) +enum class EVoxelBrushShape : uint8 +{ + Sphere UMETA(DisplayName = "Sphere"), + Box UMETA(DisplayName = "Box"), + Capsule UMETA(DisplayName = "Capsule") +}; + +/** + * FVoxelModification — A single edit to the density field. + * + * Represents one brush stroke at a world position. Multiple overlapping + * modifications combine additively. + * + * Strength convention (INTERNAL density, before MC negate): + * Negative → subtract density → create air → CARVE (remove rock) + * Positive → add density → create solid → FILL (add rock) + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelModification +{ + GENERATED_BODY() + + // Actor-local voxel-space center of the brush (endpoint A for Capsule). The AVoxelWorld helper + // converts world centimetres to this space before calling the diff layer. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") + FVector Center = FVector::ZeroVector; + + // Sphere radius / capsule tube radius, in voxels. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.5")) + float Radius = 3.0f; + + // Density change: negative = carve (remove rock), positive = fill (add rock). + // -10 → strong carve (punches through BaseDensity=8) · +10 → strong fill + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") + float Strength = -10.0f; + + // Brush geometry. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") + EVoxelBrushShape Shape = EVoxelBrushShape::Sphere; + + // Box half-extents in voxels (Box shape only). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", + meta = (EditCondition = "Shape == EVoxelBrushShape::Box")) + FVector BoxExtent = FVector(5.0f, 5.0f, 5.0f); + + // Capsule second endpoint in actor-local voxel coords (Capsule shape only). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", + meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule")) + FVector CapsuleEnd = FVector::ZeroVector; + + // Soft falloff band (voxels) around the Box/Capsule surface for smooth edges. + // Sphere ignores this — its whole Radius is the falloff. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1")) + float Falloff = 2.0f; + + // Conservative voxel-space AABB the brush can affect (used to find overlapping chunks). + void GetWorldBounds(FVector& OutMin, FVector& OutMax) const + { + switch (Shape) + { + case EVoxelBrushShape::Box: + { + const FVector E = BoxExtent + FVector(Falloff); + OutMin = Center - E; + OutMax = Center + E; + break; + } + case EVoxelBrushShape::Capsule: + { + const float Pad = Radius + Falloff; + OutMin = FVector(FMath::Min(Center.X, CapsuleEnd.X), FMath::Min(Center.Y, CapsuleEnd.Y), FMath::Min(Center.Z, CapsuleEnd.Z)) - FVector(Pad); + OutMax = FVector(FMath::Max(Center.X, CapsuleEnd.X), FMath::Max(Center.Y, CapsuleEnd.Y), FMath::Max(Center.Z, CapsuleEnd.Z)) + FVector(Pad); + break; + } + case EVoxelBrushShape::Sphere: + default: + OutMin = Center - FVector(Radius); + OutMax = Center + FVector(Radius); + break; + } + } +}; + +/** + * UVoxelDiffLayer — Stores all player modifications as a spatial diff. + * + * Owned by AVoxelWorld, passed to the generator for density evaluation. + * Modifications are grouped by chunk coord for fast spatial lookup — + * when evaluating density for a chunk, we only check modifications + * stored in that chunk (no global scan). + * + * When a modification sphere overlaps multiple chunks, it's stored in ALL + * of them so each chunk's lookup is self-contained. + */ +UCLASS() +class VOXELFORGE_API UVoxelDiffLayer : public UObject +{ + GENERATED_BODY() + +public: + /** Process-unique identity for worker-local modification snapshots. */ + uint64 GetCacheLifetimeId() const; + + //========================================================================= + // BUDGET CONFIGURATION + //========================================================================= + + /** + * Set the budget limits for player modifications. + * Called by AVoxelWorld during BeginPlay from VoxelSettings values. + * A value of 0 means unlimited (no cap). + * + * @param InMaxMods - Max number of operations (0 = unlimited) + * @param InMaxRadius - Max brush radius per operation + * @param InMaxVolume - Max cumulative brush volume in cubic voxels (0 = unlimited) + */ + void SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxVolume); + + /** + * Check if a modification would be allowed under the current budget. + * Does NOT consume budget — use this for UI feedback (greyed-out tool, etc.) + * + * @param Radius - The brush radius the player wants to use + * @return true if the modification is allowed + */ + bool CanModify(float Radius) const; + + /** Shape-aware budget check used by ApplyModification. Boxes charge their affected outer box and + * capsules charge their full swept volume, so a long tunnel cannot be billed as a small sphere. */ + bool CanModify(const FVoxelModification& Mod) const; + + /** + * Get remaining modification count (how many more operations the player can do). + * Returns -1 if unlimited. + */ + UFUNCTION(BlueprintPure, Category = "Voxel Diff Layer") + int32 GetRemainingModifications() const; + + /** + * Get remaining volume budget (cubic voxels of modification still allowed). + * Returns -1.0 if unlimited. + */ + UFUNCTION(BlueprintPure, Category = "Voxel Diff Layer") + float GetRemainingVolume() const; + + //========================================================================= + // APPLY MODIFICATIONS + //========================================================================= + + /** + * Apply a carve or fill operation. + * + * Stores the modification in every chunk the brush volume overlaps. + * Returns the list of affected chunk coordinates so the world knows + * which chunks need re-meshing. + * + * Budget enforcement: if the modification would exceed any configured + * limit (count, radius, or volume), it is REJECTED and an empty array + * is returned. Check CanModify() first for UI feedback. + * + * @param Mod - The modification to apply + * @return Array of chunk coordinates that need re-meshing (empty if rejected) + */ + TArray ApplyModification(const FVoxelModification& Mod); + + //========================================================================= + // DENSITY EVALUATION + //========================================================================= + + /** + * Get the combined density offset from all modifications at a world position. + * + * Called per-voxel by the generator during density evaluation. + * Only checks modifications stored for the given chunk coordinate, + * so it's fast when most chunks have no modifications. + * + * Each modification contributes with a smoothstep falloff from center to edge. + * Multiple overlapping modifications combine additively. + * + * @param ChunkCoord - The chunk being evaluated (for spatial lookup) + * @param WorldX, WorldY, WorldZ - The voxel position + * @return Total density offset (negative = carve, positive = fill) + */ + float GetDensityOffset(const FIntVector& ChunkCoord, + float WorldX, float WorldY, float WorldZ) const; + + /** + * Check if a chunk has any modifications (fast reject for hot path). + * + * @param ChunkCoord - The chunk to check + * @return true if there are modifications affecting this chunk + */ + bool HasModifications(const FIntVector& ChunkCoord) const; + + //========================================================================= + // HOT-PATH SNAPSHOT API (per-chunk, lock-amortised) + //========================================================================= + // Once ANY carve exists, calling HasModifications + GetDensityOffset per voxel costs two + // ModsLock acquisitions + two TMap finds per density sample (~86k lock ops per tile task — + // during carve gameplay, exactly when re-mesh latency matters). Instead, a worker snapshots a + // chunk's mod list ONCE per (chunk, version) and evaluates it lock-free via EvaluateMods; the + // version bump on ApplyModification/Clear invalidates worker-side caches. + + /** Lock-free: true once any modification exists anywhere (false = the common streaming case). */ + bool HasAnyMods() const { return bHasAnyMods.load(std::memory_order_acquire); } + + /** Monotonic mod-state version — bumped by ApplyModification and Clear. */ + uint32 GetModsVersion() const { return ModsVersion.load(std::memory_order_acquire); } + + /** Copy this chunk's modification list under ONE read lock (Out emptied if none). */ + void GetChunkModsSnapshot(const FIntVector& ChunkCoord, TArray& Out) const; + + /** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par + * construction : ApplyModification enregistre le mod dans TOUS les chunks que son rayon + * touche, donc le test par clé de chunk suffit. La recherche parcourt directement les clés de + * la plage demandée, pas tout l'historique des chunks — utilisé par ClassifyTile, PAS par voxel. */ + bool HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const; + + /** Evaluate a mod list at a voxel — the lock-free core shared by GetDensityOffset and the + * generator's snapshot path. Pure function (deterministic). Returns the combined offset + * (negative = carve, positive = fill). */ + static float EvaluateMods(const TArray& Mods, + float WorldX, float WorldY, float WorldZ); + + //========================================================================= + // MANAGEMENT + //========================================================================= + + /** Clear ALL modifications (e.g., on season reset). */ + void Clear(); + + /** Get total number of stored modification entries (for debug/stats). */ + int32 GetTotalModificationCount() const; + + /** Get number of chunks that have modifications. */ + int32 GetModifiedChunkCount() const; + +private: + // Never reused for the lifetime of the process: a recycled UObject address must not make a + // worker-local snapshot from another diff layer look current when its chunk/version match. + mutable std::atomic CacheLifetimeId{0}; + + //========================================================================= + // STORAGE + //========================================================================= + + // Modifications grouped by chunk coordinate. + // Each chunk's list contains ALL modifications that overlap it + // (including mods centered in neighboring chunks whose radius reaches here). + // + // THREADING: written on the game thread (ApplyModification / Clear) but read by MANY mesher + // WORKER threads (GetDensityAt -> HasModifications / GetDensityOffset). TMap is not thread-safe — + // a read landing during a write's rehash reads freed hash memory (EXCEPTION_ACCESS_VIOLATION in + // TSet::FindId). All access to ChunkMods MUST hold ModsLock (read lock for queries, write lock for + // mutation). bHasAnyMods is a lock-free fast reject: while it's false (the common streaming case, + // no carves) readers skip the map AND the lock entirely, so unmodified worlds pay nothing. + TMap> ChunkMods; + mutable FRWLock ModsLock; + std::atomic bHasAnyMods{ false }; + std::atomic ModsVersion{ 1 }; // see the snapshot API above + + //========================================================================= + // BUDGET TRACKING + //========================================================================= + + // Configured limits (0 = unlimited) + int32 BudgetMaxMods = 0; + float BudgetMaxRadius = 50.0f; + float BudgetMaxVolume = 0.0f; + + // Running totals — reset when Clear() is called + int32 ModificationCount = 0; // Total operations applied + float AccumulatedVolume = 0.0f; // Sum of all brush sphere volumes (4/3 * PI * R^3) +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelGenerator.h b/Standalone/Source/VoxelForge/Public/VoxelGenerator.h new file mode 100644 index 0000000..d51da90 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelGenerator.h @@ -0,0 +1,479 @@ +// VoxelGenerator.h +// Fournit le champ de densité du monde (positif = solide, négatif = air). +// +// Pipeline density-only: plus de grille de blocs, plus de surface heightfield. +// Le mesher appelle GetDensityAt() par voxel pour reconstruire la géométrie. +// Chaque strate est générée par sa pile d'opérateurs (VoxelDensityOpStack.h), pilotée par les +// params de strate récupérés auprès du StrateManager. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "VoxelTypes.h" +#include "VoxelStrateTypes.h" +#include "VoxelBiomeTypes.h" + +#include "VoxelGenerator.generated.h" + +// Forward decls (évite les includes transitifs) +class UVoxelSettings; +class UVoxelStrateManager; +class UVoxelDiffLayer; +class UVoxelBiomeDefinition; +struct FTunnelCoreWorldEvaluation; + +/** Guards which may be disabled only by the sealed-solid proof's soundness tests. */ +enum class EVoxelSealedSolidProofGuard : uint8 +{ + None = 0, + DiffLayer = 1u << 0, + Passages = 1u << 1, + OriginLanding = 1u << 2, + DisturbanceChasms = 1u << 3, +}; + +//============================================================================= +// LOD-AWARE OCTAVE REDUCTION (T2.b) +//============================================================================= +// At Step=2/4, noise octaves whose wavelength is smaller than the sampling cell +// are pure aliasing cost — they can't shape the coarse isosurface, only shift it +// by sub-cell noise. Réduction d'octaves sur les tuiles lointaines (LOD). +// +// OctaveBias = octaves dropped from PER-VOXEL volumetric noise for the tile +// currently being meshed on THIS thread. Default 0 = full quality; set (via +// TGuardValue) by UVoxelMarchingCubesMesher::GenerateMesh from its Step and the +// opt-in UVoxelSettings::LODOctaveDrop, restored when the tile finishes — so +// game-thread queries, deco snapping and the density-volume fill thread always +// see 0. fBM/Ridged add octaves low→high frequency, so dropping the TAIL keeps +// the coarse shape identical; only sub-cell detail (already unrepresentable at +// that Step) disappears. LOD0 (Step=1) is byte-identical regardless. +// +// Deliberately NOT applied to XY-field noise (heightfield, ceiling, relief, +// moisture): those feed box-validated caches that can outlive a tile task on +// the same thread, and biome/climate must stay LOD-independent. +namespace VoxelGenLOD +{ + // The TLS variables themselves stay module-local because MSVC forbids exporting thread_local + // data (C2492). These tiny accessors let the editor explorer build a shared LOD grid without + // linking directly to the storage in the game module. + extern thread_local int32 OctaveBias; + + // Sampling step of the tile currently being meshed on this worker. GetDensityAt itself has + // no Step parameter because callers outside the mesher are point queries; this TLS is only a + // read-only hint for caches that may safely widen their spatial window for coarse tiles. + extern thread_local int32 SampleStep; + + // Exact lattice footprint of the tile currently being meshed. The cache-window switch + // uses this rather than inferring a region from an individual sample coordinate, so every + // sample in one tile shares one deterministic window even at tile/chunk boundaries. + extern thread_local FIntVector TileOriginVoxels; + extern thread_local int32 TileCellsPerAxis; + + VOXELFORGE_API int32 GetThreadOctaveBias(); + VOXELFORGE_API void SetThreadOctaveBias(int32 Value); + VOXELFORGE_API int32 GetThreadSampleStep(); + VOXELFORGE_API void SetThreadSampleStep(int32 Value); + // Called by the module's game-thread startup before any worker can enter GetDensityAt. + VOXELFORGE_API void InitializeConsoleSwitches(); + VOXELFORGE_API bool IsTileCacheWindowEnabled(bool bFusedPath = true); +#if WITH_EDITOR + /** Research-only override for the bounded editor region evaluator; default false per thread. */ + VOXELFORGE_API void SetResearchRegionCacheWindowEnabled(bool bEnabled); +#endif + VOXELFORGE_API bool GetThreadTileCacheWindow( + FIntVector& OutOriginVoxels, int32& OutStep, int32& OutCellsPerAxis); + VOXELFORGE_API void SetThreadTileContext( + const FIntVector& OriginVoxels, int32 CellsPerAxis); + + // Effective octave count for a per-voxel noise call site. + // At least 1 octave always survives (the coarse base shape). + FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); } +} + +// NOTE: EVoxelTileClass (le verdict T1.d) a déménagé dans VoxelTypes.h — inclus ci-dessus — +// pour que VoxelDensityOp.h puisse le partager sans dépendre d'un header UCLASS. +// EVoxelTileClass (the T1.d verdict) moved to VoxelTypes.h, included above. + +/** + * UVoxelGenerator + * + * Objet UObject léger — ne stocke pas de données lourdes. + * Tient les paramètres globaux copiés depuis les settings (seed, spine, bord XY) et les services + * dont il a besoin (StrateManager pour les params par chunk, DiffLayer pour + * les carves du joueur). + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelGenerator : public UObject +{ + GENERATED_BODY() + +public: + UVoxelGenerator(); + + //========================================================================= + // SEED (source unique: Settings->Seed) + //========================================================================= + // Les paramètres globaux sont copiés au démarrage pour éviter un déréférencement par voxel. + // Les formes créatives viennent ensuite des params de strate. + int32 Seed = 0; + + // Authored radius (voxels) of the guaranteed flat-floored landing room at world XY (0,0) in + // every strate — the old “spine” setting now sizes a room and never creates a vertical bore. + // 0 disables the origin landing rooms. Copied from VoxelSettings. + float OriginSpineRadius = 28.0f; + + // Radius (voxels) of the bounded world in actor-space XY, measured from (0,0). + // 0 keeps the legacy unbounded behavior and is a true no-op for the edge seal. + float WorldRadiusVoxels = 8192.0f; + + // Width (voxels) of the smooth solid ramp at the XY world edge. + float EdgeSealThickness = 128.0f; + + //========================================================================= + // SERVICES (injectés par AVoxelWorld::BeginPlay) + //========================================================================= + + // Manager des strates — fournit les params de cave par chunk. + // Peut être nullptr: dans ce cas on utilise des params par défaut. + UPROPERTY() + const UVoxelStrateManager* StrateManager = nullptr; + + // Diff layer des modifications joueur (carve/fill). + // Peut être nullptr: dans ce cas pas de modifs appliquées. + UPROPERTY() + const UVoxelDiffLayer* DiffLayer = nullptr; + + void SetStrateManager(const UVoxelStrateManager* InManager) + { + StrateManager = InManager; + } + void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer) + { + DiffLayer = InDiffLayer; + } + + // Copie le seed depuis les settings. Appelé au démarrage et lors d'un ChangeSeed. + void InitializeSettings(const UVoxelSettings* Settings); + + //========================================================================= + // API DE DENSITÉ + //========================================================================= + + /** + * Densité combinée (strates + diff du joueur). + * + * Convention de sortie (MC): négatif = solide, positif = air. + * C'est ce que le marching cubes attend comme champ scalaire. + * + * @param WorldX, WorldY, WorldZ - coordonnées monde en voxels (pas cm) + */ + float GetDensityAt(float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity = nullptr) const; + + /** + * Open a worker-local lattice hand-off for the following GetDensityAt calls. The mesher uses + * this around one tile's density-grid fill. The first sample in each resolved chunk lets the + * generator run the operator stack once in EvalBlock; later samples consume the exact cached + * per-sample result while the ordinary post-stack tail still runs in canonical order. + * + * This is intentionally a scoped service rather than a persistent world cache: its lifetime is + * one tile task, it is thread-local, and EndDensityBlock must be called on every exit path. + */ + void BeginDensityBlock(FIntVector OriginVoxels, int32 Step, + int32 SizeX, int32 SizeY, int32 SizeZ) const; + void EndDensityBlock() const; + +#if WITH_EDITOR + /** Editor-only chunk program; supported routes enter GetDensityAt once to seed a whole block. */ + bool EvaluateDensityChunk(FIntVector OriginVoxels, int32 SizeX, int32 SizeY, int32 SizeZ, + TArray& OutDensities, int32& OutFallbackSamples, + uint64& OutGeneralPathEntries, + uint64& OutSupportedBlockSetupEntries) const; +#endif + + /** + * Prove that the mesher's core lattice is entirely solid from the cheap, structural rules + * that own inter-strate gaps and boundary seals. This never samples density. It is intended + * for an empty-tile fast path; a false answer is harmless, while a true answer must make every + * core lattice vertex negative in the final MC field. + * + * The proof is deliberately narrower than ClassifyTile: it accepts only a tile made entirely + * of gap/seal lattice rows and rejects recipes/composer overrides plus every known air-writing + * structural feature. The mesher's +/-1 normal halo is not part of the cell lattice, matching + * ClassifyTile and GenerateMesh. + */ + bool TryProveSealedSolidTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis) const; + +#if WITH_DEV_AUTOMATION_TESTS + /** Test hook: disable named proof guards and require the brute-force oracle to catch it. */ + bool TryProveSealedSolidTileForTest( + const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, + uint8 DisabledGuards) const; +#endif + + /** + * Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise). + * C'est l'évaluateur fusionné de la pile TunnelNetwork/Underwater dans GetDensityAt. + * Exposée pour permettre des tests isolés avec des params custom. + * + * ⚠️ `ParamsFingerprint` ET `LayoutVersion` SONT OBLIGATOIRES (`AUDIT §C2`). Le cache SDF + * interne est clé sur (boîte XY, strate, seed) et PAS sur les params. Or `GetGenerationParams` BLENDE les params à l'intérieur d'une même + * strate — `Alpha` dépend du chunk Z en mode `Gradient` (le DÉFAUT, avec + * `TransitionBlendChunks = 2`) et du chunk XY en plus en mode `Interleaved`. Deux chunks de la + * même strate, même seed, donc même clé, mais des params DIFFÉRENTS : le worker évalue le + * deuxième chunk qu'il construit contre les salles du premier. Et comme *quel* chunk vient en + * premier dépend de l'ordre des workers, **deux pairs divergent depuis la même seed** — ce que + * `OPSTACK-PLAN §2.6.1` interdit explicitement. + * + * Pourquoi une empreinte PASSÉE plutôt qu'un `MemCrc32` calculé ici : ce serait ~300 octets de + * CRC PAR VOXEL sur le chemin le plus chaud du plugin. L'appelant la calcule UNE fois par + * chunk, là où le mémo de params vit déjà (`CP_*`), donc le coût par voxel est exactement deux + * comparaisons d'entiers. Pas de valeur par défaut : un appelant qui oublie doit ne pas + * compiler, pas hériter silencieusement du trou (la discipline de `FVoxelOpContext`). + * + * ⚠️ POUR LES TESTS : passez `FCrc::MemCrc32(&Params, sizeof(Params))`. Un oracle qui partage + * le défaut qu'il teste ne prouve rien — c'est précisément ce que la note de + * `VoxelForgeOpStackTunnelTest.cpp` (contrôle 3) décrivait comme le trou de l'original. + * + * The SDF cache key had neither the params nor anything that determines them, while the params + * are blended per chunk INSIDE a strate — so a worker could evaluate one chunk against another + * chunk's rooms, and which came first depends on worker order. Passing a once-per-chunk + * fingerprint keeps the fix off the per-voxel path. No default: forgetting it must not compile. + */ + float GetDensityWithParams(float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params, + uint32 ParamsFingerprint, uint32 LayoutVersion, + bool bApplyLegacyStructuralPosts = true, + // Optional hand-off used by a lowered canonical evaluator. When + // non-null, this receives the pre-disturbance TunnelNetwork core; + // it does not alter the scalar field result. + FTunnelCoreWorldEvaluation* OutTunnelCore = nullptr, + // Diagnostics are opt-in so ordinary/reference calls do not pay + // for fused-path counters. + bool bCollectFusedDiagnostics = false, + // Optional same-sample hand-off from EvaluateSDFCached. This is + // only a room-shape ownership bit; it does not alter the scalar + // result and avoids a duplicate room-index query. + bool* OutRoomOwnsBottom = nullptr) const; + + //========================================================================= + // CLIMATE & BIOME FIELDS (pure XY, deterministic, window-invariant) + //========================================================================= + + /** + * Relief ("elevation") field at a world XY → [0,1], contrast-shaped & smoothed. + * The single source of truth for the relief map M: SurfaceWorld terrain and the + * biome climate map both call this, so they agree when given the same freq/contrast. + */ + float SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const; + + /** + * La chaîne de hauteur complète de SurfaceWorld : structural → cliff → terrace → layer lines → + * plage. Rend une ALTITUDE monde en voxels, pas une densité. + * + * PUBLIQUE pour permettre un test isolé : c'est la référence de + * `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui compare la pile d'opérateurs de hauteur + * (`VoxelHeightOp.h`) à cette fonction point par point. + * Public so the height-op stack can be measured against it. + */ + float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + + /** + * La colonne de surface : terrain Z, plafond, et le gate d'OVERHANG résolu par colonne + * (amplitude + direction amont). C'est la colonne que `ClassifyTile` teste pour SurfaceWorld ; + * publique pour que les tests puissent placer des échantillons dans la fenêtre d'overhang. + * The surface column ClassifyTile tests for SurfaceWorld; public so tests can aim samples at + * the overhang window. + */ + void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ, + const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx, + const TArray& BiomeParams, FChunkBiomeCache& BiomeCache, + float& OutTerrainZ, float& OutCeilSurf, + float& OutOverhangAmp, float& OutDirX, float& OutDirY) const; + + /** + * Moisture field at a world XY → [0,1]. The second climate axis for biome placement. + */ + float SampleMoisture(float WorldX, float WorldY, float Frequency) const; + + /** + * Resolve the biome at a world XY: dominant cell + nearest neighbour + blend weight. + * Warped Voronoi over a jittered grid; each cell's biome is chosen by its site's + * (relief, moisture) against the context's climate boxes. PURE function of (XY, seed, + * Ctx) — no chunk-window dependence (CODEMAP §8.4). Returns an empty sample when the + * context has no biomes. Used by the 2D preview bake and (next) the density path. + */ + FBiomeSample SampleBiomeAt(float WorldX, float WorldY, const FBiomeContext& Ctx) const; + + /** + * Hot-path biome resolve: the dominant + neighbour biome and blend weight at a world + * XY for the given strate slice. Uses (and lazily rebuilds) Cache — a box-validated + * per-chunk grid — so the noise-heavy cell classification happens once per chunk, not + * per voxel. Bit-identical to SampleBiomeAt, so the baked preview matches the terrain. + */ + FBiomeSample ResolveBiomeSampleAt(float WorldX, float WorldY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const; + + /** + * Dominant biome ASSET at a world XY for the given strate slice (chunk Z), or nullptr + * when the strate has no biomes / the point is outside the strate range. Game-thread + * helper for content + atmosphere selection — uses the same field as the density path. + */ + const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const; + + /** + * Rich biome probe at a world XY for a strate slice (ChunkZ): dominant + neighbour biome assets, + * climate fields, border blend weight, and the dominant biome's EFFECTIVE decoration count. Mirrors + * exactly what the decoration scatter resolves per column, so it is a faithful "what's under here?" + * diagnostic (DominantDecorationCount == 0 explains an empty biome region). Game-thread, uncached. + */ + void QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const; + + /** + * Per-vertex material data for the master triplanar palette material (F6). Resolves the + * biome field at a world XY/Z and returns the dominant + neighbour MaterialPaletteIndex + * and the border blend weight (0 deep in a cell → 0.5 at the border). The mesher packs + * these into vertex colour so one material re-skins terrain per biome and cross-fades + * across biome borders. No biomes ⇒ Dominant=Neighbour=0, Weight=0 (default palette). + * Thread-safe: own per-chunk thread_local context + box cache (clustered tile queries + * stay warm). Window-invariant (ResolveBiomeSampleAt is bit-identical to SampleBiomeAt). + */ + void GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ, + int32& OutDominantPalette, int32& OutNeighborPalette, + float& OutBlendWeight) const; + + /** + * F7 AWARE PLACEMENT: evaluate an entry's relational placement conditions at a candidate voxel XY. + * True = ALL conditions pass (AND); empty list = true (zero cost). Pure query of the analytic fields + * (relief/moisture/biome-border) — deterministic + worker-safe. The caller passes the strate's + * already-resolved biome context (freq/contrast + Voronoi map), so no re-resolve. Also the shared core + * of the future quest FindFeature locator (same predicate, run as an outward search). + */ + bool EvaluateTerrainConditions(const TArray& Conditions, + float WorldX, float WorldY, const FBiomeContext& BiomeCtx) const; + + /** + * SurfaceWorld HEIGHT ORACLE: the terrain surface Z + sky-cap ceiling Z (voxel coords) at a world + * XY for the given strate slice (ChunkZ), WITHOUT ray-marching the density column. Returns false + * (outs untouched) when the chunk is NOT a SurfaceWorld heightfield — callers fall back to marching. + * Shares the density path's surface helpers, so a decoration snapped to OutTerrainZ sits exactly on + * the rendered ground. Does NOT include passage/spine/seal carving — verify with one GetDensityAt at + * the result if a column might be carved. Thread-safe (own per-chunk thread_local cache). + */ + bool GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ, + float& OutTerrainZ, float& OutCeilSurf) const; + + /** + * T1.d — classification conservative d'une tuile AVANT le pré-échantillonnage du mesher. + * (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict + * porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse). + * + * GAP (bedrock), hors-layout (air constant) et SurfaceWorld plient leurs hypothèses par Z ; + * SurfaceWorld évalue ses colonnes terrain/plafond avec le MÊME ComputeSurfaceColumn que le + * chemin densité. Un unique slot cave peut ajouter le verdict conservatif de sa pile, + * sous gardes d'archétype et de params bit-identiques. Spine/passages/disturbances/diff restent + * des gardes conservatrices. Worker-safe (lecture seule + caches thread_local partagés avec + * GetDensityAt — un verdict Mixed laisse les colonnes chaudes pour la génération qui suit). + */ + EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis) const; + EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis, + struct FVoxelTileClassificationStats* OutStats) const; + + /** + * Prove the exact constant-air field above/below the strate layout for a mesher lattice. + * This is separate from TryProveSealedSolidTile: it never supplies collision support and its + * caller records AllAir, not AllSolid. It shares the layout/diff/structural guards with the + * production classifier and returns false for every mixed or uncertain box. + */ + bool TryProveOutOfLayoutAirTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis) const; + +private: + /** Identité process-unique du propriétaire des caches `CP_*` thread_local. + * Process-unique owner identity for the `CP_*` thread-local cache key. */ + uint64 DensityCacheOwnerId = 0; + + EVoxelTileClass ClassifyTileUncached( + const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, + struct FVoxelTileClassificationStats* OutStats) const; + + /** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */ + int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const; + + // ComputeSurfaceTerrainZ a été DÉPLACÉE en `public` (voir plus haut) pour que + // VoxelForge.OpStack.SurfaceHeightEquivalence puisse s'y comparer. Une seule déclaration. + // Moved to public above so the height-stack test can compare against it. One declaration only. + + /** F20 — the RAW structural heightfield (continents + mountains + detail), BEFORE any + * terrain op (cliff/terrace/layer-lines/beach). Ops in ComputeSurfaceTerrainZ build on + * this; Cliff re-samples it at an XY offset for a cheap analytic slope. OutM = the relief + * "mountainous-ness" [0,1], reused to relief-condition the ops. */ + float SampleSurfaceStructuralZ(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params, float& OutM) const; + + /** The SurfaceWorld sky-cap ceiling surface Z at a world XY (also pure per-XY). */ + float ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + + /** Resolve a chunk's SurfaceWorld params (strate base + per-biome overrides, structural fields + * forced from the strate). Shared by GetDensityAt's per-chunk cache and the GetSurfaceHeightAt + * oracle so both produce the SAME surface. */ + void ResolveSurfaceChunkParams(const FIntVector& ChunkCoord, + FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx, + TArray& OutBiomeParams) const; + + /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ + void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache, + uint64 OwnerId, uint64 ManagerLifetimeId) const; +}; + +namespace VoxelGenLOD +{ + // -1 uses the measured path/LOD policy; 0/1 force the spatial broad phase off/on for an A/B + // run. The policy affects candidate enumeration only, never cache contents or ordering. + VOXELFORGE_API bool ShouldUseSpatialIndex(bool bFusedPath); +} + +/** + * Optional diagnostics for one ClassifyTile call. This is populated only when the caller asks + * for it (the streaming profiler does); it is never consulted by the proof and cannot affect a + * verdict. Keeping the counters beside the classifier lets the game profile distinguish cache, + * interval refinement, and exact-lattice work without sampling a different code path. + */ +struct FVoxelTileClassificationStats +{ + uint32 RefineNodes = 0; + uint32 StackBoxCalls = 0; + uint32 WholeMixedNodes = 0; + uint32 WholeSolidNodes = 0; + uint32 WholeAirNodes = 0; + uint32 NeedsFinalFieldNodes = 0; + uint32 SplitNodes = 0; + uint32 MaxRefinementDepth = 0; + uint32 ExactCoreSamples = 0; + uint32 ExactFinalSamples = 0; + uint32 ExactCoreIntervalChecks = 0; + uint32 ExactCoreIntervalDisagreements = 0; + uint32 ExactCoreCacheHits = 0; + uint32 ExactFinalCacheHits = 0; + uint32 ExactCoreLeaves = 0; + uint32 ExactFinalLeaves = 0; + uint64 StackBoxCycles = 0; + uint64 ExactCoreCycles = 0; + uint64 ExactFinalCycles = 0; + uint32 RoomTailQueries = 0; + uint32 RoomTailEvaluated = 0; + uint64 RoomTailCycles = 0; + uint64 RoomPropagateCycles = 0; + uint64 RoomExactPrimitiveCycles = 0; + uint64 RoomCacheWindowCycles = 0; + int32 RoomNumRooms = 0; + int32 RoomNumTunnels = 0; + int32 RoomNumRoomFloorJoins = 0; + int32 RoomNumPits = 0; + int32 RoomNumChimneys = 0; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelHeightOp.h b/Standalone/Source/VoxelForge/Public/VoxelHeightOp.h new file mode 100644 index 0000000..48a67e2 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelHeightOp.h @@ -0,0 +1,273 @@ +// VoxelHeightOp.h +// L'ESPACE DES HAUTEURS — une seconde famille d'opérateurs, et pourquoi elle DOIT exister. +// HEIGHT SPACE — a second operator family, and why it has to exist. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// LE CONSTAT QUI FORCE CE FICHIER +// ───────────────────────────────────────────────────────────────────────────────────────── +// `OPSTACK-DECOMPOSITION §5` décompose SurfaceWorld ainsi : +// +// FHeightfieldSource ← toute la chaîne de colonne, XY-pure +// ├─ FStructuralHeightField +// ├─ FCliffHeightMod +// ├─ FTerraceHeightMod +// ├─ FLayerLineHeightMod +// └─ FBeachHeightMod +// +// et note, sans en tirer la conséquence : *« les ops de hauteur opèrent sur des valeurs Z dans la +// colonne, pas sur la densité »*. En lisant `ComputeSurfaceTerrainZ`, c'est littéralement vrai : +// c'est une suite de blocs qui lisent et écrivent **un seul float `Terrain`**, une altitude. +// +// **Ils ne rentrent donc PAS dans `IVoxelDensityOp`.** Sa signature est +// `Eval(x, y, z, FVoxelOpSample&)` — par voxel, deux canaux densité/SDF. Un op de hauteur n'a pas +// de Z d'entrée (il en PRODUIT un), ne veut pas être appelé par voxel (il est XY-pur, une fois par +// colonne), et n'écrit ni densité ni SDF. Les forcer dans le contrat densité demanderait soit un +// troisième canal par voxel — alors que la hauteur est une propriété de COLONNE, pas de voxel —, +// soit de replier les cinq en un seul op opaque, ce que `§2.5` appelle précisément l'échec du +// refactor. +// +// **Donc : une seconde famille, dans son propre espace.** C'est la même leçon que `§0.1` (il fallait +// un canal SDF en plus de la densité), un cran plus loin : certaines choses ne sont pas un canal de +// plus, elles sont un ESPACE de plus. +// +// The height ops read and write a single float ALTITUDE. They have no input Z (they produce one), +// are XY-pure (once per column, not per voxel), and write neither density nor SDF. Forcing them into +// IVoxelDensityOp would need either a per-voxel third channel for what is a COLUMN property, or +// collapsing all five into one opaque op — which §2.5 calls the failure mode. Hence a second family. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// CE QUE ÇA ACHÈTE / WHAT IT BUYS +// ───────────────────────────────────────────────────────────────────────────────────────── +// • **Le cache de colonne T1.a tombe naturellement.** Une pile de hauteur est XY-pure PAR +// CONSTRUCTION — il n'y a pas de Z à mettre dedans par erreur. `AUDIT §6.3` avertit qu'une donnée +// dépendante de Z glissée dans `FSurfaceColumn` corrompt silencieusement toute la pile verticale +// de chunks, et que `ValidateDeterminism` ne le verrait pas. Ici c'est le TYPE qui l'interdit. +// • **La composition d'idées de terrain devient de l'authoring**, comme pour la densité. +// • Les mêmes ops resserviront à VerticalShafts (ledges) et FloatingIslands. +// +// ⚠️ CE FICHIER NE TOUCHE PAS AU JEU. Il est bâti et exercé par +// `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui le compare à `ComputeSurfaceTerrainZ` point par +// point. Le branchement dans le chemin densité est l'étape SUIVANTE (§5 : `FHeightfieldSource`, +// `FSkyCapSource`, `FOverhangShelfMod`), délibérément séparée pour que la question d'architecture +// — *« l'espace des hauteurs se décompose-t-il vraiment ? »* — reçoive une réponse MESURÉE avant +// qu'on écrive l'adaptateur qui en dépend. + +#pragma once + +#include "CoreMinimal.h" +#include "Templates/UniquePtr.h" +#include "VoxelStrateTypes.h" // FSurfaceGenerationParams + +/** + * L'état qui traverse une pile de hauteur. DEUX canaux, exactement comme `FVoxelOpSample` — et + * pour la même raison : le code le fait déjà. + * + * `Relief` (le `M` de `SampleSurfaceStructuralZ`) est PRODUIT par la source structurelle et CONSOMMÉ + * par le gate du terrace (`TerraceStrength * M`). Sans ce second canal, le terrace devrait + * ré-échantillonner le champ de relief — plus lent, et surtout une occasion de diverger de la valeur + * que la source a réellement utilisée. + * + * Two channels, for the same reason as FVoxelOpSample: Relief (the `M` of the structural field) is + * produced by the source and consumed by the terrace gate. Threading it beats resampling it. + */ +struct FVoxelHeightSample +{ + /** Altitude monde en VOXELS (pas cm). */ + float Height = 0.0f; + + /** « Montagnosité » [0,1]. 1 = uniforme (ReliefStrength = 0). */ + float Relief = 1.0f; +}; + +/** + * Le résultat d'une requête de champ de biome en un XY : qui domine, qui est le voisin, et à quel + * poids on va vers lui dans la bande de frontière. + */ +struct FVoxelBiomeWeights +{ + int32 Dominant = 0; + int32 Neighbor = -1; + float NeighborWeight = 0.0f; // 0 ⇒ pas de mélange, le dominant seul +}; + +/** + * LE CHAMP DE BIOMES, VU COMME UNE INTERFACE — et c'est délibérément une interface, pas un pointeur + * vers `UVoxelGenerator`. + * + * Le résolveur de biome réel est une Voronoï warpée avec un cache par chunk, qui vit sur le + * générateur. Un opérateur ne doit PAS en dépendre : la Phase 3 veut que les opérateurs deviennent + * des DONNÉES (des assets), et un op qui tient un `UVoxelGenerator*` ne peut pas le devenir. En + * passant par cette interface, l'adaptateur qui connaît le générateur reste du côté du générateur, + * et l'opérateur ne connaît qu'« un truc qui répond (dominant, voisin, poids) en XY ». + * + * Deliberately an interface rather than a UVoxelGenerator*: Phase 3 wants ops to become data, and an + * op holding a generator pointer never can. The adapter that knows the generator stays on the + * generator's side; the op only knows "something that answers (dominant, neighbour, weight) at XY". + */ +class IVoxelBiomeField +{ +public: + virtual ~IVoxelBiomeField() = default; + + /** PURE en XY, et bit-identique quel que soit le thread ou l'ordre — même contrat que le reste + * de l'espace-hauteur, puisque le résultat alimente le cache de colonne T1.a. */ + virtual FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const = 0; +}; + +/** + * Un opérateur d'espace-hauteur. Trois différences avec `IVoxelDensityOp`, toutes voulues : + * • pas de Z d'entrée — la pile en PRODUIT un ; + * • XY-pur par construction, donc pas de `IsXYPure()` à déclarer ni à oublier ; + * • pas de `PrepareChunk` — ces ops sont déjà appelés une fois par colonne, ce qui EST la + * granularité que `PrepareChunk` sert à obtenir côté densité. + */ +class IVoxelHeightOp +{ +public: + virtual ~IVoxelHeightOp() = default; + + /** + * INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (X, Y, seed, params). Le même XY + * évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le float + * BIT-IDENTIQUE — le cache de colonne T1.a est partagé sur toute la pile verticale de chunks, + * donc une impureté ici se propage à tous les Z d'un coup. + */ + virtual void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const = 0; + + /** + * Majorant CONSERVATIF du déplacement vertical que cet op peut ajouter, en voxels. + * Sert à borner la colonne pour un futur `ClassifyBox` exact du heightfield — la même logique + * que les bandes de `FSlabVoidSource`, qui prouvent 36-40 tuiles sur 60. + * Rendre trop grand coûte du CPU ; rendre trop petit serait un TROU. `FLT_MAX` = « je ne sais + * pas », toujours sûr, et c'est le défaut. + */ + virtual float MaxDisplacement() const { return FLT_MAX; } +}; + +/** + * Pile de hauteur : source → modificateurs, dans l'ordre. Déplaçable, pas copiable, exactement + * comme `FVoxelOpStack` et pour la même raison (elle POSSÈDE ses opérateurs). + */ +class FVoxelHeightStack +{ +public: + FVoxelHeightStack() = default; + FVoxelHeightStack(FVoxelHeightStack&&) = default; + FVoxelHeightStack& operator=(FVoxelHeightStack&&) = default; + FVoxelHeightStack(const FVoxelHeightStack&) = delete; + FVoxelHeightStack& operator=(const FVoxelHeightStack&) = delete; + + void Add(TUniquePtr Op) { Ops.Add(MoveTemp(Op)); } + int32 Num() const { return Ops.Num(); } + + /** L'altitude après toute la pile. */ + float EvalHeight(float WorldX, float WorldY) const + { + return EvalSample(WorldX, WorldY).Height; + } + + /** L'état complet (altitude + relief). */ + FVoxelHeightSample EvalSample(float WorldX, float WorldY) const + { + FVoxelHeightSample S; + for (const TUniquePtr& Op : Ops) { Op->Eval(WorldX, WorldY, S); } + return S; + } + + /** Somme des majorants. `FLT_MAX` dès qu'un seul op ne sait pas répondre. */ + float MaxTotalDisplacement() const + { + float Total = 0.0f; + for (const TUniquePtr& Op : Ops) + { + const float D = Op->MaxDisplacement(); + if (D >= FLT_MAX) { return FLT_MAX; } + Total += D; + } + return Total; + } + +private: + TArray> Ops; +}; + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelHeightOps +{ + /** + * La source structurelle : continents + montagnes + détail, sous une frame de warp. + * Produit `Height` ET `Relief`. Transcription littérale de `SampleSurfaceStructuralZ`. + * + * ⚠️ Rend un pointeur NON-POSSÉDANT via `OutSource` : `FCliffHeightMod` doit pouvoir + * RÉ-ÉCHANTILLONNER ce champ (4 fois, en différences centrées) et doit le faire sur la MÊME + * fonction, pas sur une copie qui pourrait dériver. La pile garde la propriété ; la source vit + * donc aussi longtemps que le modificateur qui la référence, parce que le constructeur de pile + * les ajoute ensemble et que la pile ne réordonne jamais. + */ + VOXELFORGE_API TUniquePtr MakeStructuralHeightSource( + const FSurfaceGenerationParams& P, int32 Seed, const IVoxelHeightOp** OutSource); + + /** Raidissement conditionné par la pente. Le seul op qui coûte des échantillons en plus + * (4 resamples structurels), et seulement quand il est activé. */ + VOXELFORGE_API TUniquePtr MakeCliffHeightMod( + const FSurfaceGenerationParams& P, const IVoxelHeightOp* StructuralSource); + + /** Plateaux quantifiés, gatés par le relief (`TerraceStrength * M`) — d'où le canal Relief. */ + VOXELFORGE_API TUniquePtr MakeTerraceHeightMod(const FSurfaceGenerationParams& P); + + /** Bandes sédimentaires : `Height -= sin(Height · 2π / Spacing) · Depth`. */ + VOXELFORGE_API TUniquePtr MakeLayerLineHeightMod(const FSurfaceGenerationParams& P); + + /** Aplatissement vers la ligne d'eau dans `BeachWidth`. */ + VOXELFORGE_API TUniquePtr MakeBeachHeightMod(const FSurfaceGenerationParams& P); + + /** + * La pile de hauteur complète de SurfaceWorld, dans l'ordre de `ComputeSurfaceTerrainZ` : + * structural → cliff → terrace → layer lines → beach + * + * L'ordre n'est PAS négociable : le terrace quantifie une hauteur que le cliff a déjà raidie, + * les layer lines se posent sur le résultat, et la plage écrase tout près de l'eau. C'est + * l'ordre du code d'origine, et le test échouerait bruyamment sur toute permutation. + */ + VOXELFORGE_API void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, + const FSurfaceGenerationParams& P, int32 Seed); + + /** La voûte : warp + gonflement signé + pendage vers le bas uniquement. C'est une ALTITUDE, + * donc un op de hauteur — la soustraction n'arrive qu'au combine côté densité. */ + VOXELFORGE_API TUniquePtr MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, + int32 Seed); + + /** La pile de plafond de SurfaceWorld. Un seul op aujourd'hui, et c'est une information : le + * plafond n'a pas d'équivalent des quatre modificateurs du sol. */ + VOXELFORGE_API void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack, + const FSurfaceGenerationParams& P, int32 Seed); + + /** + * LE COMBINER `Mask` — mélange de biomes, et `OPSTACK-DECOMPOSITION §5` en fait le prototype + * de la Phase 3 entière : « unifier strates et biomes » EST ce mécanisme, généralisé. + * + * Une pile de hauteur COMPLÈTE par biome, plus un champ qui dit lequel domine en (X,Y). Ce sont + * les **HAUTEURS** qui sont interpolées, pas les params — c'est ce que fait déjà le code + * d'origine, et c'est ce qui rend les frontières continues quelle que soit la différence de + * params entre deux biomes (interpoler des params ferait passer un terrace de « fort » à + * « faible » à travers des états intermédiaires qui n'ont de sens pour personne). + * + * Each biome gets a COMPLETE height stack; the HEIGHTS are lerped, not the params — which is + * what keeps borders continuous across any param difference. + * + * @param Field non possédé, doit survivre à la pile. `nullptr` ⇒ biome 0 partout. + */ + VOXELFORGE_API TUniquePtr MakeBiomeBlendHeightSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field); + + /** Idem pour le plafond — mais le plafond N'EST PAS mélangé : le code d'origine prend celui du + * biome DOMINANT seul. Reproduit tel quel, pas « amélioré » : une voûte interpolée changerait + * la silhouette du monde et ce portage n'est pas l'endroit pour décider ça. */ + VOXELFORGE_API TUniquePtr MakeBiomeSelectCeilingSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field); +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h new file mode 100644 index 0000000..c0ad880 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -0,0 +1,144 @@ +// VoxelMarchingCubesMesher.h +// Mesh lisse à partir du champ de densité du générateur. +// +// Principe: +// 1. Chaque cellule du chunk (cube 2x2x2 voxels) lit la densité aux 8 coins. +// 2. Un index 8 bits indique quels coins sont au-dessus du IsoLevel. +// 3. Les tables MC (EdgeTable + TriTable) donnent les triangles à générer. +// 4. On interpole la position du vertex le long de chaque arête traversée. +// +// LOD: Step > 1 échantillonne moins souvent → moins de triangles à distance. + +#pragma once + +#include "CoreMinimal.h" +#include +#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc. +#include "VoxelGenerator.h" +#include "VoxelMarchingCubesMesher.generated.h" + +// A run-scoped, read-only density lattice. The explorer owns the storage and installs the +// pointer only while its canonical tiles are being meshed. The lattice includes the one-point +// halo needed by every tile, so each world sample is generated once and copied into both adjacent +// tile scratch grids. It is deliberately not a UObject or a cache with eviction: ownership, +// lifetime, and iteration order remain explicit and deterministic. +struct FVoxelSharedDensityGrid +{ + FIntVector OriginVoxels = FIntVector::ZeroValue; + int32 Step = 1; + int32 Dim = 0; + TArray Samples; + + void Reset() + { + OriginVoxels = FIntVector::ZeroValue; + Step = 1; + Dim = 0; + Samples.Reset(); + } +}; + +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject +{ + GENERATED_BODY() + +public: + // Parse command-line switches on the module's game-thread startup. GenerateMesh is a worker + // entry point and therefore only reads the resulting values. + static void InitializeConsoleSwitches(); + + /** + * Génère le mesh d'une TUILE de clipmap (voir FVoxelTileKey). + * + * @param OriginVoxels - Coin min de la tuile en coords VOXEL (= Tile.OriginVoxels()). + * @param Step - Taille de cellule en voxels. La tuile couvre CellsPerAxis*Step voxels. + * @param CellsPerAxis - Nombre de cellules par axe. Les tuiles GROSSIÈRES en utilisent MOINS + * (gen moins chère, maillage plus grossier au loin) tout en couvrant la + * même étendue (extent = CellsPerAxis*Step). Niveau 0 = CHUNK_SIZE. + * @param OutCaptureGrid - CAPTURE-DURING-MESHING (optionnel). Si non-null ET CellsPerAxis==CHUNK_SIZE + * (tuile pleine résolution, Step==1<* OutCaptureGrid = nullptr, + int32 BandZMinVox = INT32_MIN, int32 BandZMaxVox = INT32_MAX, + int64* OutDensitySampleCount = nullptr, + FVoxelSharedDensityGrid* OutPreEditDensityGrid = nullptr, + const FVoxelSharedDensityGrid* InSharedDensityGrid = nullptr); + + //========================================================================= + // SERVICES (injectés par AVoxelWorld) + //========================================================================= + + // Le générateur fournit la densité par voxel. + UPROPERTY() + const UVoxelGenerator* Generator = nullptr; + + void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; } + + // AVoxelWorld installs its bShuttingDown flag here. The commandlet path leaves it null. + // This is intentionally read-only: workers may observe shutdown, but never mutate world state. + void SetShutdownFlag(const std::atomic* InShutdownFlag) { ShutdownFlag = InShutdownFlag; } + + // Optional run-scoped shared lattice. The caller must keep it alive and immutable until all + // GenerateMesh calls finish. A malformed/incomplete lattice is ignored and falls back to + // the canonical generator path rather than changing geometry. + void SetSharedDensityGrid(const FVoxelSharedDensityGrid* InGrid) { SharedDensityGrid = InGrid; } + +private: + FORCEINLINE bool ShouldAbortWork() const + { + return ShutdownFlag && ShutdownFlag->load(std::memory_order_relaxed); + } + + const std::atomic* ShutdownFlag = nullptr; + const FVoxelSharedDensityGrid* SharedDensityGrid = nullptr; + +public: + + //========================================================================= + // SETTINGS + //========================================================================= + + // Seuil de l'isosurface dans le champ de densité. + // Convention MC: densité < IsoLevel = solide, >= = air. + float IsoLevel = 0.0f; + + // SKIRTS — bouchent les fissures aux frontières de tuiles entre niveaux de clipmap voisins + // (résolutions différentes → les iso-surfaces ne se rejoignent pas exactement). Une jupe + // (mur court) est extrudée vers le solide depuis chaque arête de surface posée sur une des 6 + // faces externes de la tuile. Voir GenerateMesh. + bool bGenerateSkirts = true; + // Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). Four L0 cells preserve + // the 0.5 m default extrusion at 12.5 cm per voxel. + float SkirtCells = 4.0f; + + // Conservative 8x8x8 block classifier. Uniform blocks skip the marching-cubes cell loop; + // when every relevant block has the same verdict, the density grid is skipped as well. + // ClassifyTile is proof-only, so no samples, resolution, or geometry are removed. + // The tile classifier is the cheap, whole-tile proof. Keep the optional 8^3-block refinement + // available for focused diagnostics, but leave it disabled in streaming: repeating a proof on + // every mixed tile can cost more than the density grid it is meant to avoid. + bool bUseBlockEarlyOut = false; + + // T2.b — LOD-aware octave reduction (opt-in, copied from UVoxelSettings::LODOctaveDrop). + // Octaves dropped from per-voxel volumetric noise PER Step doubling: a tile at Step=S + // drops LODOctaveDrop * log2(S) octaves (see VoxelGenLOD in VoxelGenerator.h). + // 0 (default) = off — every LOD samples full octaves, byte-identical to before. + // Réduction d'octaves sur les tuiles grossières ; 0 = désactivé. + int32 LODOctaveDrop = 0; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelNoise.h b/Standalone/Source/VoxelForge/Public/VoxelNoise.h new file mode 100644 index 0000000..1ef9a08 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelNoise.h @@ -0,0 +1,438 @@ +// VoxelNoise.h +// Float, SIMD-batched gradient-noise core (T2.a). Replaces UE's double-precision +// FMath::PerlinNoise3D on the density hot path (~6.6 ms/chunk was all noise math). +// +// WHY THIS EXISTS +// FMath::PerlinNoise3D is double-precision with a permutation-table lookup. The +// density field calls it >1M times per surface chunk (fBm octaves + domain warps). +// This core is: +// - FLOAT (no double math), +// - table-free hash-gradient Perlin (pure arithmetic → vectorizes cleanly), +// - SIMD-BATCHED across fBm octaves: one FractalNoise/Ridged call evaluates up to +// 4 octaves' Perlin samples in one 4-wide SSE pass. +// +// It is a DIFFERENT noise field than FMath's, so worlds re-tune ONCE (accepted). +// +// DETERMINISM / CACHES +// Perlin3D is a pure function of (x,y,z) exactly like the old call — every box-validity +// cache (SDF §8.10, biome, surface column) stays valid. No invariant changes. +// +// SCALAR vs SIMD +// Perlin3D (scalar) and Perlin3D_x4 (SSE) use the IDENTICAL formula, op-for-op, so on +// x86 (SSE math == scalar-float math, same IEEE rounding) they produce bit-identical +// results. The SSE path is therefore a pure speedup with no second re-tune. If it ever +// fails to build on a given toolchain, force the scalar fallback with one line: +// #define VF_NOISE_USE_SIMD 0 // (before including this header, or here) +// The scalar path alone is still a large win over the old double-precision core. +// +// REQUIRES: SSE4.1 for the SIMD path (UE5 x64 baseline is SSE4.2 → always available). + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" + +#if !defined(VF_NOISE_USE_SIMD) + #if PLATFORM_ENABLE_VECTORINTRINSICS && PLATFORM_CPU_X86_FAMILY + #define VF_NOISE_USE_SIMD 1 + #else + #define VF_NOISE_USE_SIMD 0 + #endif +#endif + +#if VF_NOISE_USE_SIMD + #include // SSE4.1: _mm_floor_ps / _mm_mullo_epi32 / _mm_blendv_ps +#endif + +namespace VoxelNoise +{ +namespace Detail +{ + // Table-free integer hash of a lattice corner → gradient selector. Pure mul/xor/shift + // so it vectorizes 1:1 (see the SSE Hash lambda below — must stay in lock-step). + FORCEINLINE uint32 HashCorner(int32 ix, int32 iy, int32 iz) + { + uint32 h = (uint32)ix * 0x9E3779B1u + ^ (uint32)iy * 0x85EBCA77u + ^ (uint32)iz * 0xC2B2AE3Du; + h ^= h >> 15; h *= 0x2C1B3C6Du; + h ^= h >> 12; h *= 0x297A2D39u; + h ^= h >> 15; + return h; + } + + // Quintic fade 6t^5-15t^4+10t^3, factored as t^3 * (t*(6t-15)+10) so the SSE twin + // can mirror the exact grouping. + FORCEINLINE float Fade(float t) + { + const float inner = t * (t * 6.0f - 15.0f) + 10.0f; + const float t3 = t * t * t; + return t3 * inner; + } + + // Ken Perlin's 12-gradient dot (hash&15 picks the gradient). Branchy form here; + // the SSE twin reproduces it with selects. + FORCEINLINE float GradDot(uint32 hash, float x, float y, float z) + { + const uint32 h = hash & 15u; + const float u = (h & 8u) == 0u ? x : y; + float v; + if ((h & 12u) == 0u) v = y; // h < 4 + else if ((h & 13u) == 12u) v = x; // h == 12 or 14 + else v = z; + const float ru = (h & 1u) == 0u ? u : -u; + const float rv = (h & 2u) == 0u ? v : -v; + return ru + rv; + } + + FORCEINLINE float Lerp(float a, float b, float t) { return a + t * (b - a); } +} + +// Single 3D Perlin sample, ~[-1,1] (typically [-0.7,0.7], same character as the old core +// so VOXEL_NOISE_SCALE still applies). Used for all single-sample domain warps. +FORCEINLINE float Perlin3D(float x, float y, float z) +{ + using namespace Detail; + const float xf = FMath::FloorToFloat(x); + const float yf = FMath::FloorToFloat(y); + const float zf = FMath::FloorToFloat(z); + const int32 X = (int32)xf, Y = (int32)yf, Z = (int32)zf; + const float fx = x - xf, fy = y - yf, fz = z - zf; + const float su = Fade(fx), sv = Fade(fy), sw = Fade(fz); + + const uint32 h000 = HashCorner(X, Y, Z ); + const uint32 h100 = HashCorner(X+1, Y, Z ); + const uint32 h010 = HashCorner(X, Y+1, Z ); + const uint32 h110 = HashCorner(X+1, Y+1, Z ); + const uint32 h001 = HashCorner(X, Y, Z+1); + const uint32 h101 = HashCorner(X+1, Y, Z+1); + const uint32 h011 = HashCorner(X, Y+1, Z+1); + const uint32 h111 = HashCorner(X+1, Y+1, Z+1); + + const float fx1 = fx - 1.0f, fy1 = fy - 1.0f, fz1 = fz - 1.0f; + const float n000 = GradDot(h000, fx, fy, fz ); + const float n100 = GradDot(h100, fx1, fy, fz ); + const float n010 = GradDot(h010, fx, fy1, fz ); + const float n110 = GradDot(h110, fx1, fy1, fz ); + const float n001 = GradDot(h001, fx, fy, fz1); + const float n101 = GradDot(h101, fx1, fy, fz1); + const float n011 = GradDot(h011, fx, fy1, fz1); + const float n111 = GradDot(h111, fx1, fy1, fz1); + + const float x00 = Lerp(n000, n100, su); + const float x10 = Lerp(n010, n110, su); + const float x01 = Lerp(n001, n101, su); + const float x11 = Lerp(n011, n111, su); + const float y0 = Lerp(x00, x10, sv); + const float y1 = Lerp(x01, x11, sv); + return Lerp(y0, y1, sw); +} + +FORCEINLINE float Perlin3D(const FVector& P) +{ + return Perlin3D((float)P.X, (float)P.Y, (float)P.Z); +} + +// Hot density callers already own float voxel coordinates. Keeping this overload separate from +// FVector avoids constructing UE5's double-precision FVector merely to round the coordinates back +// to float at the noise entry point. It is the same scalar formula and remains deterministic on +// every build of the module. +FORCEINLINE float Perlin3D(const FVector3f& P) +{ + return Perlin3D(P.X, P.Y, P.Z); +} + +//============================================================================= +// LIPSCHITZ BOUND FOR THE ACTUAL 3D FIELD +//============================================================================= +// This is deliberately derived from the implementation above, rather than borrowed from a +// textbook Perlin constant. GradDot's h&15 table has the twelve UNNORMALIZED gradients +// +// (±1,±1,0), (±1,0,±1), (0,±1,±1) +// +// (the four duplicate selectors do not add any vectors). For one partial derivative, write the +// four x-edges of the trilinear interpolation as independent gradient pairs. With +// d = Fade'(x) = 30 x²(1-x)², each edge contributes the support of the actual gradient set at +// +// c0 = (1-x-dx, -dy, -dz), c1 = (x-d(1-x), dy, dz). +// +// The support is max(|a|+|b|, |a|+|c|, |b|+|c|), because every allowed gradient has exactly two +// non-zero ±1 components. Taking the four y/z edge weights and the exact fade derivative, a +// finite case split over those three support branches has supremum 15/4 on [0,1]^3 (attained at +// x=y=z=1/2). This maximization permits every corner hash to choose any member of the actual +// set, so it is an upper bound for every hash cell; it is also the supremum of that finite support +// relaxation, not the looser 1 + 4*max(Fade') = 8.5 bound used by the unrelated 2D floor relief. +// +// The vector bound below follows the same per-axis-bound × sqrt(dimension) method used by the +// floor-relief proof. Abs is 1-Lipschitz and VOXEL_NOISE_SCALE is positive, so the scaled N1 +// field has this bound in noise-space coordinates. WormBlockSkip computes its radius after the +// anisotropic world->noise transform, so the HBias and VerticalScale factors are included there. +static constexpr double Perlin3DPartialAbsBound = 15.0 / 4.0; +static constexpr double Perlin3DGradientBound = + Perlin3DPartialAbsBound * 1.732050807568877293527446341505872L; +static constexpr double WormN1NoiseSpaceLipschitzBound = + static_cast(VOXEL_NOISE_SCALE) * Perlin3DGradientBound; + +// A four-point side gives the natural small worker-local blocks for the mesher's 35-point +// (32 cells + one-sample normal halo) LOD0 lattice. The cache is only an optimization; a failed +// proof falls through to the original per-sample N1/N2 code. +static constexpr int32 WormBlockSampleSide = 4; + +// The center value is evaluated with the same float Perlin expression as N1. The endpoint noise +// coordinates are already float results of monotone positive/negative affine transforms, so their +// min/max box contains every float lattice sample exactly. The 1e-3 scaled-N1 margin covers the +// remaining center-evaluation/interpolation rounding with substantial headroom; the proof remains +// strict because both the Lipschitz term and this margin are subtracted before comparing with T. +static constexpr double WormBlockOutputRoundMargin = 1.0e-3; + +FORCEINLINE bool ProvesScaledAbsPerlin3DAbove( + const FVector3f& CenterNoisePosition, + const FVector3f& MinNoisePosition, + const FVector3f& MaxNoisePosition, + float Threshold, + double ScaledFieldLipschitzBound) +{ + if (!FMath::IsFinite(CenterNoisePosition.X) + || !FMath::IsFinite(CenterNoisePosition.Y) + || !FMath::IsFinite(CenterNoisePosition.Z) + || !FMath::IsFinite(MinNoisePosition.X) + || !FMath::IsFinite(MinNoisePosition.Y) + || !FMath::IsFinite(MinNoisePosition.Z) + || !FMath::IsFinite(MaxNoisePosition.X) + || !FMath::IsFinite(MaxNoisePosition.Y) + || !FMath::IsFinite(MaxNoisePosition.Z) + || !FMath::IsFinite(Threshold) + || !(Threshold > 0.0f) + || !FMath::IsFinite(ScaledFieldLipschitzBound) + || !(ScaledFieldLipschitzBound > 0.0)) + { + return false; + } + + const double Dx = FMath::Max( + FMath::Abs(static_cast(MinNoisePosition.X) + - static_cast(CenterNoisePosition.X)), + FMath::Abs(static_cast(MaxNoisePosition.X) + - static_cast(CenterNoisePosition.X))); + const double Dy = FMath::Max( + FMath::Abs(static_cast(MinNoisePosition.Y) + - static_cast(CenterNoisePosition.Y)), + FMath::Abs(static_cast(MaxNoisePosition.Y) + - static_cast(CenterNoisePosition.Y))); + const double Dz = FMath::Max( + FMath::Abs(static_cast(MinNoisePosition.Z) + - static_cast(CenterNoisePosition.Z)), + FMath::Abs(static_cast(MaxNoisePosition.Z) + - static_cast(CenterNoisePosition.Z))); + const double Radius = FMath::Sqrt(Dx * Dx + Dy * Dy + Dz * Dz); + + // Keep this operation in float and in the same order as the production N1 expression. The + // double conversion is only for the conservative comparison below. + const float CenterN1 = FMath::Abs( + Perlin3D(CenterNoisePosition) * VOXEL_NOISE_SCALE); + if (!FMath::IsFinite(CenterN1)) + { + return false; + } + + return static_cast(CenterN1) + - ScaledFieldLipschitzBound * Radius + >= static_cast(Threshold) + WormBlockOutputRoundMargin; +} + +//============================================================================= +// 4-WIDE BATCH — the SIMD multiplier. Computes 4 independent Perlin samples. +// Inputs are 4-element arrays; unused lanes must be zero-filled by the caller +// (FBM/Ridged below do). Results written to Out[0..3]. +//============================================================================= +#if VF_NOISE_USE_SIMD +FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs, float* Out) +{ + const __m128 x = _mm_loadu_ps(Xs); + const __m128 y = _mm_loadu_ps(Ys); + const __m128 z = _mm_loadu_ps(Zs); + + const __m128 xf = _mm_floor_ps(x); + const __m128 yf = _mm_floor_ps(y); + const __m128 zf = _mm_floor_ps(z); + + const __m128i X = _mm_cvttps_epi32(xf); + const __m128i Y = _mm_cvttps_epi32(yf); + const __m128i Z = _mm_cvttps_epi32(zf); + + const __m128 fx = _mm_sub_ps(x, xf); + const __m128 fy = _mm_sub_ps(y, yf); + const __m128 fz = _mm_sub_ps(z, zf); + + const __m128 c6 = _mm_set1_ps(6.0f); + const __m128 c15 = _mm_set1_ps(15.0f); + const __m128 c10 = _mm_set1_ps(10.0f); + auto Fade = [&](const __m128 t) -> __m128 + { + const __m128 inner = _mm_add_ps(_mm_mul_ps(t, _mm_sub_ps(_mm_mul_ps(t, c6), c15)), c10); + const __m128 t3 = _mm_mul_ps(_mm_mul_ps(t, t), t); + return _mm_mul_ps(t3, inner); + }; + const __m128 su = Fade(fx); + const __m128 sv = Fade(fy); + const __m128 sw = Fade(fz); + + const __m128i one = _mm_set1_epi32(1); + const __m128i X1 = _mm_add_epi32(X, one); + const __m128i Y1 = _mm_add_epi32(Y, one); + const __m128i Z1 = _mm_add_epi32(Z, one); + + const __m128i k1 = _mm_set1_epi32((int32)0x9E3779B1u); + const __m128i k2 = _mm_set1_epi32((int32)0x85EBCA77u); + const __m128i k3 = _mm_set1_epi32((int32)0xC2B2AE3Du); + const __m128i m1 = _mm_set1_epi32((int32)0x2C1B3C6Du); + const __m128i m2 = _mm_set1_epi32((int32)0x297A2D39u); + auto Hash = [&](const __m128i ix, const __m128i iy, const __m128i iz) -> __m128i + { + __m128i h = _mm_xor_si128(_mm_xor_si128(_mm_mullo_epi32(ix, k1), + _mm_mullo_epi32(iy, k2)), + _mm_mullo_epi32(iz, k3)); + h = _mm_xor_si128(h, _mm_srli_epi32(h, 15)); h = _mm_mullo_epi32(h, m1); + h = _mm_xor_si128(h, _mm_srli_epi32(h, 12)); h = _mm_mullo_epi32(h, m2); + h = _mm_xor_si128(h, _mm_srli_epi32(h, 15)); + return h; + }; + + const __m128 fx1 = _mm_sub_ps(fx, _mm_set1_ps(1.0f)); + const __m128 fy1 = _mm_sub_ps(fy, _mm_set1_ps(1.0f)); + const __m128 fz1 = _mm_sub_ps(fz, _mm_set1_ps(1.0f)); + + const __m128i i8 = _mm_set1_epi32(8); + const __m128i i12 = _mm_set1_epi32(12); + const __m128i i13 = _mm_set1_epi32(13); + const __m128i i15 = _mm_set1_epi32(15); + const __m128i i1 = _mm_set1_epi32(1); + const __m128i i2 = _mm_set1_epi32(2); + const __m128i izero = _mm_setzero_si128(); + const __m128 sgn = _mm_set1_ps(-0.0f); + auto Grad = [&](const __m128i hash, const __m128 gx, const __m128 gy, const __m128 gz) -> __m128 + { + const __m128i h = _mm_and_si128(hash, i15); + // u = (h&8)==0 ? gx : gy + const __m128 mU = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i8), izero)); + const __m128 u = _mm_blendv_ps(gy, gx, mU); + // v = (h&12)==0 ? gy : ((h&13)==12 ? gx : gz) + const __m128 mLt4 = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i12), izero)); + const __m128 m1214 = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i13), i12)); + const __m128 vTmp = _mm_blendv_ps(gz, gx, m1214); + const __m128 v = _mm_blendv_ps(vTmp, gy, mLt4); + // signs: (h&1)? -u:u + (h&2)? -v:v + const __m128 mNegU = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i1), i1)); + const __m128 mNegV = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i2), i2)); + const __m128 ru = _mm_blendv_ps(u, _mm_xor_ps(u, sgn), mNegU); + const __m128 rv = _mm_blendv_ps(v, _mm_xor_ps(v, sgn), mNegV); + return _mm_add_ps(ru, rv); + }; + + const __m128 n000 = Grad(Hash(X, Y, Z ), fx, fy, fz ); + const __m128 n100 = Grad(Hash(X1, Y, Z ), fx1, fy, fz ); + const __m128 n010 = Grad(Hash(X, Y1, Z ), fx, fy1, fz ); + const __m128 n110 = Grad(Hash(X1, Y1, Z ), fx1, fy1, fz ); + const __m128 n001 = Grad(Hash(X, Y, Z1), fx, fy, fz1); + const __m128 n101 = Grad(Hash(X1, Y, Z1), fx1, fy, fz1); + const __m128 n011 = Grad(Hash(X, Y1, Z1), fx, fy1, fz1); + const __m128 n111 = Grad(Hash(X1, Y1, Z1), fx1, fy1, fz1); + + auto Lerp = [&](const __m128 a, const __m128 b, const __m128 t) -> __m128 + { + return _mm_add_ps(a, _mm_mul_ps(t, _mm_sub_ps(b, a))); + }; + const __m128 x00 = Lerp(n000, n100, su); + const __m128 x10 = Lerp(n010, n110, su); + const __m128 x01 = Lerp(n001, n101, su); + const __m128 x11 = Lerp(n011, n111, su); + const __m128 y0 = Lerp(x00, x10, sv); + const __m128 y1 = Lerp(x01, x11, sv); + _mm_storeu_ps(Out, Lerp(y0, y1, sw)); +} +#else +FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs, float* Out) +{ + for (int32 i = 0; i < 4; ++i) Out[i] = Perlin3D(Xs[i], Ys[i], Zs[i]); +} +#endif + +// Three independent channels are the hot cave-warp shape. On x86, evaluate them as three lanes +// of the existing four-wide implementation; the unused fourth lane is deliberate. The scalar +// fallback keeps the exact three-call behavior on platforms without the SIMD path. This helper is +// the owner of the cave-warp channel evaluation so generator, op-stack, and room-fit callers cannot +// drift into different noise formulas or channel offsets. +FORCEINLINE void Perlin3D_x3( + float X0, float Y0, float Z0, + float X1, float Y1, float Z1, + float X2, float Y2, float Z2, + float& Out0, float& Out1, float& Out2) +{ +#if VF_NOISE_USE_SIMD + const float Xs[4] = { X0, X1, X2, X0 }; + const float Ys[4] = { Y0, Y1, Y2, Y0 }; + const float Zs[4] = { Z0, Z1, Z2, Z0 }; + float Out[4]; + Perlin3D_x4(Xs, Ys, Zs, Out); + Out0 = Out[0]; + Out1 = Out[1]; + Out2 = Out[2]; +#else + Out0 = Perlin3D(X0, Y0, Z0); + Out1 = Perlin3D(X1, Y1, Z1); + Out2 = Perlin3D(X2, Y2, Z2); +#endif +} + +//============================================================================= +// fBm / Ridged — octaves evaluated 4 at a time through Perlin3D_x4. +// Accumulation stays scalar in octave order, so the result is independent of +// whether the SIMD or scalar Perlin3D_x4 is used (bit-identical either way). +//============================================================================= +FORCEINLINE float FBM(float x, float y, float z, + int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f) +{ + float Total = 0.0f, MaxValue = 0.0f, Freq = 1.0f, Amp = 1.0f; + for (int32 o = 0; o < Octaves; ) + { + const int32 N = FMath::Min(4, Octaves - o); + float Xs[4] = {}, Ys[4] = {}, Zs[4] = {}, Out[4]; + float f = Freq; + for (int32 i = 0; i < N; ++i) { Xs[i] = x * f; Ys[i] = y * f; Zs[i] = z * f; f *= Lacunarity; } + Perlin3D_x4(Xs, Ys, Zs, Out); + for (int32 i = 0; i < N; ++i) { Total += Out[i] * Amp; MaxValue += Amp; Amp *= Persistence; } + Freq = f; + o += N; + } + return Total / MaxValue; +} + +FORCEINLINE float Ridged(float x, float y, float z, + int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f) +{ + // Matches the original RidgedNoise3D fold exactly (NS scale, square, weight feedback). + static constexpr float NS = 1.25f; + float Total = 0.0f, MaxValue = 0.0f, Freq = 1.0f, Amp = 1.0f, Weight = 1.0f; + for (int32 o = 0; o < Octaves; ) + { + const int32 N = FMath::Min(4, Octaves - o); + float Xs[4] = {}, Ys[4] = {}, Zs[4] = {}, Out[4]; + float f = Freq; + for (int32 i = 0; i < N; ++i) { Xs[i] = x * f; Ys[i] = y * f; Zs[i] = z * f; f *= Lacunarity; } + Perlin3D_x4(Xs, Ys, Zs, Out); + for (int32 i = 0; i < N; ++i) + { + float Nn = Out[i] * NS; + Nn = 1.0f - FMath::Abs(Nn); // fold → ridge at zero-crossings + Nn = Nn * Nn; // sharpen + Nn *= Weight; // detail follows previous ridge + Weight = FMath::Clamp(Nn * 2.0f, 0.0f, 1.0f); + Total += Nn * Amp; MaxValue += Amp; Amp *= Persistence; + } + Freq = f; + o += N; + } + return (Total / MaxValue) * 2.0f - 1.0f; +} + +} // namespace VoxelNoise diff --git a/Standalone/Source/VoxelForge/Public/VoxelPassageGeometry.h b/Standalone/Source/VoxelForge/Public/VoxelPassageGeometry.h new file mode 100644 index 0000000..02e47f8 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelPassageGeometry.h @@ -0,0 +1,711 @@ +// Shared dimensions and construction laws for walkable passages and origin landings. +// +// This header is deliberately free of UObjects. The density path, the operator stack, the +// passage generator, and the headless measurements all include the same arithmetic so a geometry +// change cannot leave one of those paths with a different room or slope contract. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" + +namespace VoxelPassageGeometry +{ + // Authored scale and player body used by the §6.5 landing calculation. + constexpr float VoxelSizeMeters = VOXEL_SIZE / 100.0f; + constexpr float MetersToVoxels(float Meters) + { + return Meters / VoxelSizeMeters; + } + constexpr float PlayerRadiusVoxels = MetersToVoxels(0.34f); + constexpr float PlayerHalfHeightVoxels = MetersToVoxels(0.88f); + constexpr float PlayerHeightVoxels = 2.0f * PlayerHalfHeightVoxels; + constexpr float HeadroomVoxels = MetersToVoxels(1.0f); + constexpr float MinimumTurnFloorWidthVoxels = MetersToVoxels(3.0f); + constexpr float MinimumLandingHalfWidthVoxels = + MinimumTurnFloorWidthVoxels * 0.5f; + constexpr float MinimumRoomHeightVoxels = MetersToVoxels(3.0f); + constexpr float LandingFloorThicknessVoxels = MetersToVoxels(0.75f); + constexpr float MaxStepHeightVoxels = MetersToVoxels(0.45f); + // The A-to-B walk corridor is budgeted as a full player-width rectangle. Its vertical + // envelope starts above the legal step and extends one player height above that raised floor. + constexpr float WalkCorridorPlayerWidthVoxels = MetersToVoxels(0.68f); + constexpr float WalkCorridorPlayerHeightVoxels = MetersToVoxels(1.76f); + constexpr float WalkCorridorLatticeMarginVoxels = MetersToVoxels(0.25f); + constexpr float SealSafetyMarginVoxels = MetersToVoxels(1.0f); + constexpr float RoomRoundingVoxels = MetersToVoxels(0.375f); + constexpr float LandingCarveBlendVoxels = MetersToVoxels(1.0f); + // The origin landing is reserved for the same straight spine that the vertical-shaft + // generator uses: OriginSpineRadius on each side, with a six-voxel / 0.75 m floor ledge + // around it after the support patch's two-voxel inset. At the default radius (28 voxels) this + // is a 28-voxel / 7 m future shaft diameter. + constexpr float OriginShaftFloorClearanceVoxels = MetersToVoxels(1.0f); + // Level tunnel apron outside each room-floor edge. It covers the room/tunnel SDF blend and + // keeps the ramp from starting while the route is still over the room's square support patch. + constexpr float WalkableTunnelLandingApronVoxels = MetersToVoxels(1.5f); + // A level transition after a bend lets the route turn before its sloped segment begins. + constexpr float WalkableTunnelTurnTransitionVoxels = MetersToVoxels(1.5f); + // Keep the tunnel-air post 12.5 cm above the authored support plane. This is a numerical + // ownership band: the floor writer owns the plane, while the air writer owns the capsule + // volume above it even when two overlapping passage projections differ by a fraction. + constexpr float WalkableTunnelFloorAirClearanceVoxels = MetersToVoxels(0.125f); + + // A 15 degree ramp is comfortably below the 44 degree engine walkability ceiling. The + // latter is a scramble limit, not a sensible default for a tunnel the player walks through. + constexpr float WalkableTunnelMaxGradientDegrees = 15.0f; + constexpr float WalkableTunnelMaxGradient = 0.2679491924311227f; // tan(15 degrees) + // Player-fit measurements use the engine's 44 degree walkability contract. The 15 degree + // value above remains the preferred authored tunnel ramp; this wider bound is only the + // fallback threshold at which a room-to-room floor must become a terrace. + constexpr float PlayerWalkableFloorMaxGradient = 0.9656887748070738f; // tan(44 degrees) + + // TunnelFloorGentleSlopeThreshold is authored as degrees. Zero is still a legal UI value, + // but the construction path uses this small positive floor so a malformed/overly strict + // value cannot turn the graph collect window into an unbounded switchback search. + constexpr float TunnelFloorGentleSlopeMinimumDegrees = 1.0f; + constexpr float TunnelFloorGentleSlopeMaximumDegrees = 90.0f; + // The route solver uses at most six half-waves. The canonical tunnel envelope is the default + // 360-voxel tunnel reach plus its endpoint correction; round that direct-run envelope up to + // 640 voxels, then derive the total cap from the same wave limit. This leaves room above the + // proven canonical route while keeping the 0.1-degree collect window finite and bounded. + constexpr int32 TunnelFloorMaximumWindWaves = 6; + constexpr float TunnelFloorMaximumSingleWaveRunVoxels = MetersToVoxels(160.0f); + constexpr float TunnelFloorMaximumRouteLengthVoxels = + static_cast(TunnelFloorMaximumWindWaves) + * TunnelFloorMaximumSingleWaveRunVoxels; + + FORCEINLINE float EffectiveTunnelFloorGentleSlopeDegrees(float AuthoredDegrees) + { + if (!VoxelMath::IsFinite(AuthoredDegrees)) + { + return 44.0f; + } + return FMath::Clamp( + FMath::Max(AuthoredDegrees, TunnelFloorGentleSlopeMinimumDegrees), + TunnelFloorGentleSlopeMinimumDegrees, + TunnelFloorGentleSlopeMaximumDegrees); + } + + FORCEINLINE float TunnelFloorGradientFromDegrees(float AuthoredDegrees) + { + const float Degrees = EffectiveTunnelFloorGentleSlopeDegrees(AuthoredDegrees); + float SinValue = 0.0f; + float CosValue = 0.0f; + VoxelMath::DetSinCos( + SinValue, CosValue, Degrees * (PI / 180.0f)); + // At 90 degrees the mathematical tangent is unbounded. The route solver only needs a + // finite comparison, so preserve the ordering while keeping every downstream bound finite. + return FMath::Min( + FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f), + 4096.0f); + } + + // The walkability contract is about the floor carried by the tube, not only its centreline. + // A tapered tube changes that floor by the radius delta, so the construction and the audit + // use this same conservative floor delta. + FORCEINLINE float TunnelFloorZ(const FVector& Centreline, float Radius) + { + return Centreline.Z - FMath::Abs(Radius); + } + + FORCEINLINE float NativePassageFloorSupportRadius(float StartRadius, float EndRadius) + { + return FMath::Max( + FMath::Min(FMath::Abs(StartRadius), FMath::Abs(EndRadius)) + - MetersToVoxels(0.125f), + PlayerRadiusVoxels); + } + + // Keep two switchback floor branches apart far enough that the upper branch's final floor + // post cannot enter the lower player's full-width walk corridor. The 0.25 m margin covers + // the scalar lattice/interpolation boundary; Eval displacement is already followed by the + // MC-facing tunnel and native-floor stages before collision density is returned. + FORCEINLINE float WalkableTunnelTurnLegSeparation( + float StartRadius, float EndRadius) + { + return NativePassageFloorSupportRadius(StartRadius, EndRadius) + + 0.5f * WalkCorridorPlayerWidthVoxels + + WalkCorridorLatticeMarginVoxels; + } + + // Vertical-shaft tree links are round air capsules, but the player-fit graph needs a + // deterministic support plane to walk between shafts. Keep that plane on the first + // seal-safe ledge shared by the whole strate, so every tree edge has the same walk level. + // This is shaft-network support, not an origin landing connector. + FORCEINLINE float VerticalShaftConnectorFloorThickness(float Radius) + { + return FMath::Min( + LandingFloorThicknessVoxels, + FMath::Max(MetersToVoxels(0.125f), FMath::Abs(Radius) * 0.5f)); + } + + FORCEINLINE float VerticalShaftConnectorFloorZ( + float StrateBottomZ, + float InteriorBottomZ, + float InteriorTopZ, + float LedgeSpacing, + float LedgeDepth, + float ConnectorRadius) + { + const float FloorThickness = VerticalShaftConnectorFloorThickness(ConnectorRadius); + const float MinimumFloor = InteriorBottomZ + + FloorThickness + SealSafetyMarginVoxels; + const float MaximumFloor = InteriorTopZ + - PlayerHeightVoxels - SealSafetyMarginVoxels; + if (!VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(InteriorBottomZ) + || !VoxelMath::IsFinite(InteriorTopZ) + || !VoxelMath::IsFinite(LedgeSpacing) + || !VoxelMath::IsFinite(LedgeDepth) + || !VoxelMath::IsFinite(MinimumFloor) + || !VoxelMath::IsFinite(MaximumFloor) + || !(MaximumFloor > MinimumFloor)) + { + return 0.5f * (InteriorBottomZ + InteriorTopZ); + } + + if (LedgeSpacing > 0.0f && LedgeDepth > 0.0f) + { + const float RelativeBottom = InteriorBottomZ - StrateBottomZ; + const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / LedgeSpacing); + // The small fixed bound keeps malformed authored values from turning generation into + // an unbounded search. Normal shaft strates find the first or second period. + constexpr int32 MaxPeriods = 64; + for (int32 PeriodOffset = 0; PeriodOffset < MaxPeriods; ++PeriodOffset) + { + const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) + * LedgeSpacing; + const float Candidate = StrateBottomZ + PeriodStart + LedgeDepth; + if (Candidate > MinimumFloor && Candidate < MaximumFloor) + { + return Candidate; + } + } + } + + return FMath::Clamp( + 0.5f * (InteriorBottomZ + InteriorTopZ), MinimumFloor, MaximumFloor); + } + + FORCEINLINE float VerticalShaftConnectorCenterZ( + float StrateBottomZ, + float InteriorBottomZ, + float InteriorTopZ, + float LedgeSpacing, + float LedgeDepth, + float ConnectorRadius) + { + const float SafeRadius = FMath::Abs(ConnectorRadius); + const float FloorThickness = VerticalShaftConnectorFloorThickness(SafeRadius); + return VerticalShaftConnectorFloorZ( + StrateBottomZ, InteriorBottomZ, InteriorTopZ, + LedgeSpacing, LedgeDepth, SafeRadius) + + SafeRadius - FloorThickness; + } + + /** Add a flat, inset support band below one horizontal shaft-tree capsule. */ + FORCEINLINE void VF_ApplyVerticalShaftConnectorFloor( + float& Density, + float WorldX, + float WorldY, + float WorldZ, + const FVector& Start, + const FVector& End, + float Radius, + float FloorZ, + float BaseDensity) + { + const float SafeRadius = FMath::Abs(Radius); + const float FloorThickness = VerticalShaftConnectorFloorThickness(SafeRadius); + const float FloorRadius = FMath::Max( + MetersToVoxels(0.125f), SafeRadius - MetersToVoxels(0.125f)); + if (!(SafeRadius > 0.0f) + || !(BaseDensity > 0.0f) + || !VoxelMath::IsFinite(WorldX) + || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(WorldZ) + || !VoxelMath::IsFinite(FloorZ) + || !VoxelMath::IsFinite(Start.X) || !VoxelMath::IsFinite(Start.Y) + || !VoxelMath::IsFinite(End.X) || !VoxelMath::IsFinite(End.Y)) + { + return; + } + + const FVector2D A(Start.X, Start.Y); + const FVector2D Delta(End.X - Start.X, End.Y - Start.Y); + const float LengthSquared = Delta.SizeSquared(); + const float T = LengthSquared > KINDA_SMALL_NUMBER + ? FMath::Clamp( + FVector2D::DotProduct(FVector2D(WorldX, WorldY) - A, Delta) + / LengthSquared, + 0.0f, 1.0f) + : 0.0f; + const FVector2D Closest = A + Delta * T; + if ((FVector2D(WorldX, WorldY) - Closest).SizeSquared() + > FMath::Square(FloorRadius)) + { + return; + } + + if (WorldZ <= FloorZ + KINDA_SMALL_NUMBER + && WorldZ > FloorZ - FloorThickness) + { + Density = FMath::Max(Density, BaseDensity); + } + } + + // ApplyVerticalShaftConnectorAir runs before the generator's shared disturbance pass. Keep + // this one-bit, call-local hand-off so a bridge/ridge cannot refill the core it just opened. + // It is thread-local state, never a per-voxel cache, and is reset around every density query. + FORCEINLINE bool& VerticalShaftConnectorAirMarker() + { + static thread_local bool bMarked = false; + return bMarked; + } + + FORCEINLINE void ResetVerticalShaftConnectorAirMarker() + { + VerticalShaftConnectorAirMarker() = false; + } + + /** Reassert the walkable air core above one horizontal shaft-tree floor band. */ + FORCEINLINE void VF_ApplyVerticalShaftConnectorAir( + float& Density, + float WorldX, + float WorldY, + float WorldZ, + const FVector& Start, + const FVector& End, + float Radius, + float FloorZ, + float BaseDensity) + { + const float SafeRadius = FMath::Abs(Radius); + // Leave a one-voxel wall band for the roughness/solid shell, while retaining more than + // enough width for the 1.36-voxel player capsule and its support stencil. + const float CoreRadius = FMath::Max( + MetersToVoxels(0.125f), SafeRadius - MetersToVoxels(0.25f)); + if (!(SafeRadius > 0.0f) + || !(BaseDensity > 0.0f) + || !VoxelMath::IsFinite(WorldX) + || !VoxelMath::IsFinite(WorldY) + || !VoxelMath::IsFinite(WorldZ) + || !VoxelMath::IsFinite(FloorZ) + || !VoxelMath::IsFinite(Start.X) || !VoxelMath::IsFinite(Start.Y) + || !VoxelMath::IsFinite(Start.Z) + || !VoxelMath::IsFinite(End.X) || !VoxelMath::IsFinite(End.Y) + || !VoxelMath::IsFinite(End.Z)) + { + return; + } + + // The exact floor sample belongs to the air core's lower ownership edge. The floor + // writer only owns the three voxels below FloorZ, so clearing from the half-voxel band + // above it cannot remove support. + if (WorldZ + KINDA_SMALL_NUMBER + < FloorZ + WalkableTunnelFloorAirClearanceVoxels) + { + return; + } + + const FVector Delta = End - Start; + const float LengthSquared = Delta.SizeSquared(); + const float T = LengthSquared > KINDA_SMALL_NUMBER + ? FMath::Clamp(FVector::DotProduct(FVector(WorldX, WorldY, WorldZ) - Start, Delta) + / LengthSquared, 0.0f, 1.0f) + : 0.0f; + const FVector Closest = Start + Delta * T; + if ((FVector(WorldX, WorldY, WorldZ) - Closest).SizeSquared() + > FMath::Square(CoreRadius)) + { + return; + } + + // Internal convention: negative is air, positive is solid. This is intentionally a + // post-ledge write; a partial shaft shelf must not cap the tree route's player-height + // envelope after the capsule has already carved it. + VerticalShaftConnectorAirMarker() = true; + Density = FMath::Min(Density, -BaseDensity); + } + + FORCEINLINE float TunnelFloorGradient( + const FVector& Start, float StartRadius, + const FVector& End, float EndRadius) + { + const float HorizontalRun = FVector2D( + End.X - Start.X, End.Y - Start.Y).Size(); + if (HorizontalRun <= KINDA_SMALL_NUMBER) + { + return FMath::Abs(TunnelFloorZ(End, EndRadius) + - TunnelFloorZ(Start, StartRadius)) > KINDA_SMALL_NUMBER + ? FLT_MAX : 0.0f; + } + return FMath::Abs(TunnelFloorZ(End, EndRadius) + - TunnelFloorZ(Start, StartRadius)) / HorizontalRun; + } + + FORCEINLINE float RequiredHorizontalRunForFloorDrop( + float FirstFloorDrop, float SecondFloorDrop) + { + return 2.0f * FMath::Max( + FMath::Abs(FirstFloorDrop), FMath::Abs(SecondFloorDrop)) + / WalkableTunnelMaxGradient; + } + + /** Project a query into the nearest horizontal control segment of a walkable tunnel. */ + FORCEINLINE bool ProjectWalkableTunnelFloor( + const TArray& ControlPoints, + const TArray& ControlRadii, + const FVector& Position, + float& OutFloorZ, + float& OutSupportRadius, + int32* OutSegmentIndex = nullptr) + { + if (OutSegmentIndex != nullptr) + { + *OutSegmentIndex = INDEX_NONE; + } + if (ControlPoints.Num() < 2 + || ControlRadii.Num() != ControlPoints.Num()) + { + return false; + } + + const double QueryX = Position.X; + const double QueryY = Position.Y; + const int32 ControlPointCount = ControlPoints.Num(); + const FVector* ControlPointData = ControlPoints.GetData(); + const float* ControlRadiusData = ControlRadii.GetData(); + float BestDistanceSquared = FLT_MAX; + float BestHorizontalDistanceSquared = FLT_MAX; + bool bFoundSegment = false; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < ControlPointCount; + ++SegmentIndex) + { + const FVector& A = ControlPointData[SegmentIndex]; + const FVector& B = ControlPointData[SegmentIndex + 1]; + const double AXY_X = A.X; + const double AXY_Y = A.Y; + const double DeltaX = B.X - AXY_X; + const double DeltaY = B.Y - AXY_Y; + const float LengthSquared = static_cast( + DeltaX * DeltaX + DeltaY * DeltaY); + if (LengthSquared <= KINDA_SMALL_NUMBER) + { + continue; + } + + const double HorizontalDot = (QueryX - AXY_X) * DeltaX + + (QueryY - AXY_Y) * DeltaY; + const float HorizontalT = FMath::Clamp( + HorizontalDot / LengthSquared, + 0.0f, 1.0f); + const double ClosestX = AXY_X + DeltaX * HorizontalT; + const double ClosestY = AXY_Y + DeltaY * HorizontalT; + const double DistanceX = QueryX - ClosestX; + const double DistanceY = QueryY - ClosestY; + const float HorizontalDistanceSquared = static_cast( + DistanceX * DistanceX + DistanceY * DistanceY); + + const float StartRadius = FMath::Abs(ControlRadiusData[SegmentIndex]); + const float EndRadius = FMath::Abs(ControlRadiusData[SegmentIndex + 1]); + const float StartFloorZ = TunnelFloorZ(A, StartRadius); + const float EndFloorZ = TunnelFloorZ(B, EndRadius); + const double FloorDeltaZ = static_cast(EndFloorZ - StartFloorZ); + const double FullLengthSquared = + LengthSquared + FloorDeltaZ * FloorDeltaZ; + if (!(FullLengthSquared > KINDA_SMALL_NUMBER)) + { + continue; + } + + // A switchback may place two different floors at the same XY. Select the segment + // nearest to the complete query point on the floor curve, so a lower passage leg + // cannot inherit the floor height of a vertically separated upper leg. Keep floor + // interpolation horizontal after choosing the branch: FloorZ remains a function of XY. + const double FullDot = HorizontalDot + + (static_cast(Position.Z) - StartFloorZ) * 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 = StartFloorZ + FloorDeltaZ * SpatialT; + const double SpatialDistanceX = QueryX - SpatialX; + const double SpatialDistanceY = QueryY - SpatialY; + const double SpatialDistanceZ = Position.Z - SpatialZ; + const float DistanceSquared = static_cast( + SpatialDistanceX * SpatialDistanceX + + SpatialDistanceY * SpatialDistanceY + + SpatialDistanceZ * SpatialDistanceZ); + if (DistanceSquared >= BestDistanceSquared) + { + continue; + } + + OutFloorZ = FMath::Lerp( + StartFloorZ, + EndFloorZ, + HorizontalT); + OutSupportRadius = FMath::Max( + FMath::Min(StartRadius, EndRadius) - MetersToVoxels(0.125f), + PlayerRadiusVoxels); + if (OutSegmentIndex != nullptr) + { + *OutSegmentIndex = SegmentIndex; + } + BestDistanceSquared = DistanceSquared; + BestHorizontalDistanceSquared = HorizontalDistanceSquared; + bFoundSegment = true; + } + + return bFoundSegment + && VoxelMath::IsFinite(OutFloorZ) + && VoxelMath::IsFinite(OutSupportRadius) + && OutSupportRadius > 0.0f + && BestHorizontalDistanceSquared <= FMath::Square(OutSupportRadius); + } + + struct FOriginLandingGeometry + { + bool bValid = false; + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + float HalfWidth = 0.0f; + float FloorThickness = LandingFloorThicknessVoxels; + }; + + FORCEINLINE float LandingHalfWidthForRadius(float Radius) + { + const float SafeRadius = FMath::Max( + FMath::Abs(Radius), MetersToVoxels(0.25f)); + return FMath::Max(MinimumLandingHalfWidthVoxels, + SafeRadius + MetersToVoxels(0.5f)); + } + + FORCEINLINE float LandingHeightForRadius(float Radius) + { + const float SafeRadius = FMath::Max( + FMath::Abs(Radius), MetersToVoxels(0.25f)); + return FMath::Max( + FMath::Max(MinimumRoomHeightVoxels, PlayerHeightVoxels + HeadroomVoxels), + 2.0f * SafeRadius + MetersToVoxels(0.5f)); + } + + /** + * Build the one origin landing in a strate. + * + * `OriginRadius` is the old spine setting and becomes the room's authored mouth radius. The + * room dimensions therefore reuse the §6.5 formula exactly: + * half-width = max(1.5 m, radius + 0.5 m), + * height = max(3 m, capsule height + 1 m, 2 * radius + 0.5 m). + * The floor is placed against the top of the safe interior interval. This makes the first + * landing the place reached from above, while every lower landing remains sealed above until + * progression opens its inter-strate passage. + */ + FORCEINLINE FOriginLandingGeometry BuildOriginLandingGeometry( + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + FOriginLandingGeometry Result; + if (!(OriginRadius > 0.0f) + || !VoxelMath::IsFinite(StrateTopZ) + || !VoxelMath::IsFinite(StrateBottomZ) + || !VoxelMath::IsFinite(SealThickness) + || !VoxelMath::IsFinite(OriginRadius)) + { + return Result; + } + + // Keep a solid walking ledge around the future shaft, not merely a room whose wall is + // tangent to it. The shaft diameter is 2 * OriginRadius; the eight-voxel / 1 m extra + // leaves six voxels / 0.75 m of floor after the support patch's two-voxel / 0.25 m inset. + Result.HalfWidth = FMath::Max( + LandingHalfWidthForRadius(OriginRadius), + FMath::Max(FMath::Abs(OriginRadius), MetersToVoxels(0.25f)) + + OriginShaftFloorClearanceVoxels); + const float DesiredHeight = LandingHeightForRadius(OriginRadius); + const float SafeSeal = FMath::Max(SealThickness, 0.0f); + const float InnerBottom = StrateBottomZ + SafeSeal; + const float InnerTop = StrateTopZ - SafeSeal; + const float MinimumFloor = InnerBottom + + Result.FloorThickness + SealSafetyMarginVoxels; + const float MaximumFloor = InnerTop + - DesiredHeight - SealSafetyMarginVoxels; + if (!VoxelMath::IsFinite(InnerBottom) || !VoxelMath::IsFinite(InnerTop) + || !(InnerTop > InnerBottom) + || !VoxelMath::IsFinite(MinimumFloor) + || !VoxelMath::IsFinite(MaximumFloor) + || MaximumFloor < MinimumFloor) + { + Result.HalfWidth = 0.0f; + return Result; + } + + Result.FloorZ = MaximumFloor; + Result.CeilingZ = Result.FloorZ + DesiredHeight; + Result.bValid = true; + return Result; + } + + FORCEINLINE bool IntersectsClosedInterval(float MinA, float MaxA, + float MinB, float MaxB) + { + return MaxA >= MinB && MinA <= MaxB; + } + + /** True when the exact MC lattice has at least one sample in a closed interval. */ + FORCEINLINE bool LatticeAxisHasSampleInInterval( + float Min, float Max, float Origin, int32 Step) + { + if (Step <= 0 || !VoxelMath::IsFinite(Min) || !VoxelMath::IsFinite(Max) + || !VoxelMath::IsFinite(Origin) || Min > Max) + { + return false; + } + const float InvStep = 1.0f / static_cast(Step); + const int32 First = FMath::CeilToInt( + (Min - Origin) * InvStep - 1.0e-4f); + const int32 Last = FMath::FloorToInt( + (Max - Origin) * InvStep + 1.0e-4f); + return First <= Last; + } + + FORCEINLINE bool OriginLandingRoomTouchesLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius, + float ReachScale = 1.0f) + { + if (!VoxelBox.IsValid) return false; + if (!VoxelMath::IsFinite(ReachScale) || ReachScale <= 0.0f) + { + // This is an invalid proof request, not an empty room. The manager treats a + // malformed scale as conservative/unknown before calling this helper. + return true; + } + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + const float Pad = LandingCarveBlendVoxels * ReachScale; + return LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.X, -Geometry.HalfWidth - Pad), + FMath::Min((float)VoxelBox.Max.X, Geometry.HalfWidth + Pad), + (float)LatticeOrigin.X, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Y, -Geometry.HalfWidth - Pad), + FMath::Min((float)VoxelBox.Max.Y, Geometry.HalfWidth + Pad), + (float)LatticeOrigin.Y, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Z, Geometry.FloorZ - Pad), + FMath::Min((float)VoxelBox.Max.Z, Geometry.CeilingZ + Pad), + (float)LatticeOrigin.Z, Step); + } + + FORCEINLINE bool OriginLandingFloorTouchesLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + + constexpr float ProofPad = 1.0f; + const float FloorHalfWidth = FMath::Max( + Geometry.HalfWidth - MetersToVoxels(0.25f), 0.0f); + return LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.X, -FloorHalfWidth - ProofPad), + FMath::Min((float)VoxelBox.Max.X, FloorHalfWidth + ProofPad), + (float)LatticeOrigin.X, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Y, -FloorHalfWidth - ProofPad), + FMath::Min((float)VoxelBox.Max.Y, FloorHalfWidth + ProofPad), + (float)LatticeOrigin.Y, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Z, + Geometry.FloorZ - Geometry.FloorThickness - ProofPad), + FMath::Min((float)VoxelBox.Max.Z, Geometry.FloorZ + ProofPad), + (float)LatticeOrigin.Z, Step); + } + + /** Conservative influence box for the origin room's air carve. */ + FORCEINLINE bool OriginLandingRoomTouchesBox( + const FBox& VoxelBox, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + const float Pad = LandingCarveBlendVoxels; + return IntersectsClosedInterval( + VoxelBox.Min.X, VoxelBox.Max.X, + -Geometry.HalfWidth - Pad, Geometry.HalfWidth + Pad) + && IntersectsClosedInterval( + VoxelBox.Min.Y, VoxelBox.Max.Y, + -Geometry.HalfWidth - Pad, Geometry.HalfWidth + Pad) + && IntersectsClosedInterval( + VoxelBox.Min.Z, VoxelBox.Max.Z, + Geometry.FloorZ - Pad, Geometry.CeilingZ + Pad); + } + + /** Conservative influence box for the origin room's guaranteed solid floor. */ + FORCEINLINE bool OriginLandingFloorTouchesBox( + const FBox& VoxelBox, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + + // One voxel is a proof margin, not a write margin. It keeps ClassifyTile conservative at + // an exact lattice edge and mirrors the existing passage-floor guard. + constexpr float ProofPad = 1.0f; + const float FloorHalfWidth = FMath::Max( + Geometry.HalfWidth - MetersToVoxels(0.25f), 0.0f); + return IntersectsClosedInterval( + VoxelBox.Min.X, VoxelBox.Max.X, + -FloorHalfWidth - ProofPad, FloorHalfWidth + ProofPad) + && IntersectsClosedInterval( + VoxelBox.Min.Y, VoxelBox.Max.Y, + -FloorHalfWidth - ProofPad, FloorHalfWidth + ProofPad) + && IntersectsClosedInterval( + VoxelBox.Min.Z, VoxelBox.Max.Z, + Geometry.FloorZ - Geometry.FloorThickness - ProofPad, + Geometry.FloorZ + ProofPad); + } + + /** Signed distance for a rounded origin room with a hard flat floor. */ + FORCEINLINE float OriginLandingRoomSDF( + const FVector& Position, const FOriginLandingGeometry& Geometry) + { + if (!Geometry.bValid || Geometry.CeilingZ <= Geometry.FloorZ) + { + return FLT_MAX; + } + + const FVector Center(0.0f, 0.0f, + (Geometry.FloorZ + Geometry.CeilingZ) * 0.5f); + const float Rounding = RoomRoundingVoxels; + const float HalfZ = (Geometry.CeilingZ - Geometry.FloorZ) * 0.5f; + const FVector HalfExtent( + FMath::Max(Geometry.HalfWidth - Rounding, MetersToVoxels(0.0625f)), + FMath::Max(Geometry.HalfWidth - Rounding, MetersToVoxels(0.0625f)), + FMath::Max(HalfZ - Rounding, MetersToVoxels(0.0625f))); + const FVector D( + FMath::Abs(Position.X - Center.X) - HalfExtent.X, + FMath::Abs(Position.Y - Center.Y) - HalfExtent.Y, + FMath::Abs(Position.Z - Center.Z) - HalfExtent.Z); + const FVector Outside( + FMath::Max(D.X, 0.0f), + FMath::Max(D.Y, 0.0f), + FMath::Max(D.Z, 0.0f)); + const float Inside = FMath::Min( + FMath::Max(D.X, FMath::Max(D.Y, D.Z)), 0.0f); + const float RoundedBoxSDF = Outside.Size() + Inside - Rounding; + + // Below the floor is rock. Above it the rounded-box air volume is authoritative. + return FMath::Max(RoundedBoxSDF, Geometry.FloorZ - Position.Z); + } +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelRedefinedDensityField.h b/Standalone/Source/VoxelForge/Public/VoxelRedefinedDensityField.h new file mode 100644 index 0000000..6f434ab --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelRedefinedDensityField.h @@ -0,0 +1,195 @@ +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" + +class UVoxelGenerator; + +#if WITH_EDITOR +/** + * Step 2d editor research field. It is intentionally separate from the gameplay evaluator and + * is selected only by the default-off voxel.ResearchRedefinedDensityField console variable. + * + * The field is a connected, archetype-sized cave lattice with an authored walkable floor at each + * passage landing. Its macro shape is evaluated directly in world space; bounded, continuous + * hash-value noise is added at every full-resolution sample. The brick classifier is permitted to + * skip only when a Lipschitz bound on the macro field plus the exact maximum detail amplitude + * proves that the final sign is uniform throughout the brick and its marching-cubes halo. + */ +namespace VoxelForgeRedefinedDensityField +{ + /** Proof sample width shared by the research mesher and the region-evaluation harness. */ + constexpr int32 ParameterizedBrickSamples = 4; + /** Uniform fill magnitude used after a 16 metre sign proof. */ + constexpr float ParameterizedUniformDensity = 16.0f * 100.0f / VOXEL_SIZE; + + /** Per-worker, per-brick state. The opaque plan pointer is valid until the layout changes. */ + struct FBrickEvaluationContext + { + const void* PlanToken = nullptr; + bool bPlanResolved = false; + bool bMayContainPassage = true; + bool bChunkResolved = false; + bool bIsGapChunk = false; + bool bHasDiffMods = false; + bool bMacroXYPrecomputed = false; + FIntVector BrickOrigin = FIntVector::ZeroValue; + FIntVector BrickSize = FIntVector::ZeroValue; + FIntVector Chunk = FIntVector::ZeroValue; + TArray FloorByXY; + TArray GridAirByXY; + TArray DetailFadeByXY; + TArray LandingHorizontalByXY; + TArray LandingApproachByXY; + TArray LandingBridgeHorizontalByXY; + TArray LandingBridgeFloorByXY; + TArray PassageCandidates; + }; + + /** Cell-level controls for the separate editor-only parameterized cave field. */ + struct FGenerationParameters + { + float RoomPitchVoxels = 144.0f; + float MinimumRoomRadiusFraction = 0.30f; + float MaximumRoomRadiusFraction = 0.45f; + float RoomHeightVoxels = 192.0f; + float FloorReliefVoxels = 8.0f; + float TunnelRadiusVoxels = 28.0f; + float TunnelHalfHeightVoxels = 26.0f; + float LoopProbability = 0.20f; + float VerticalShaftProbability = 0.10f; + float ShaftRadiusVoxels = 18.0f; + float PillarProbability = 0.10f; + float LedgeProbability = 0.20f; + float FloatingMassProbability = 0.10f; + float SpireProbability = 0.25f; + float BasinProbability = 0.0f; + float BasinDepthVoxels = 0.0f; + float WaterLevelFraction = 0.5f; + }; + + /** Brick-local immutable room cells used by step 2e evaluation and its final-field proof. */ + struct FParameterizedBrickContext + { + const void* PlanToken = nullptr; + const void* RoomCellCacheToken = nullptr; + bool bPlanResolved = false; + bool bChunkResolved = false; + bool bIsGapChunk = false; + bool bHasDiffMods = false; + bool bMayContainPassage = true; + FIntVector BrickOrigin = FIntVector::ZeroValue; + FIntVector BrickSize = FIntVector::ZeroValue; + FIntVector Chunk = FIntVector::ZeroValue; + FIntPoint RoomCellCacheMin = FIntPoint::ZeroValue; + FIntPoint RoomCellCacheSize = FIntPoint::ZeroValue; + TArray PassageCandidates; + }; + + enum class EBrickFallbackReason : uint8 + { + None, + UnsupportedState, + ChunkOrStrateBoundary, + PassageBounds, + MixedMacroInterval + }; + + struct FBrickCounters + { + uint64 Tested = 0; + uint64 Skipped = 0; + uint64 EvaluatedSamples = 0; + }; + + VOXELFORGE_API bool IsEnabled(); + VOXELFORGE_API void SetEnabled(bool bEnabled); + VOXELFORGE_API float GetDetailScale(); + VOXELFORGE_API void SetDetailScale(float Scale); + + /** The parameterized cave field is editor-only and default-off. -1 rolls per strate. */ + VOXELFORGE_API bool IsParameterizedEnabled(); + VOXELFORGE_API void SetParameterizedEnabled(bool bEnabled); + /** Select the frozen step 2e implementation while the step 2f audit measures its gates. */ + VOXELFORGE_API void SetLegacyStep2eAuditMode(bool bEnabled); + VOXELFORGE_API void SetParameterSet(int32 ParameterSet); + VOXELFORGE_API int32 GetParameterSet(); + VOXELFORGE_API int32 GetParameterSetCount(); + VOXELFORGE_API bool GetParameterSetInfo( + int32 ParameterSet, const TCHAR*& OutName, FGenerationParameters& OutParameters); + + /** Evaluate the separate parameterized room / tunnel network, including edits/passages. */ + VOXELFORGE_API float EvaluateParameterized( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity = nullptr, float DetailScale = 1.0f); + + /** Evaluate only the parameterized room/tunnel macro, before passage overlays and seals. */ + VOXELFORGE_API float EvaluateParameterizedMacroOnly( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ); + + /** Describe the candidate room SDF values at a point for step 2f proof diagnosis. */ + VOXELFORGE_API FString DescribeParameterizedMacroAt( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ); + + /** Return the active parameterized detail signal for the band-limit research gate. */ + VOXELFORGE_API float EvaluateParameterizedDetail( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ); + + VOXELFORGE_API float EvaluateParameterizedWithDetailValue( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, + float DetailValue, float* OutPreEditDensity = nullptr, float DetailScale = 1.0f, + const FParameterizedBrickContext* Context = nullptr); + + /** Evaluate a Z column while reusing its XY-dependent room graph data. */ + VOXELFORGE_API void EvaluateParameterizedColumnWithDetailValues( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZStart, + const float* DetailValues, int32 DetailStride, int32 Count, float* OutValues, + const FParameterizedBrickContext* Context = nullptr, float DetailScale = 1.0f, + int32 SampleStep = 1); + + VOXELFORGE_API bool TryClassifyParameterizedBrick( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + bool& bOutAir, float DetailScale = 1.0f, + bool* bOutEvaluatedMacroCentre = nullptr, + EBrickFallbackReason* OutFallbackReason = nullptr, + int32 SampleStep = 1); + + VOXELFORGE_API void BuildParameterizedBrickContext( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + FParameterizedBrickContext& OutContext, int32 SampleStep = 1); + + /** Evaluate a mixed brick with one shared room/passage setup for all Z samples. */ + VOXELFORGE_API void EvaluateParameterizedDensityBrick( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + float DetailScale, FParameterizedBrickContext& InOutContext, + TArray& InOutDetail, TArray& InOutColumnValues, + TArray& OutValues, int32 SampleStep = 1); + + /** Evaluate the new field, including existing passage carving and player diffs. */ + VOXELFORGE_API float Evaluate(const UVoxelGenerator& Generator, float WorldX, float WorldY, + float WorldZ, float* OutPreEditDensity = nullptr, + float DetailScale = 1.0f); + + /** Build mathematically identical multi-scale noise for one integer-lattice brick. */ + VOXELFORGE_API void BuildDetailBrick(const UVoxelGenerator& Generator, + FIntVector OriginVoxels, FIntVector Size, float DetailScale, + TArray& OutDetail, FBrickEvaluationContext* OutContext = nullptr, + bool bParameterized = false, int32 SampleStep = 1); + + /** Evaluate with a detail value produced by BuildDetailBrick. */ + VOXELFORGE_API float EvaluateWithDetailValue(const UVoxelGenerator& Generator, + float WorldX, float WorldY, float WorldZ, float DetailValue, + float* OutPreEditDensity = nullptr, float DetailScale = 1.0f, + const FBrickEvaluationContext* Context = nullptr); + + /** Prove a world-aligned box has one final density sign; false means evaluate every point. */ + VOXELFORGE_API bool TryClassifyBrick( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + bool& bOutAir, float DetailScale = 1.0f, + bool* bOutEvaluatedMacroCentre = nullptr, + EBrickFallbackReason* OutFallbackReason = nullptr); + + /** Reset this thread's immutable plan cache; used by deterministic editor experiments. */ + VOXELFORGE_API void ResetThreadCache(); +} +#endif diff --git a/Standalone/Source/VoxelForge/Public/VoxelSeasonAsset.h b/Standalone/Source/VoxelForge/Public/VoxelSeasonAsset.h new file mode 100644 index 0000000..7eea906 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelSeasonAsset.h @@ -0,0 +1,71 @@ +// Cookable runtime carrier for one reviewed season manifest. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VoxelSeasonAsset.generated.h" + +struct FVoxelSeasonManifest; +class UVoxelStrateDefinition; + +/** + * The JSON remains the reviewed/diffable source artifact. This asset embeds that exact text so + * Unreal's cooker can carry it into a packaged build, together with an independently stored digest + * used to detect an accidentally stale or edited asset. + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelSeasonAsset : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + virtual FPrimaryAssetId GetPrimaryAssetId() const override; + + /** Parse and validate the embedded manifest, including both content-hash copies. */ + bool LoadManifest(FVoxelSeasonManifest& OutManifest, FString& OutReport) const; + + /** Runtime-safe import core used by the editor button and automation. */ + bool SetManifestJson(const FString& InManifestJson, FString& OutReport); + + int32 GetSeasonNumber() const { return SeasonNumber; } + int32 GetSeasonSeed() const { return SeasonSeed; } + float GetOriginSpineRadius() const { return OriginSpineRadius; } + float GetWorldRadiusVoxels() const { return WorldRadiusVoxels; } + + /** Create the asset in Content Browser, choose a JSON file, then press this button. */ + UFUNCTION(CallInEditor, Category = "Season") + void ImportSeasonManifestJson(); + +#if WITH_EDITORONLY_DATA + UPROPERTY(EditAnywhere, Category = "Season|Import", meta = (FilePathFilter = "json")) + FFilePath SourceManifestJson; +#endif + +private: + UPROPERTY(VisibleAnywhere, Category = "Season") + int32 SchemaVersion = 0; + + UPROPERTY(VisibleAnywhere, Category = "Season") + int32 SeasonNumber = 0; + + UPROPERTY(VisibleAnywhere, Category = "Season") + int32 SeasonSeed = 0; + + UPROPERTY(VisibleAnywhere, Category = "Season") + float OriginSpineRadius = 0.0f; + + UPROPERTY(VisibleAnywhere, Category = "Season") + float WorldRadiusVoxels = 0.0f; + + UPROPERTY(VisibleAnywhere, Category = "Season") + FString ManifestContentHash; + + UPROPERTY(VisibleAnywhere, Category = "Season", meta = (MultiLine = true)) + FString ManifestJson; + + // Import-derived cook references for fixed/authored content bags named by the JSON. Generated + // strates legitimately have no source definition and use deterministic C++ defaults at runtime. + UPROPERTY(VisibleAnywhere, Category = "Season") + TArray> ReferencedDefinitions; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelSeasonManifest.h b/Standalone/Source/VoxelForge/Public/VoxelSeasonManifest.h new file mode 100644 index 0000000..736ef33 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelSeasonManifest.h @@ -0,0 +1,299 @@ +// Offline Tier 4c composition plus the runtime-readable, diffable Tier A manifest. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelStrateComposer.h" +#include "VoxelStrateMeasure.h" + +class UVoxelSettings; + +/** Why the provisional season selector put one surviving candidate in a slot. */ +enum class EVoxelSeasonSelectionReason : uint8 +{ + Grounded, + Outlier, + Fixed, +}; + +/** + * Explicit, reviewable selection knobs for one season. + * + * None of these values is a settled definition of quality. They are the first policy Jahni can + * change after walking the review page. Hard validation remains separate from this policy. + */ +struct VOXELFORGE_API FVoxelSeasonSelectionPolicy +{ + // Placeholder mix only: ordinary strates give an outlier contrast. Revisit after the first + // season review; this is not a learned or final quality score. + float GroundedFraction = 0.75f; + float OutlierFraction = 0.25f; + + // Below this measured distance two neighbours look too similar. The selector subtracts a + // penalty proportional to the gap; provisional because the useful contrast is visual. + double AdjacentSimilarityDistanceThreshold = 0.20; + double AdjacentSimilarityPenaltyWeight = 1.0; + + // Rewards an unseen archetype/recipe in the already chosen prefix. These are variety + // tie-breakers, not claims that one archetype or recipe is better. Provisional review knobs. + double NewArchetypeBonus = 0.10; + double NewRecipeBonus = 0.05; + + // GDD §6 says roughly every five levels, and a boss is intermediary rather than terminal. + // This interval is therefore an explicit provisional layout knob, not a quality score. + int32 BossSlotInterval = 5; + + // Tier 2 hard gate: a surviving air component must retain at least this share. Provisional + // only in its threshold; the existence of the gate is non-negotiable for a shippable season. + float MinimumLargestComponentShare = 0.50f; + + // Normalizers for the two unbounded measured dimensions in the adjacency distance. Fractions + // already live in [0,1]. These physical scales are provisional and visible in the manifest. + float FeatureScaleNormalizationVoxels = 256.0f; + float ClearanceNormalizationVoxels = 64.0f; +}; + +/** A caller-supplied fixed slot for tests/tools that already have a complete manifest record. */ +struct VOXELFORGE_API FVoxelSeasonFixedStrate +{ + int32 Seed = 0; + int32 HeightInChunks = 8; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams Params; + FVoxelOpStackRecipe Recipe; + FVoxelStrateRegionManifest Regions; + bool bUsesRecipe = true; + bool bUsesRegions = false; + FString SourceDefinitionPath; +}; + +/** Settings for the offline build-box pass. No member is consulted by normal runtime generation. */ +struct VOXELFORGE_API FVoxelSeasonCompositionSettings +{ + // Optional live-world settings. TotalStrates, spine/gap values, and FixedStrates are read + // from it when present. The pointer is borrowed for the duration of VF_ComposeSeason only. + const UVoxelSettings* WorldSettings = nullptr; + + // Optional preloaded corpus. With nullptr the composer loads the project asset corpus on the + // build box, exactly like the existing offline composer tests. + const FVoxelStrateCorpus* Corpus = nullptr; + + // 24 gives a useful minute-scale automation pass at the default coarse measurement window. + // Production can raise this to the overnight budget without changing the policy. + int32 CandidateCount = 24; + int32 SelectedStrateCount = 10; + + // Generated candidates use one stable height. Fixed authored slots may provide their own + // height. World layout bounds are written into every selected parameter family below. + int32 StrateHeightInChunks = 8; + int32 InterStrateGapChunks = 0; + float OriginSpineRadius = 28.0f; + float WorldRadiusVoxels = 0.0f; + + // Coarse measurement is deliberately bounded. Tests may reduce the window; production may + // spend the overnight budget on a finer pass after the selector has been judged. + FVoxelStrateMeasureSettings MeasureSettings; + // Player-fit is a separate, explicitly fine pass. SampleStep values other than one are + // refused by the metric, never interpreted as an approximate capsule answer. + FVoxelStrateMeasureSettings PlayerFitMeasureSettings; + FVoxelSeasonSelectionPolicy SelectionPolicy; + + // Explicit composed fixed slots take precedence. When empty, the composer adapts the + // existing UVoxelSettings::FixedStrates map, preserving its absolute slot indices. + TMap FixedStrates; + + // Empty means ProjectSavedDir()/VoxelForge/Seasons. Files are never written to the runtime + // content directory by this API. + FString OutputDirectory; + bool bWriteArtifacts = true; + bool bWriteReviewPage = true; + bool bIncludeCandidateAudit = true; + + // Informational only; when WorldSettings is present CurrentSeason supplies the default. + int32 SeasonNumber = 0; + + FVoxelSeasonCompositionSettings() + { + // The existing Tier 4b test uses step 4. Season composition defaults to step 8 so the + // default automation artifact is measured in minutes, while MaxCells remains bounded. + MeasureSettings.SampleStep = 8; + MeasureSettings.RadiusInVoxels = 512; + MeasureSettings.MaxCells = 8000000; + MeasureSettings.MaxRouteRetries = 16; + MeasureSettings.HeadroomCells = 2; + MeasureSettings.InteriorMarginVoxels = -1; + + PlayerFitMeasureSettings.SampleStep = 1; + PlayerFitMeasureSettings.RadiusInVoxels = 128; + // A fitted mouth-to-mouth XY window across an 8-chunk (256-voxel) strate can approach + // four million cells. Keep the cap finite and below the old multi-gigabyte retry cost. + PlayerFitMeasureSettings.MaxCells = 4000000; + PlayerFitMeasureSettings.MaxRouteRetries = 16; + PlayerFitMeasureSettings.HeadroomCells = 2; + PlayerFitMeasureSettings.InteriorMarginVoxels = -1; + } +}; + +/** Compact audit row; the full vectors live only for selected strates. */ +struct VOXELFORGE_API FVoxelSeasonCandidateAudit +{ + int32 CandidateIndex = INDEX_NONE; + int32 Seed = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + uint32 RecipeHash = 0; + double DistanceFromCorpusCentroid = 0.0; + bool bPassedHardGates = false; + bool bSelected = false; + FString RejectionReason; +}; + +/** Count of one explicit hard-gate or final-placement rejection reason. */ +struct VOXELFORGE_API FVoxelSeasonRejectionCount +{ + FString Reason; + int32 Count = 0; +}; + +/** One ordered Tier A spine entry. */ +struct VOXELFORGE_API FVoxelSeasonStrate +{ + // DepthIndex is the absolute world-layout slot. CandidateIndex is the offline attempt id. + int32 DepthIndex = INDEX_NONE; + int32 CandidateIndex = INDEX_NONE; + int32 Seed = 0; + int32 HeightInChunks = 0; + int32 TopWorldZ = 0; + int32 BottomWorldZ = 0; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelOpStackRecipe Recipe; + FVoxelStrateArchetypeParams Params; + // When true this is the complete lateral parent description. Params/Archetype remain the + // compatibility identity of region zero; runtime rebuilds use Regions and never blend params. + FVoxelStrateRegionManifest Regions; + FVoxelStrateMeasuredMetrics Metrics; + + double DistanceFromCorpusCentroid = 0.0; + FString SelectionReason; + FString SourceDefinitionPath; + + bool bGrounded = false; + bool bOutlier = false; + bool bBossSlot = false; + bool bFixed = false; + bool bUsesRecipe = true; + bool bUsesRegions = false; + + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; + EVoxelConnectivityResult PrimordialLawResult = EVoxelConnectivityResult::OutOfWindow; +}; + +/** + * The stored Tier A season artifact. + * + * Tier B is intentionally absent: its hash-lattice field remains an arithmetic/enumeration API, + * not a second stored table. Strates is ordered from the surface down and is the only finite spine + * persisted by this task. + */ +struct VOXELFORGE_API FVoxelSeasonManifest +{ + static constexpr int32 CurrentSchemaVersion = 2; + + bool bValid = false; + int32 SchemaVersion = CurrentSchemaVersion; + FString Error; + + int32 Season = 0; + int32 Seed = 0; + uint32 InputCorpusHash = 0; + // SHA-1 of the canonical manifest JSON with this field omitted. This detects stale cooked + // season assets without requiring the editor-only input corpus on a player's machine. + FString ContentHash; + int32 CandidateCount = 0; + int32 SurvivorCount = 0; + int32 SelectedCount = 0; + int32 RejectedCount = 0; + int32 GroundedSelectedCount = 0; + int32 OutlierSelectedCount = 0; + int32 FixedSelectedCount = 0; + int32 TotalStrates = 0; + int32 InterStrateGapChunks = 0; + float OriginSpineRadius = 0.0f; + float WorldRadiusVoxels = 0.0f; + + FVoxelSeasonSelectionPolicy SelectionPolicy; + FString PolicyNote; + + TArray Strates; + TArray CandidateAudit; + TArray RejectionCounts; + + // Derived artifact locations; intentionally not serialized into the portable JSON. + FString ManifestPath; + FString ReviewPagePath; + FString SerializedJson; + + bool IsUsable() const + { + if (!bValid || !Error.IsEmpty() || ContentHash.Len() != 40 || WorldRadiusVoxels != 0.0f + || Strates.Num() != SelectedCount || SelectedCount <= 0) + { + return false; + } + for (const FVoxelSeasonStrate& Strate : Strates) + { + // Region composition is intentionally not a shippable season format yet. Keeping + // this rejection in the portable manifest contract prevents an editor experiment + // from becoming packaged terrain merely because its density evaluator exists. + if (Strate.bUsesRegions) + { + return false; + } + } + return true; + } +}; + +/** Compose, validate, select, serialize, and (by default) write one season. Editor/build-box only. */ +#if WITH_EDITOR +VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( + int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings); + +/** Convenience overload that adapts the existing runtime settings asset. */ +VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( + int32 SeasonSeed, const UVoxelSettings* WorldSettings); +#endif + +/** Serialize/read the complete Tier A manifest as stable pretty JSON. */ +VOXELFORGE_API FString VF_SerializeVoxelSeasonManifest( + const FVoxelSeasonManifest& Manifest); +/** Parse an in-memory/cooked JSON manifest and verify its embedded content hash. */ +VOXELFORGE_API bool VF_DeserializeVoxelSeasonManifest( + const FString& ManifestJson, FVoxelSeasonManifest& OutManifest, FString& OutReport); +/** Stable SHA-1 over the canonical manifest payload (the content_hash field itself is omitted). */ +VOXELFORGE_API FString VF_ComputeVoxelSeasonManifestContentHash( + const FVoxelSeasonManifest& Manifest); +VOXELFORGE_API bool VF_SaveVoxelSeasonManifest( + const FString& ManifestPath, const FVoxelSeasonManifest& Manifest, FString& OutReport); +VOXELFORGE_API bool VF_LoadVoxelSeasonManifest( + const FString& ManifestPath, FVoxelSeasonManifest& OutManifest, FString& OutReport); + +/** Rebuild exactly the stack represented by one manifest entry. */ +VOXELFORGE_API bool VF_BuildSeasonStrateStack( + const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr); + +/** Verbose alias for tests/tools that want the round-trip operation named explicitly. */ +VOXELFORGE_API bool VF_RebuildVoxelSeasonStrate( + const FVoxelSeasonStrate& Strate, + float SpineRadius, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext, + FString* OutError = nullptr); diff --git a/Standalone/Source/VoxelForge/Public/VoxelSettings.h b/Standalone/Source/VoxelForge/Public/VoxelSettings.h new file mode 100644 index 0000000..6d49a84 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelSettings.h @@ -0,0 +1,436 @@ +// VoxelSettings.h +// Settings du plugin VoxelForge. +// +// Data asset unique (assigné sur AVoxelWorld) qui regroupe tous les tuning +// du monde: seed, streaming, LOD, pool de strates, budgets de carving. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VoxelStrateDefinition.h" +#include "VoxelSettings.generated.h" + +// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use. +class UMaterialInterface; +class UVoxelSeasonAsset; + +UCLASS(BlueprintType) +class UVoxelSettings : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + + /** Season seed/radius replace the mutable authored values only when Season is assigned. */ + int32 GetEffectiveWorldSeed() const; + float GetEffectiveOriginSpineRadius() const; + float GetEffectiveWorldRadiusVoxels() const; + int32 GetEffectiveSeasonNumber() const; + + //========================================================================= + // STREAMING (distance de vue) + //========================================================================= + + // En CHUNKS (CHUNK_SIZE=32). At 12.5 cm/voxel, each view-distance chunk covers 4 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 ViewDistanceXY = 32; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 ViewDistanceUp = 10; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 ViewDistanceDown = 10; + + // Nombre max de tâches de génération/mesh en parallèle. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 MaxConcurrentTasks = 16; + + // Optional hard cap for the generation worker budget. 0 keeps the safe automatic cap of + // logical cores minus two; a positive value makes that cap visible and authorable beside the + // task budget instead of hiding it in AVoxelWorld. The effective task count is still the + // minimum of this value and MaxConcurrentTasks. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) + int32 MaxGenerationWorkerCores = 0; + + // Keep this many task slots available for collision-critical, edited, and level-0 terrain while + // coarse horizon work is in flight. A positive reserve prevents a full far-field queue from + // delaying a new near tile because running tasks cannot be preempted. The runtime clamps the + // value below the effective task budget; 4 is the conservative default for the ship settings. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0", ClampMax = "16")) + int32 NearTaskReserve = 4; + + // Nombre max de meshes appliqués (upload GPU) par frame. Seuls les vrais applies + // comptent (chunks vides/périmés se vident gratuitement). À 32³ chaque apply est léger + // (~0.1 ms) — tunable en live sur l'asset : montez (8-16) si le remplissage traîne, + // baissez si l'apparition des chunks fait des à-coups. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 MaxMeshAppliesPerFrame = 4; + + // Wall-clock cap for the complete game-thread result drain, including component teardown and + // collision configuration. The count cap remains the second guard. A zero value disables this + // cap; the default keeps one burst of chunk application below a small frame-time budget without + // changing worker resolution or mesh quality. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0.0")) + float MaxMeshApplyMilliseconds = 2.0f; + + // Max tile teardowns (component destroy + content-actor Destroy) per frame. A fast traversal + // culls a whole shell of tiles at once; doing every destroy in one frame is a game-thread spike + // ("stuff torn down behind you"). This spreads it. The drain auto-scales up if the backlog grows + // (PendingUnload/4) so it never falls far behind — raise the floor if teardown lags at high speed. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 MaxUnloadsPerFrame = 6; + + // Independent level-0 collision radius around the locally controlled pawn. Render tiles outside + // this cube remain visible but do not queue Chaos cooks; registered streaming anchors keep their + // own authored collision boxes, and the collision gate can extend a landing-support column. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1", ClampMax = "4")) + int32 PawnCollisionRadiusChunks = 2; + + // Strate-aware VERTICAL streaming: don't load chunks into a strate you can't see. + // Seals + the inter-strate bedrock gap are light-tight (§8.7), so the strate above/below + // the one you're in is fully occluded — loading it just wastes scene primitives (the + // game-thread cost). When ON, the vertical view is clamped to the player's strate ± + // StrateViewMarginChunks. In the bedrock GAP itself the view is NOT clamped (you can see + // both sides through the descent passage there), so the next strate streams in ahead. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + bool bClampViewToStrate = true; + + // Chunks of vertical look-ahead PAST the strate seal (each side) when bClampViewToStrate + // is on: keeps the descent tunnel / immediate transition loaded and acts as the trailing + // buffer of the strate you're leaving (it sheds naturally as you descend out of view). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) + int32 StrateViewMarginChunks = 6; + + // STRATE CONTENT CUT (F17 separation) — a level-L tile is 2^L chunks TALL and can straddle a + // strate boundary: at coarse Steps the thin seal/gap solid between two strates' airs falls + // between lattice points (⇒ holes into the neighbour strate at far LOD) and one tile mixes + // both strates' materials. From this clip level UP, the mesher only meshes cells inside the + // PLAYER's strate Z-band (the other strates are sealed/enclosed ⇒ invisible from here anyway); + // loaded coarse tiles re-queue automatically when the band changes (strate transition). + // Default 1 = cut LOD1 and coarser. LOD0 is deliberately never content-cut because it owns pawn + // collision: cutting the passage, gap, or next-strate support tile would deadlock a walk at the + // current strate boundary. The runtime clamps legacy/hand-authored values below 1 to 1 as well. + // Raise only if a broader near-field view needs full coarse tiles. 9 = off. + // At ultra-coarse levels where one CELL is taller than the band itself, the tile is skipped + // entirely (see LoadTile) — cell-granular cutting there could only render garbage. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1", ClampMax = "9")) + int32 StrateContentCutMinLevel = 1; + + /** + * Return the effective content-cut level used by runtime streaming. + * + * Existing data assets can contain a serialized legacy value of 0 even though the property now + * documents 1 as the minimum. Keep the safety rule in code, at the consumer boundary: level 0 + * tiles are the collision tiles and must never receive a strate content band. + */ + int32 GetEffectiveStrateContentCutMinLevel() const + { + return FMath::Clamp(FMath::Max(StrateContentCutMinLevel, 1), 1, 9); + } + + //========================================================================= + // CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance box above) + //========================================================================= + // Streaming loads concentric shells of tiles: level 0 = full-res chunks near the player, + // each coarser level doubles tile size (and reach). Total tile/draw/gen count stays ~flat + // regardless of how far you see — so this is the "see to the horizon cheaply" system. + // Total reach ≈ ClipRadius × CHUNK_SIZE × 2^MaxClipLevel voxels. + // Near full-res reach ≈ ClipRadius × CHUNK_SIZE voxels. + + // Half-extent (in tiles, per axis) of EACH level's shell. Bigger = more tiles per level + // (more detail / overlap) but more components. 3 → a 7×7×7 box per level (minus the inner + // hole filled by the finer level). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1")) + int32 ClipRadius = 6; + + // Coarsest LOD level. 0 = full-res only (no clipmap). Each level up doubles tile size & + // reach at ~constant cost — raise this to see much farther for cheap. (Level L step = 2^L.) + // Ship default: level 5, the measured horizon-distance balance at 1536 chunks. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8")) + int32 MaxClipLevel = 5; + + // How many near levels mesh at FULL resolution (CHUNK_SIZE cells). Levels at or above this + // use the cheaper CoarseTileCells → MUCH faster gen for the far field (it's far, so the + // extra blockiness is hidden, and skirts cover the seams). 2 = levels 0,1 crisp; set to 1 + // for the fastest gen (slightly harder LOD0→1 transition). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1")) + int32 FullResClipLevels = 2; + + // Cells per axis for COARSE tiles (levels >= FullResClipLevels). Lower = cheaper gen, blockier + // far terrain. 16 = ~⅛ the gen samples of a full 32-cell tile; 8 = ~1/64. Tile EXTENT is + // unchanged (the shells still tile) — only the mesh resolution within a far tile drops. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "4", ClampMax = "32")) + int32 CoarseTileCells = 16; + + // RENDER DISTANCE — custom horizontal reach, in CHUNKS (1 chunk = 4 m; 256 ≈ 1 km, 1536 ≈ 6 km). + // When > 0 and farther than the natural clipmap reach (ClipRadius × 2^MaxClipLevel chunks), the + // OUTERMOST shell keeps generating level-MaxClipLevel tiles outward until it covers this + // distance. Pick MaxClipLevel = the coarsest level that still renders strates correctly (one + // cell must fit inside a strate band — level ≥7 blanks via the too-coarse skip) and buy the + // remaining horizon here. Cost: the extra ring is all same-level tiles — tile/draw/gen count + // grows with (distance / 2^MaxClipLevel)², so each MaxClipLevel step down quadruples the ring. + // Ship default: 1536 chunks (about 6.1 km at 4 m/chunk). 0 = off (natural reach). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0")) + int32 RenderDistanceChunks = 1536; + + // SKIRTS — seal the thin cracks where neighbouring clipmap shells (different resolutions) meet. + // A short wall is extruded into the solid from each surface edge on the tile's outer faces. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap") + bool bGenerateSkirts = true; + + // Skirt depth in tile CELLS (× the tile's step). Four L0 cells preserve the former 0.5 m + // default depth at 12.5 cm per voxel. Raise if cracks still show; lower if skirts peek out. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0")) + float SkirtCells = 4.0f; + + // T2.b — LOD-aware octave reduction. Octaves DROPPED from per-voxel volumetric noise + // (roughness, worms displacement, slab/maze/shaft/island detail) per Step doubling on + // coarse tiles: a Step=4 tile drops 2×this. Sub-cell octaves can't shape a coarse + // isosurface — they only cost CPU — so 1 shaves 30-50% off far-tile gen for a sub-cell + // isosurface shift (skirts already stitch bigger LOD seams). LOD0 is NEVER affected. + // 0 = off (every LOD samples full octaves — byte-identical to before this setting). + // Réduction d'octaves sur les tuiles lointaines ; 0 = désactivé, LOD0 jamais touché. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "3")) + int32 LODOctaveDrop = 0; + + //========================================================================== + // WORLD BOUNDS (bounded gigantic world in actor-space XY) + //========================================================================== + // Radius (voxels) of the bounded world in XY, measured from actor-space (0,0). + // 0 = unbounded (legacy behaviour, no edge seal). + // + // ⚠️ DEFAULTS TO 0 ON PURPOSE, and it is not the value the GDD wants. + // Existing DA_Settings / BP_VoxelSettings assets predate this property, so they do NOT serialize + // it — they silently inherit whatever C++ default is written here. A non-zero default would + // therefore wall in every existing world the next time the editor opens, with nothing in the + // asset to show why. The former 8192-voxel / 2.05 km target must be 16384 voxels at 12.5 cm. + // 8192 now means a 1.024 km radius. + // SET IT IN THE ASSET, deliberately, once. + // + // FR : 0 par défaut EXPRÈS — les assets existants n'ont pas cette propriété et héritent + // silencieusement de la valeur C++. La valeur de production visée (~8192) se règle dans l'asset. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0")) + float WorldRadiusVoxels = 0.0f; + + // Solid ramp thickness at the radial world edge, in voxels. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0")) + float EdgeSealThickness = 128.0f; + + //========================================================================= + // CONTENT — distance-based decoration grid (no LOD pop) + //========================================================================= + // Decorations are placed on a fixed WORLD XY cell grid (1 cell = 1 chunk footprint) and streamed + // by DISTANCE from the player, independent of which clipmap tile (LOD) currently meshes the ground + // under them. Each candidate column is ray-marched through the player's strate via the generator's + // density field and snapped to the real surface — so a given prop keeps the SAME world position at + // every LOD (no teleport/pop on tile swaps). Decorations exist only in the player's current strate. + + // FAR-tier stream radius in cells (= chunks): how far FStrateDecoration entries set to EDecoStreamTier::Far + // (the default — trees, landmarks, rare props) stream out. Bigger = props visible farther + more + // spawn/march cost (but the far grid is COARSE — see DecorationFarSpacingVoxels — so far cost is cheap). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1")) + int32 DecorationRadiusChunks = 12; + + // NEAR-tier stream radius in cells (= chunks): how far EDecoStreamTier::Near entries (dense groundcover + // like grass) stream out. Keep this SHORT — near entries use the FINE grid (DecorationSpacingVoxels), so + // their cost is the steep one; bounding their radius keeps the far-region HISM build + memory small. + // (Repurposes the old vestigial DecorationActorRadiusChunks; same default.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1")) + int32 DecorationNearRadiusChunks = 6; + + // Decoration cells are grouped into REGIONS of RxR cells, and ALL placements in a region share ONE + // HISM per mesh (instead of one HISM per cell per mesh). Regions load/unload as a unit, so clearing + // stays a plain DestroyComponent — no per-instance index remapping. This is the render-thread lever: + // component count (which InitViews walks every frame) drops by ~R^2. R=4 → ~16 regions in a radius-6 + // disk vs 169 cells (~10x fewer components). Bigger R = fewer components but coarser pop-in + heavier + // one-shot cluster-tree build per region (apply is budgeted, so the build is amortised). MUST be >= 1. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16")) + int32 DecorationRegionSizeCells = 8; + + // NEAR-tier column spacing (in voxels) within a cell — the FINE grid. MUST divide CHUNK_SIZE (32): + // 4 → 8×8=64 columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity rolls + // per column-crossing. Used by EDecoStreamTier::Near entries (and is the legacy single-grid spacing). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32")) + int32 DecorationSpacingVoxels = 8; + + // FAR-tier column spacing (in voxels) within a cell — the COARSE grid. MUST divide CHUNK_SIZE (32): + // 16 → 2×2=4 columns/cell (16× fewer worker ray-marches than a spacing-4 grid). This is the lever that + // makes a RARE prop visible at every distance cheap: the far grid samples sparsely, so supporting a + // low-SpawnDensity landmark across the full radius costs a fraction of the fine grid. DEFAULTS to the + // fine value (4) so existing worlds are byte-identical until you raise it; bump to 8–16 for cheap far props. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32")) + int32 DecorationFarSpacingVoxels = 8; + + // COARSE vertical march step (in voxels) when searching a column for surface crossings. The crossing + // Z is then bisection-refined, so accuracy is independent of this — raise it (4-8) to cut the scan + // cost (the column is ray-marched on a WORKER thread, but a smaller step still means more samples). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "8")) + int32 DecorationMarchStepVoxels = 4; + + // Max surface crossings placed per column (caps cave columns that pierce many floors/ceilings; + // surface worlds have 1). Bounds worst-case march/spawn cost. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16")) + int32 DecorationMaxCrossingsPerColumn = 4; + + // Stop marching a column after this many voxels of CONTIGUOUS SOLID once it has already entered the + // open space — i.e. cap how far into bedrock BELOW the floor (or below a cavern) we keep scanning. + // The top cap/seal and the air above the ground are always marched first (this only trims the dead + // rock underneath), so surface worlds still get their ground and caves still find layered floors. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "8")) + int32 DecorationColumnDepthVoxels = 320; + + // Budget: how many completed decoration REGIONS to APPLY (game-thread HISM build + SpawnActor) per + // frame. A region is applied once ALL its cells have finished marching, in one batched AddInstances + // per mesh; this throttles that burst. The expensive ray-march runs async on workers. (Named "Cells" + // for asset back-compat — the apply unit is now a region of DecorationRegionSizeCells^2 cells.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1")) + int32 MaxDecorationCellsPerFrame = 2; + + // Max decoration ray-march tasks in flight at once. Keeps decoration marching from crowding the + // mesh-gen workers (which are the streaming bottleneck). 0 disables decorations entirely. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) + int32 MaxConcurrentDecorationTasks = 4; + + //========================================================================= + // LIGHTING — DENSITY VOLUME (mini-sun raymarched shadows) + //========================================================================= + // A player-centred CLIPMAP of the density field, uploaded to the GPU so the terrain + // material can RAYMARCH it toward the "mini-sun" orbs → from-the-orb, crisp, dynamic + // shadows under FORWARD rendering (Lumen/DF off the table). The volume is the load-bearing + // prerequisite: density is CPU-only (GetDensityAt), so we stream it onto the GPU here. + // Concentric levels: level 0 = full-res near the player (step 1), each level up doubles the + // sampling step & reach (fine near / coarse far — exactly what shadow rays want). Filled on + // WORKER threads (re-evaluating GetDensityAt → deterministic, carves auto-picked-up), with + // toroidal incremental refill on movement and localized refill on carve. See VoxelDensityVolume. + + // Master switch. OFF = no volume built, no fill tasks, no GPU cost (terrain unlit by orbs). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting") + bool bEnableDensityVolume = false; + + // Per-axis resolution of EACH clip level (cells). Memory per level ≈ Res³ bytes (R8). + // 256 → ~16 MiB/level; 512 → ~128 MiB/level. Doubling Res preserves metre reach at 12.5 cm. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "32", ClampMax = "512")) + int32 DensityVolumeResolution = 256; + + // Number of concentric clip levels. Level L samples every (1< Season; + + // Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + int32 CurrentSeason = 1; + + // Pool de définitions de strates disponibles pour l'assignation aléatoire. + // Chaque définition a sa propre hauteur — les strates ne sont PAS uniformes. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + TArray> StratePool; + + // Strates fixées: {index → définition}. Ex: {5, CrystalCaverns} = + // la strate 5 est toujours CrystalCaverns. Les autres sont tirées du pool. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + TMap> FixedStrates; + + // Nombre total de strates à générer vers le bas. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (ClampMin = "1")) + int32 TotalStrates = 10; + + // Vertical SEPARATION between consecutive strates, in chunks of solid bedrock. + // 0 = strates touch (just the seals between them). Higher = a thick rock layer + // between each biome that the player must dig through at (0,0), and that the + // auto-carved passages tunnel across. + // 0 → adjacent layers (default) · 1-2 → a real bedrock band · 4+ → deep separation + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (ClampMin = "0", ClampMax = "16")) + int32 InterStrateGapChunks = 0; + + //========================================================================= + // SPINE & INTER-STRATE CONNECTIONS (the (0,0) descent axis) + //========================================================================= + + // Authored radius (voxels) of the guaranteed (0,0) landing room in EVERY strate, regardless + // of archetype. The §6.5 body-sized formulas turn it into a flat-floored room reserved for a + // future straight shaft of diameter 2 * this radius. At the default 28-voxel radius that is + // 56 voxels / 7 m, with a six-voxel / 0.75 m floor ledge around it. This setting does not bore the + // shaft today: lower seals remain solid until a progression passage is opened. + // 10-14 → compact landing room (default) · 20+ → broad landing room + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine", meta = (ClampMin = "0.0")) + float OriginSpineRadius = 28.0f; + + // Auto-open the very top seal at (0,0) so the world starts with a hole to the + // surface (the entry shaft). Lower boundaries are NOT auto-opened — the player + // digs those. Disable for a fully sealed world the player must breach from above. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine") + bool bOpenSurfaceEntry = true; + + // NOTE: inter-strate tunnel count + shape (style, width, length, placement, worming, + // spiral, cascade) are now PER-STRATE — see UVoxelStrateDefinition::PassageConfig. + // Each strate controls its own descent tunnels to the layer below it. + + //========================================================================= + // CARVING (modifications du joueur) + //========================================================================= + + // Nombre max d'opérations de modification par saison. 0 = illimité. + // 500 = édition légère, 2000 = mining modéré, 0 = créatif. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Carving", + meta = (ClampMin = "0")) + int32 MaxModifications = 0; + + // Rayon max d'un brush (en voxels). Empêche les édits géants. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Carving", + meta = (ClampMin = "1.0")) + float MaxBrushRadius = 30.0f; + + // Volume total maximum (somme des volumes de brush). 0 = illimité. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Carving", + meta = (ClampMin = "0.0")) + float MaxTotalVolume = 0.0f; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelStartupTrace.h b/Standalone/Source/VoxelForge/Public/VoxelStartupTrace.h new file mode 100644 index 0000000..3f4a921 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStartupTrace.h @@ -0,0 +1,65 @@ +// Bounded, opt-in startup trace for the complete streaming path. +// The trace is deliberately independent of the shared Unreal log: callers provide an +// absolute output path and every record is one JSON object per line. +#pragma once + +#include "CoreMinimal.h" + +namespace VoxelForgeStartupTrace +{ + struct VOXELFORGE_API FTileSample + { + int32 TileX = 0; + int32 TileY = 0; + int32 TileZ = 0; + int32 Level = -1; + int32 Verdict = -1; // Mixed=0, AllSolid=1, AllAir=2, -1=not classified (early path) + bool bSealedSolidProof = false; + bool bEmpty = true; + int32 Vertices = 0; + int32 Triangles = 0; + uint64 GeometryBytes = 0; + // This is worker-result -> game-thread mesh submission. Collision readiness is a later + // per-tile RMC completion event recorded separately as collision_ready. + double RequestToApplySeconds = 0.0; + double QueueWaitSeconds = 0.0; + double WorkerQueueSeconds = 0.0; + double ResultQueueSeconds = 0.0; + double GenerationSeconds = 0.0; + double ClassifySeconds = 0.0; + double MeshSeconds = 0.0; + double StreamSeconds = 0.0; + double ApplySeconds = 0.0; + }; + + /** Starts when -voxel.StartupTraceFile= is present. */ + VOXELFORGE_API void BeginFromCommandLine(); + + VOXELFORGE_API bool IsActive(); + + /** FieldsJson is the contents after the type/timestamp object prefix, without braces. */ + VOXELFORGE_API void RecordEvent(const TCHAR* Type, const FString& FieldsJson = FString()); + + VOXELFORGE_API void StageBegin(const TCHAR* Name); + VOXELFORGE_API void StageEnd(const TCHAR* Name); + + VOXELFORGE_API void RecordTile(const FTileSample& Sample); + + /** Writes the final bounded summary and closes the file. Safe to call more than once. */ + VOXELFORGE_API void Finish(const TCHAR* Reason); + + /** RAII helper for stages which have early-return failure paths. */ + class VOXELFORGE_API FStageScope + { + public: + explicit FStageScope(const TCHAR* InName); + ~FStageScope(); + + FStageScope(const FStageScope&) = delete; + FStageScope& operator=(const FStageScope&) = delete; + + private: + FString Name; + bool bActive = false; + }; +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelStats.h b/Standalone/Source/VoxelForge/Public/VoxelStats.h new file mode 100644 index 0000000..9cc9d81 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStats.h @@ -0,0 +1,27 @@ +// VoxelStats.h +// Per-frame runtime counters for tile classification and meshing. +// Compteurs runtime par frame pour la classification et le meshing des tuiles. + +#pragma once + +#include "Stats/Stats.h" + +DECLARE_STATS_GROUP(TEXT("VoxelForge"), STATGROUP_VoxelForge, STATCAT_Advanced); + +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Classified"), STAT_VoxelForgeTilesClassified, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Solid"), STAT_VoxelForgeTilesSkippedAllSolid, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Air"), STAT_VoxelForgeTilesSkippedAllAir, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Meshed"), STAT_VoxelForgeTilesMeshed, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Solid"), STAT_VoxelForgeTilesOpStackSolid, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Air"), STAT_VoxelForgeTilesOpStackAir, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Out Of Layout"), STAT_VoxelForgeCaveMixOutOfLayout, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Gap"), STAT_VoxelForgeCaveMixGap, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Surface World"), STAT_VoxelForgeCaveMixSurfaceWorld, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Two Cave Slots"), STAT_VoxelForgeCaveBailTwoCaveSlots, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Fold Conflict"), STAT_VoxelForgeCaveBailFoldConflict, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API); +DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Misses"), STAT_VoxelForgeColumnMemoMiss, STATGROUP_VoxelForge, VOXELFORGE_API); diff --git a/Standalone/Source/VoxelForge/Public/VoxelStrateComposer.h b/Standalone/Source/VoxelForge/Public/VoxelStrateComposer.h new file mode 100644 index 0000000..d832a15 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStrateComposer.h @@ -0,0 +1,821 @@ +// Offline strate corpus and parameter composer. +// +// This is deliberately not part of the runtime generation path. The composer is a build-box +// tool/test API: it loads authored vectors, measures their spread, and makes deterministic +// candidates by blending those vectors before applying spread-relative (or season-zero bootstrap) +// jitter. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelStrateTypes.h" +#include "VoxelDensityOpStack.h" +#include "VoxelStrateMeasure.h" + +#include "VoxelStrateComposer.generated.h" + +class UVoxelSettings; +class UVoxelStrateDefinition; +class UVoxelStrateManager; + +#if WITH_EDITOR +// The complete definitions live in VoxelSeasonManifest.h. Keeping this forward declaration here +// makes the Tier 4c entry point discoverable from the composer header without creating a circular +// include (the manifest header includes this one for the existing vector/recipe types). +struct FVoxelSeasonManifest; +struct FVoxelSeasonCompositionSettings; +VOXELFORGE_API FVoxelSeasonManifest VF_ComposeSeason( + int32 SeasonSeed, const FVoxelSeasonCompositionSettings& Settings); +#endif + +/** The one-bit identity choice at the root of a rolled structure. */ +UENUM(BlueprintType) +enum class EVoxelStrateRootPolarity : uint8 +{ + RockCarve = 0, + VoidFill = 1, +}; + +/** Native parameter family an op reads when a recipe is materialised. */ +UENUM(BlueprintType) +enum class EVoxelStrateParamBlock : uint8 +{ + None = 0, + TunnelNetwork = 1, + Slab = 2, + Maze = 3, + Surface = 4, + VerticalShaft = 5, + FloatingIsland = 6, +}; + +/** Stable manifest id for every op the offline structure roller may place. */ +UENUM(BlueprintType) +enum class EVoxelStrateOpClass : uint8 +{ + ConstantRockSource = 0, + ConstantVoidSource = 1, + RoomGraphSource = 2, + LatticeCorridorSource = 3, + ShaftFieldSource = 4, + IslandBlobSource = 5, + NoiseRibbonSource = 6, + SdfRoughnessMod = 7, + SdfCarve = 8, + SdfFill = 9, + GridColumnMod = 10, + CaveRoughnessMod = 11, + CaveTerraceMod = 12, + LayerLineMod = 13, + RibbingMod = 14, + CaveOverhangMod = 15, + CaveCliffMod = 16, + ScallopMod = 17, + CaveArchMod = 18, + RoomColumnMod = 19, + DomeMod = 20, + PinchMod = 21, + FloorBiasMod = 22, + WormFieldSource = 23, + ShaftLedgeMod = 24, + DensityNoiseCarveMod = 25, + DensityNoiseFillMod = 26, +}; + +/** One creative op in the serialisable structure manifest. */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelOpRecipeEntry +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateOpClass OpClass = EVoxelStrateOpClass::ConstantRockSource; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateParamBlock ParamBlock = EVoxelStrateParamBlock::None; +}; + +/** + * Serialisable creative structure. Structural posts are intentionally absent: the recipe builder + * appends spine, vertical seal, passage carve, and XY edge seal in that fixed order every time. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelOpStackRecipe +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateRootPolarity RootPolarity = EVoxelStrateRootPolarity::RockCarve; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + FVoxelOpRecipeEntry Root; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + FVoxelOpRecipeEntry ShapeSource; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + FVoxelOpRecipeEntry Conversion; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + TArray Modifiers; + + // Which native block supplies the mandatory structural post parameters. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Recipe") + EVoxelStrateParamBlock StructuralParamBlock = EVoxelStrateParamBlock::None; +}; + +/** The kind of value represented by one entry of VF_STRATE_PARAM_FIELDS. */ +enum class EVoxelStrateFieldKind : uint8 +{ + Continuous, + Integer, + Boolean, + Enum, +}; + +/** A field deliberately kept out of the parameter roll after the measured liveness audit. */ +struct VOXELFORGE_API FVoxelStrateFieldExclusion +{ + FString FieldName; + FString Reason; +}; + +/** Measured statistics for one field in one archetype's corpus group. */ +struct VOXELFORGE_API FVoxelStrateFieldSpread +{ + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FString ParamStructName; + FString FieldName; + EVoxelStrateFieldKind Kind = EVoxelStrateFieldKind::Continuous; + bool bExcluded = false; + bool bReflected = false; + + // Actual authored corpus samples and synthetic per-operation default samples are kept + // separate in the report. SampleCount is their effective total used for the statistics. + int32 AuthoredSampleCount = 0; + int32 PromotedSampleCount = 0; + int32 DefaultSeedCount = 0; + int32 SampleCount = 0; + double Min = 0.0; + double Max = 0.0; + double Mean = 0.0; + double StdDev = 0.0; + + // These are copied from UPROPERTY ClampMin/ClampMax when reflection metadata is available in + // the current build. They are safety limits, never the source of a roll range. + bool bHasClampMin = false; + bool bHasClampMax = false; + double ClampMin = 0.0; + double ClampMax = 0.0; +}; + +/** Where a member of the offline corpus came from. */ +enum class EVoxelStrateCorpusProvenance : uint8 +{ + Project, + Default, + Promoted, +}; + +/** The finite, diffable metric summary stored with a promoted strate. */ +struct VOXELFORGE_API FVoxelStrateMeasuredMetrics +{ + bool bValid = false; + + int64 NumSampled = 0; + int64 NumAir = 0; + int64 NumSolid = 0; + float AirFraction = 0.0f; + int32 NumAirComponents = 0; + float LargestComponentShare = 0.0f; + FVector LargestComponentPoint = FVector::ZeroVector; + int64 LargestComponentCells = 0; + int32 NumComponentsAtLeast1Pct = 0; + float WalkableFraction = 0.0f; + int64 WalkableFloorColumns = 0; + float WalkableFloorAreaFraction = 0.0f; + int32 NumWalkableSurfaceComponents = 0; + int64 LargestWalkableSurfaceColumns = 0; + float LargestWalkableSurfaceShare = 0.0f; + float MedianFeatureScale = 0.0f; + int32 MedianVerticalClearance = 0; + + bool bPlayerFitResolved = false; + FString PlayerFitRefusalReason; + int64 NumPlayerFitCells = 0; + float PlayerFitFraction = 0.0f; + int32 NumTraversableComponents = 0; + int64 LargestTraversableComponentCells = 0; + float TraversableComponentShare = 0.0f; + float MinimumPlayerClearanceVoxels = 0.0f; + + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + int32 SampledNumX = 0; + int32 SampledNumY = 0; + int32 SampledNumZ = 0; + float SampledMinX = 0.0f; + float SampledMaxX = 0.0f; + float SampledMinY = 0.0f; + float SampledMaxY = 0.0f; + + // This is the component-size audit trail, not a retained density grid. It is bounded by the + // number of discovered air components and is useful when reviewing a promoted record. + TArray AirComponentCells; + + bool IsUsable() const; +}; + +/** Copy the measured facts that are safe and useful to persist; no sampler or grid is retained. */ +VOXELFORGE_API FVoxelStrateMeasuredMetrics VF_SummarizeStrateMetrics( + const FVoxelStrateMetrics& Metrics); + +/** Native parameter storage for every currently supported strate archetype. */ +struct VOXELFORGE_API FVoxelStrateArchetypeParams +{ + FStrateGenerationParams TunnelNetworkParams; + FSlabGenerationParams SlabParams; + FMazeGenerationParams MazeParams; + FSurfaceGenerationParams SurfaceParams; + FVerticalShaftParams VerticalShaftParams; + FFloatingIslandParams FloatingIslandParams; +}; + +/** + * One lateral region in a strate. + * + * This is deliberately a plain manifest record rather than a label on the strate. The recipe + * and the native vector belong to the region, and the runtime combines the resulting density + * fields. Structural posts are not region-owned. + */ +struct VOXELFORGE_API FVoxelStrateRegion +{ + int32 RegionIndex = 0; + int32 Seed = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ArchetypeParams; + bool bUsesRecipe = false; + FVoxelOpStackRecipe Recipe; +}; + +/** + * Offline-composed lateral region manifest for one strate. + * + * `StrateIndex` is part of the partition identity. A candidate can be moved to another live + * slot, so the editor hand-off must call VF_RekeyStrateRegionManifest before installing it. + * `PartitionSeed` is retained in the manifest so the runtime never has to recreate an RNG stream. + */ +struct VOXELFORGE_API FVoxelStrateRegionManifest +{ + bool bValid = false; + FString FailureReason; + + int32 Seed = 0; + int32 StrateIndex = 0; + int32 RegionCount = 1; + uint32 PartitionSeed = 0; + + // A 32-voxel chunk sees a tiny portion of this lattice. The values are manifest data, not + // hidden runtime tuning knobs; changing them changes the authored world and its hash. + float LatticeCellSize = 256.0f; + float BlendWidth = 24.0f; + + // One global structural post configuration. Region recipes may use different native blocks, + // but no region is allowed to choose the spine/seal/passage/edge-seal values independently. + bool bHasGlobalStructuralParams = false; + EVoxelStrateParamBlock StructuralParamBlock = EVoxelStrateParamBlock::None; + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; + float BoundarySealThickness = 0.0f; + float BaseDensity = 8.0f; + + TArray Regions; + + bool IsSingleRegion() const { return RegionCount == 1 && Regions.Num() == 1; } + + bool IsValid() const + { + return bValid && RegionCount >= 1 && RegionCount <= 3 + && Regions.Num() == RegionCount + && FMath::IsFinite(LatticeCellSize) && LatticeCellSize > 0.0f + && FMath::IsFinite(BlendWidth) && BlendWidth >= 0.0f; + } +}; + +/** Result of the positional Voronoi query used by the lateral density combiner. */ +struct VOXELFORGE_API FVoxelStrateRegionQuery +{ + int32 PrimaryRegion = 0; + int32 NeighborRegion = INDEX_NONE; + float NeighborWeight = 0.0f; + float NearestDifferentRegionGap = FLT_MAX; + // False means the bounded lattice neighborhood could not prove that its different-region + // candidate is the nearest one. Density evaluation may still use a zero blend weight, but a + // ClassifyBox proof must refuse to call that an infinite boundary distance. + bool bNearestDifferentRegionKnown = false; + bool bInBlendBand = false; +}; + +/** Conservative XY proof used before a tile may be skipped. */ +struct VOXELFORGE_API FVoxelStrateRegionBoxProof +{ + bool bProvablySingleRegion = false; + bool bTouchesRegionBoundary = true; + bool bTouchesBlendBand = true; +}; + +/** One hash-lattice site retained by the per-chunk partition cache. */ +struct VOXELFORGE_API FVoxelStrateRegionSite +{ + int32 CellX = 0; + int32 CellY = 0; + int32 Region = 0; + float WorldX = 0.0f; + float WorldY = 0.0f; +}; + +/** + * Worker-local cache for the lateral partition. + * + * Hash/site discovery and the integer XY grid are prepared once for a chunk. The voxel path + * then does an O(1) lookup for the ordinary integer density samples; only fractional gradient + * probes use the small prepared site list. No seed lattice is searched or allocated per voxel. + */ +struct VOXELFORGE_API FVoxelStrateRegionPartitionCache +{ + void PrepareForChunk(const FVoxelStrateRegionManifest& Manifest, + const FIntVector& ChunkCoord); + FVoxelStrateRegionQuery Query(float WorldX, float WorldY) const; + +private: + int32 RegionCount = 1; + uint32 PartitionSeed = 0; + float LatticeCellSize = 256.0f; + float BlendWidth = 24.0f; + int32 BaseX = 0; + int32 BaseY = 0; + int32 Dim = 0; + TArray Sites; + TArray IntegerSamples; +}; + +/** Set every native family’s runtime Z bounds to the same strate interval. */ +VOXELFORGE_API void VF_SetStrateArchetypeRuntimeBounds( + FVoxelStrateArchetypeParams& Params, float TopWorldZ, float BottomWorldZ); + +/** Stable identity helpers for the deterministic lateral partition. */ +#if WITH_EDITOR +VOXELFORGE_API int32 VF_RollStrateRegionCount(int32 Seed, int32 StrateIndex); +#endif +VOXELFORGE_API uint32 VF_GetStrateRegionPartitionSeed(int32 Seed, int32 StrateIndex); +VOXELFORGE_API void VF_RekeyStrateRegionManifest( + FVoxelStrateRegionManifest& Manifest, int32 Seed, int32 StrateIndex); + +/** Pure positional query; no retained state and no ordering-dependent RNG. */ +VOXELFORGE_API FVoxelStrateRegionQuery VF_QueryStrateRegion( + const FVoxelStrateRegionManifest& Manifest, float WorldX, float WorldY); + +/** Conservative proof for an XY projection of a voxel box. */ +VOXELFORGE_API FVoxelStrateRegionBoxProof VF_AnalyzeStrateRegionBox( + const FVoxelStrateRegionManifest& Manifest, const FBox& VoxelBox); + +/** One known-good authored vector and the archetype that gives it meaning. */ +struct VOXELFORGE_API FVoxelStrateCorpusEntry +{ + FString SourcePath; + FString SourceName; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + + // Native storage is selected by Archetype. Keeping every family here makes the corpus + // type-safe without changing any runtime generation parameter struct. + FVoxelStrateArchetypeParams ArchetypeParams; + + // Compatibility view for callers of the original Tier 4a API. It is meaningful for + // TunnelNetwork/Underwater entries and is the same value as ArchetypeParams.TunnelNetworkParams. + FStrateGenerationParams Params; + + // The current asset format has no corpus weight field. Loaded hand-authored entries therefore + // use 1.0. The field remains explicit so a future offline corpus can assign weights without + // changing the roll algorithm. + float Weight = 1.0f; + + EVoxelStrateCorpusProvenance Provenance = EVoxelStrateCorpusProvenance::Project; + + // Promoted entries retain their recipe and re-measured facts so the next policy decision can + // compare measured geometry, not only parameter-space distance. Project/default entries leave + // these fields unset unless an offline simulation explicitly measures them. + bool bHasRecipe = false; + FVoxelOpStackRecipe Recipe; + bool bHasMeasuredMetrics = false; + FVoxelStrateMeasuredMetrics MeasuredMetrics; + + FString PromotionRecordId; + int32 PromotionSeason = INDEX_NONE; + int32 PromotionSeed = 0; + int32 PromotionCandidateIndex = INDEX_NONE; + uint32 PromotionInputCorpusHash = 0; +}; + +/** A candidate record that is eligible to be considered for corpus promotion. */ +struct VOXELFORGE_API FVoxelStratePromotableRecord +{ + FString RecordId; + int32 Season = 0; + int32 Seed = 0; + int32 CandidateIndex = INDEX_NONE; + uint32 InputCorpusHash = 0; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams Params; + FVoxelOpStackRecipe Recipe; + FVoxelStrateMeasuredMetrics MeasuredMetrics; + + // These are recorded evidence for review. The loader deliberately ignores the stored metric + // and gate values and asks its verifier to rebuild/re-measure the recipe instead. + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; +}; + +/** Fresh validation output used by the safe promoted-record loader. */ +struct VOXELFORGE_API FVoxelStratePromotionVerification +{ + bool bPassedNonVacuous = false; + bool bPassedLargestComponent = false; + bool bPassedPrimordialLaw = false; + FVoxelStrateMeasuredMetrics MeasuredMetrics; + FString FailureReason; + + bool PassedAllGates() const + { + return bPassedNonVacuous && bPassedLargestComponent && bPassedPrimordialLaw + && MeasuredMetrics.IsUsable(); + } +}; + +/** + * World-specific re-measurement seam for promoted records. + * + * A corpus store cannot know the active fixture, mouths, or validation window. Its caller must + * provide this verifier; a null verifier means promoted records are skipped rather than trusted. + */ +class VOXELFORGE_API IVoxelStratePromotionVerifier +{ +public: + virtual ~IVoxelStratePromotionVerifier() = default; + virtual bool Verify(const FVoxelStratePromotableRecord& Record, + FVoxelStratePromotionVerification& OutVerification) const = 0; + + // Optional companion audit for project/default members. A policy pass can then compare a + // candidate against the measured corpus rather than falling back to parameter identity. A + // world-specific verifier may return false when that fixture cannot measure the member. + virtual bool MeasureCorpusEntry(const FVoxelStrateCorpusEntry& Entry, + FVoxelStrateMeasuredMetrics& OutMetrics) const + { + (void)Entry; + (void)OutMetrics; + return false; + } +}; + +/** Explicit, provisional Tier 5 policy. The owner can change these after seeing the preview. */ +struct VOXELFORGE_API FVoxelStratePromotionPolicy +{ + // Five normalized measured dimensions are compared with Euclidean distance. 0.20 is large + // enough to reject close copies while still admitting genuinely different geometry; it is a + // provisional review threshold, not a claim that this is the owner's final definition of good. + double MinimumNormalizedMeasuredMetricDistance = 0.20; + int32 MaxPromotionsPerSeason = 6; + + // Physical normalization for the two unbounded-in-principle metric dimensions. Air fraction, + // largest share, and walkable fraction already live in [0,1]. + float FeatureScaleNormalizationVoxels = 256.0f; + float ClearanceNormalizationVoxels = 64.0f; +}; + +/** Counters and accepted records from one deterministic policy pass. */ +struct VOXELFORGE_API FVoxelStratePromotionBatchResult +{ + bool bPolicyValid = false; + TArray Promoted; + int32 NumCandidates = 0; + int32 NumValidationRejected = 0; + int32 NumCorpusHashRejected = 0; + int32 NumNearDuplicateRejected = 0; + int32 NumCapRejected = 0; + double MinimumAcceptedDistance = 0.0; +}; + +/** One line of the diffable season manifest written beside the cumulative record store. */ +struct VOXELFORGE_API FVoxelStrateSeasonManifest +{ + int32 Season = 0; + int32 Seed = 0; + uint32 InputCorpusHash = 0; + int32 CandidateCount = 0; + int32 SurvivorCount = 0; + int32 PromotedCount = 0; + int32 CorpusSize = 0; + int32 ProjectCount = 0; + int32 DefaultCount = 0; + int32 PromotedCorpusCount = 0; + float SurvivalRate = 0.0f; + double MeasuredSpread = 0.0; +}; + +/** Result of one deterministic roll, including provenance for the offline report. */ +struct VOXELFORGE_API FVoxelStrateRollInfo +{ + bool bValid = false; + FString FailureReason; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ArchetypeParams; + + // Compatibility view for the original tunnel-only API. For a non-tunnel roll, the native + // result is in ArchetypeParams and this member is not the active candidate vector. + FStrateGenerationParams Params; + + TArray ParentEntryIndices; + TArray ParentWeights; + int32 DominantParentPosition = INDEX_NONE; + + // Provenance for the starved-corpus fallback. Names are keyed as "archetype:field" so a + // report can distinguish field descriptors from the number of times they were applied. + int32 BootstrapJitterFieldCount = 0; + TArray BootstrapJitterFieldNames; +}; + +#if WITH_EDITOR +/** + * One candidate materialised by the same deterministic calls used by the offline composer tests. + * This is an editor-only hand-off object: it is never a reflected asset and never part of the + * shipping generation contract. + */ +struct VOXELFORGE_API FVoxelStrateComposerCandidate +{ + bool bValid = false; + FString FailureReason; + + int32 Seed = 0; + int32 Index = 0; + bool bStructureRoll = false; + + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams ArchetypeParams; + FVoxelOpStackRecipe Recipe; + FVoxelStrateRegionManifest Regions; + + // Kept so automation/editor callers can audit the exact six independent structure blocks + // without re-rolling them through a second implementation. + FVoxelStrateRollInfo ParameterRoll; + TArray StructureBlockRolls; +}; +#endif + +/** + * Known-good strate vectors plus their measured field spread. + * + * `LoadFromAssetRegistry` enumerates every project UVoxelStrateDefinition through the Asset + * Registry, explicitly excluding Saved/Autosaves and Saved/Cooked copies. It also adds one + * known-good default vector for every archetype/family in VoxelStrateTypes.h, then optionally + * loads the cumulative promoted-record JSON store. Promoted records enter only after the caller's + * world-specific verifier rebuilds and remeasures them; with no verifier they are skipped safely. + * Parents are selected only inside the exact archetype group; no cross-archetype blend is attempted. + */ +class VOXELFORGE_API FVoxelStrateCorpus +{ +public: + void Reset(); + + bool LoadFromAssetRegistry( + FString& OutReport, + const FString& PromotedStorePath = FString(), + const IVoxelStratePromotionVerifier* PromotionVerifier = nullptr); + + bool LoadFromDefinitions(const TArray& Definitions, FString& OutReport); + + /** Add an already-resolved vector, primarily for offline tools and focused tests. */ + bool AddEntry(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, const FStrateGenerationParams& Params, + float Weight = 1.0f); + bool AddEntry(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, const FVoxelStrateArchetypeParams& Params, + float Weight = 1.0f); + + /** Load cumulative promoted records, re-verifying every record before it enters Entries. */ + bool LoadPromotedRecords(const FString& StorePath, + const IVoxelStratePromotionVerifier* PromotionVerifier, + FString& OutReport); + + /** Attach an offline measurement to an existing member without changing corpus identity. */ + bool SetMeasuredMetrics(const FString& SourcePath, + const FVoxelStrateMeasuredMetrics& Metrics); + + bool IsValid() const { return bSchemaValid && Entries.Num() > 0; } + bool IsSchemaValid() const { return bSchemaValid; } + const FString& GetSchemaError() const { return SchemaError; } + + int32 Num() const { return Entries.Num(); } + const TArray& GetEntries() const { return Entries; } + const TArray& GetFieldSpreads() const { return FieldSpreads; } + + int32 NumForArchetype(ECaveGeneratorType Archetype) const; + int32 NumForProvenance(EVoxelStrateCorpusProvenance Provenance) const; + + const FVoxelStrateFieldSpread* FindSpread( + ECaveGeneratorType Archetype, const FString& FieldName) const; + const FVoxelStrateFieldSpread* FindSpread(const FString& FieldName) const; + + /** Stable hash of source paths, archetypes, weights, and every listed scalar corpus field. */ + uint32 GetContentsHash() const; + + /** Explicit exclusion list retained after the fixed-lattice terrain-detail liveness audit. */ + static const TArray& GetNonTunableFields(); + + /** True in an editor build where UPROPERTY metadata was available while spreads were built. */ + bool AreClampMetadataAvailableAtBuild() const { return bClampMetadataAvailableAtBuild; } + + /** False by policy: metadata is not promised to survive into a cooked runtime. */ + bool AreClampMetadataAvailableAtRuntime() const { return false; } + +private: + bool AddEntryInternal(const FString& SourcePath, const FString& SourceName, + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + float Weight, EVoxelStrateCorpusProvenance Provenance, + const FVoxelOpStackRecipe* Recipe, + const FVoxelStrateMeasuredMetrics* Metrics, + const FString& PromotionRecordId, + int32 PromotionSeason, + int32 PromotionSeed, + int32 PromotionCandidateIndex, + uint32 PromotionInputCorpusHash, + bool bRebuild); + void RebuildSpreads(); + + TArray Entries; + TArray FieldSpreads; + TArray SkippedDefinitions; + FString SchemaError; + bool bSchemaValid = true; + bool bClampMetadataAvailableAtBuild = false; +}; + +/** Human-readable name used by the offline report. */ +VOXELFORGE_API const TCHAR* VF_GetStrateArchetypeName(ECaveGeneratorType Archetype); +VOXELFORGE_API const TCHAR* VF_GetStrateCorpusProvenanceName( + EVoxelStrateCorpusProvenance Provenance); + +/** Full provenance result. Pure with respect to the corpus: no global or retained RNG state. */ +VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailed( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index); + +/** Roll one exact native family, used to feed the independent blocks of a novel recipe. */ +VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsDetailedForArchetype( + const FVoxelStrateCorpus& Corpus, ECaveGeneratorType Archetype, int32 Seed, int32 Index); + +/** Pure structure roll: no retained RNG state and no corpus/runtime dependency. */ +VOXELFORGE_API FVoxelOpStackRecipe VF_RollStrateStructure(int32 Seed, int32 Index); + +/** Roll one-to-three independent region recipes/vectors plus their deterministic partition. */ +VOXELFORGE_API FVoxelStrateRegionManifest VF_RollStrateRegionManifest( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 StrateIndex, bool bRollStructure); + +/** + * ⛔ THE LATERAL-REGION GATE. Returns false, and must keep returning false until the seam law holds. + * + * Lateral regions (archetypes mixing inside one strate — "tunnel network leading to a big chamber") + * are BUILT and MEASURED but NOT SHIPPABLE. Measured 2026-09-05: `arrival -> departure` across a + * region seam passes **9 of 16 seeds**, against 11/16 for same-region controls in the same harness. + * + * Density blending alone does not guarantee a player can cross a seam. A strate that mixes archetypes + * and strands you at the boundary is strictly worse than one that does not mix — so this stays off. + * + * ⚠️ DO NOT flip this to true to "enable the feature". The fix is an explicit corridor/landing + * contract across seams (a design decision, deliberately not made unilaterally), after which the + * measured rate must be 16/16. `VoxelForge.Composer.LateralRegions` asserts that this gate is shut + * whenever the rate is imperfect, so flipping it without fixing the law turns the suite red. + * + * Safe today regardless: single-region output is bit-identical to legacy, and cross-boundary box + * verdicts returned 0 violations over 199,800 brute-forced samples (all correctly `Mixed`). + */ +VOXELFORGE_API bool VF_LateralRegionsAreShippable(); + +/** + * Provenance for a parameter block that does not read the authored corpus. + * + * NaiveUniform is the §3.3 control: every scalar is rolled independently inside the same + * finite code-declared envelope used by the constraint arm. ConstraintSampled draws the + * independent geometry quantities and derives the coupled quantities from generator equations. + * Neither mode reads a strate asset or a corpus entry. + */ +enum class EVoxelStrateCorpusFreeSamplingMode : uint8 +{ + NaiveUniform, + ConstraintSampled, +}; + +/** Roll one native parameter family without consulting FVoxelStrateCorpus. */ +VOXELFORGE_API FVoxelStrateRollInfo VF_RollStrateParamsCorpusFree( + ECaveGeneratorType Archetype, + int32 Seed, + int32 Index, + EVoxelStrateCorpusFreeSamplingMode Mode, + float StrateHeightInVoxels); + +/** Validate the hard geometry relations used by ConstraintSampled and the corpus audit. */ +VOXELFORGE_API bool VF_ValidateStrateCorpusFreeConstraints( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + float StrateHeightInVoxels, + FString& OutViolation); + +/** Normalized Euclidean distance in the five policy metric dimensions. */ +VOXELFORGE_API double VF_NormalizedMeasuredMetricDistance( + const FVoxelStrateMeasuredMetrics& A, + const FVoxelStrateMeasuredMetrics& B, + const FVoxelStratePromotionPolicy& Policy); + +/** Select deterministic, validated, varied records for one season. */ +VOXELFORGE_API FVoxelStratePromotionBatchResult VF_SelectStratePromotions( + const FVoxelStrateCorpus& Corpus, + const TArray& Candidates, + const FVoxelStratePromotionPolicy& Policy); + +/** Write/readable cumulative JSON store; records are sorted by RecordId before writing. */ +VOXELFORGE_API bool VF_SaveStratePromotedRecords( + const FString& StorePath, + const TArray& Records, + FString& OutReport); + +/** Write the matching season summary beside the promoted-record store. */ +VOXELFORGE_API bool VF_SaveStrateSeasonManifest( + const FString& ManifestPath, + const FVoxelStrateSeasonManifest& Manifest, + FString& OutReport); + +#if WITH_EDITOR +/** + * Roll one candidate for the editor walk-through path. Parameter rolls call + * VF_RollStrateParamsDetailed; structure rolls call VF_RollStrateStructure and the exact six + * VF_RollStrateParamsDetailedForArchetype block rolls used by the structure test. + */ +VOXELFORGE_API FVoxelStrateComposerCandidate VF_RollStrateCandidate( + const FVoxelStrateCorpus& Corpus, int32 Seed, int32 Index, bool bRollStructure); + +/** + * Build a parameter-roll candidate through the same archetype-to-stack mapping used by production + * GetDensityAt/ClassifyTile. This is editor-only so the offline composer remains absent from a + * shipping build; the result is used only for the post-apply density sanity check. + */ +VOXELFORGE_API bool VF_BuildNativeStrateStackForCandidate( + ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, + int32 Seed, + float SpineRadius, + float WorldRadiusVoxels, + float EdgeSealThickness, + const UVoxelStrateManager* StrateManager, + FVoxelOpStack& OutStack, + FVoxelOpContext& OutContext); + +/** Build the persisted record from the exact editor candidate and its just-measured gate facts. */ +VOXELFORGE_API FVoxelStratePromotableRecord VF_MakeStratePromotableRecord( + const FVoxelStrateComposerCandidate& Candidate, + const FVoxelStrateMetrics& Metrics, + int32 Season, + uint32 InputCorpusHash, + bool bPassedNonVacuous, + bool bPassedLargestComponent, + bool bPassedPrimordialLaw); +#endif + +/** Stable compact representation and equality/hash helpers for manifests and reports. */ +VOXELFORGE_API FString VF_FormatStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe); +VOXELFORGE_API uint32 VF_HashStrateStructureRecipe(const FVoxelOpStackRecipe& Recipe); +VOXELFORGE_API bool VF_AreStrateStructureRecipesIdentical( + const FVoxelOpStackRecipe& A, const FVoxelOpStackRecipe& B); + +/** Bitwise comparison over exactly the fields in FStrateGenerationParams::Lerp. */ +VOXELFORGE_API bool VF_AreStrateParamsBitIdentical( + const FStrateGenerationParams& A, const FStrateGenerationParams& B); + +/** Bitwise comparison of the native vector for one archetype family. */ +VOXELFORGE_API bool VF_AreStrateArchetypeParamsBitIdentical( + const FVoxelStrateArchetypeParams& A, + const FVoxelStrateArchetypeParams& B, + ECaveGeneratorType Archetype); + +/** + * Normalised distance from the measured centroid of one exact-archetype corpus group. + * + * This is an offline ordering aid for the preview only. Continuous/integer fields are measured + * in their corpus units; zero-spread fields use a conservative scale floor so the value remains + * finite without turning clamps into roll ranges. It never participates in generation. + */ +VOXELFORGE_API double VF_DistanceFromStrateCorpusCentroid( + const FVoxelStrateCorpus& Corpus, + const FVoxelStrateArchetypeParams& Params, + ECaveGeneratorType Archetype); diff --git a/Standalone/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Standalone/Source/VoxelForge/Public/VoxelStrateDefinition.h new file mode 100644 index 0000000..5fdb22f --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -0,0 +1,394 @@ +// VoxelStrateDefinition.h +// Data asset defining everything about a single strate (layer) type. +// +// HOW TO USE: +// ----------- +// 1. Right-click in Content Browser → Miscellaneous → Data Asset +// 2. Pick "VoxelStrateDefinition" as the class +// 3. Fill in the fields — cave shape, visuals, content lists, audio, gameplay tags +// 4. Reference it from VoxelSettings (StratePool or FixedStrates) +// +// Each asset is a strate TYPE (e.g., "CrystalCaverns"). +// Multiple strate slots can use the same definition. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "GameplayTagContainer.h" +#include "VoxelStrateTypes.h" +#include "VoxelBiomeTypes.h" +#include "Templates/SubclassOf.h" // IWYU : TSubclassOf (Atmosphere/Ceiling/FloorLayerActor) +#include "VoxelStrateDefinition.generated.h" + +class UVoxelBiomeDefinition; +// IWYU : tous en pointeur ou en paramètre de TSubclassOf ⇒ déclarations avant suffisantes. +// Le PCH partagé les fournissait ; `FPSemantics = Precise` (AUDIT §C9) nous en prive. +// All pointer-only or TSubclassOf parameters, so forward declarations suffice. +class UMaterialInterface; +class USoundBase; +class AActor; + +/** + * UVoxelStrateDefinition — The content bag for a strate type. + * + * This is the central piece of the strate system. Every field here + * defines what makes a strate unique: how its caves form, what it + * looks like, what lives in it, how it sounds, and what gameplay + * rules apply. + * + * EXAMPLE STRATES: + * - "Crystal Caverns": big open caves, blue fog, crystal decorations + * - "Tight Tunnels": narrow caves, dark, spider creatures + * - "Flooded Galleries": flat floors, water, diving required + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelStrateDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + virtual void PostLoad() override; + + //========================================================================= + // IDENTIFICATION + //========================================================================= + + // Human-readable name for this strate type (shown in editor and debug) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Strate") + FText StrateName; + + // Short description for editor tooltips + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Strate", meta = (MultiLine = true)) + FText StrateDescription; + + //========================================================================= + // DIMENSIONS + //========================================================================= + + // How many chunks tall this strate is. + // Each strate can have a different height! + // Human-scale defaults use 16 chunks = 64 m. This gives a 20 m+ vertical budget for + // cathedral rooms, tall shafts, and the surface sky cap without changing the lateral gate. + // 1 chunk = 32 voxels x 12.5cm = 4 m; 16 chunks preserves the 64 m authored default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "256")) + int32 StrateHeightInChunks = 16; + + //========================================================================= + // BOUNDARY TRANSITION + //========================================================================= + // Controls how this strate blends with the strate BELOW it. + // Each boundary between two strates uses the UPPER strate's transition type. + // + // Gradient: Smooth param lerp — no visible boundary (default). + // Hard: Abrupt switch — creates a cliff/ledge at the boundary. + // Interleaved: 3D noise warps the boundary — fingers of one strate + // reach into the other for an organic, interlocking look. + // + // NOTE: The BOTTOM-MOST strate's transition type is unused (nothing below it). + + // Which transition style to use at this strate's lower boundary + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Boundary", + meta = (ToolTip = "How this strate transitions to the strate below it. Gradient = smooth blend, Hard = sharp cliff, Interleaved = noisy interlocking fingers.")) + EVoxelStrateTransition TransitionType = EVoxelStrateTransition::Gradient; + + // How many chunks the transition zone spans (used by Gradient and Interleaved). + // For Hard transitions this is ignored (effectively 0). + // Higher = wider, more gradual transition zone. + // 1 → very narrow transition (almost hard) + // 2 → moderate (good default) + // 4 → wide, gentle blend across many chunks + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Boundary", + meta = (ClampMin = "1", ClampMax = "8", EditCondition = "TransitionType != EVoxelStrateTransition::Hard")) + int32 TransitionBlendChunks = 2; + + //========================================================================= + // GENERATOR TYPE + //========================================================================= + // Controls which density strategy this strate uses. + // + // TunnelNetwork → classic rooms + tunnels + worm noise (FStrateGenerationParams below) + // FlatPlain → horizontal void between floor and ceiling (FSlabGenerationParams below) + // CrystalChamber → same as FlatPlain but ceiling has heavy downward formations + // + // Changing this shows/hides the relevant parameter sections below. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation") + ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork; + + /** + * Authoritative per-strate worm switch. Existing assets default to enabled. The manager + * resolves a disabled strate to WormStrength == 0 before any fused, stack, composer, or + * interval path sees its parameters, so this is a geometry switch rather than a local CVar. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (DisplayName = "Enable Worms")) + bool bEnableWorms = true; + + //========================================================================= + // TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type) + //========================================================================= + // These params control how the density field is shaped for chunks in this strate. + // The generator reads these values to produce unique cave geometry per layer. + // Only relevant when GeneratorType == TunnelNetwork. + + // Used by TunnelNetwork (rooms+tunnels) AND Underwater (same rock, flooded). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::TunnelNetwork || GeneratorType == ECaveGeneratorType::Underwater")) + FStrateGenerationParams GenerationParams; + + //========================================================================= + // SLAB PARAMS (shown only for FlatPlain and CrystalChamber generator types) + //========================================================================= + // Controls the floor height, ceiling height, roughness, and column density + // for open-void strates. Only relevant when GeneratorType is FlatPlain or + // CrystalChamber. Other generator types ignore these entirely. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::FlatPlain || GeneratorType == ECaveGeneratorType::CrystalChamber")) + FSlabGenerationParams SlabParams; + + //========================================================================= + // ARCHETYPE PARAMS (each shown only for its generator type) + //========================================================================= + // Tight branching corridors on a 3D lattice. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::Maze")) + FMazeGenerationParams MazeParams; + + // Open-sky terrain: hills/mountains/plains/beaches under a high ceiling, with water. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::SurfaceWorld")) + FSurfaceGenerationParams SurfaceParams; + + // Mostly-vertical shafts with ledges and sparse horizontal links. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::VerticalShafts")) + FVerticalShaftParams VerticalShaftParams; + + // Suspended land masses floating in a large open void. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::FloatingIslands")) + FFloatingIslandParams FloatingIslandParams; + + //========================================================================= + // BIOMES (vary terrain & content WITHIN this strate — any archetype) + //========================================================================= + // Optional. When empty, this strate generates exactly as before (no biome field, + // bit-identical output). When populated, a deterministic world-XY biome field + // (warped Voronoi + climate, see FBiomeMapParams) assigns regions; each biome can + // supply its OWN archetype params (a mini-strate-variant, output-blended for surface) + // plus its own decorations / atmosphere / water. Surface terrain wired today; + // content/atmosphere work for any archetype. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Biomes") + TArray Biomes; + + // World-XY biome field tuning (cell size, border warp/blend, climate fields). + // Only relevant when Biomes is non-empty. Bake AVoxelWorld::BakeBiomePreview to + // see the resulting map before regenerating the world. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Biomes") + FBiomeMapParams BiomeMapParams; + + //========================================================================= + // DISTURBANCES (the "wow" layer — applies on top of ANY archetype) + //========================================================================= + // Chasms, natural bridges and rock ridges layered onto whatever the archetype + // produces, for surprise and variety. All features default to disabled. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Disturbances") + FStrateDisturbanceParams Disturbances; + + //========================================================================= + // INTER-STRATE PASSAGES (how THIS strate connects DOWN to the next) + //========================================================================= + // Auto-carved passages from this strate to the one below it: count, style + // (walkable sloped tunnel / worm / spiral / cascade), tapered width, length, placement. + // Every strate has its own finite (0,0) landing room; the passage opens the next room through + // progression. The non-default styles are retained as exotic, non-walkability-guaranteed + // descriptors. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Passages") + FStratePassageConfig PassageConfig; + + //========================================================================= + // WATER RENDERING (self-contained, aesthetic — no swim/flow simulation) + //========================================================================= + // The water table HEIGHT comes from the active generator params + // (FSurfaceGenerationParams::WaterLevelRelative or FStrateGenerationParams:: + // WaterLevelRelative). These fields control whether/how it is rendered. + + // Master switch for the water surface in this strate. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Water") + bool bHasWater = false; + + // Translucent material applied to the generated water surface planes. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Water", + meta = (EditCondition = "bHasWater")) + UMaterialInterface* WaterMaterial = nullptr; + + // Water tint/colour, available to the material as a parameter if it reads it. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Water", + meta = (EditCondition = "bHasWater")) + FLinearColor WaterColor = FLinearColor(0.02f, 0.08f, 0.12f, 0.8f); + + //========================================================================= + // TERRAIN OPERATIONS + //========================================================================= + // Weighted list of terrain operation data assets to apply in this strate. + // + // HOW TO USE: + // 1. Create terrain op assets (Content → Data Asset → VoxelTerrainOpDefinition) + // 2. Add entries here — pick an asset and set its Weight + // 3. Weight scales the op's intensity: 1.0 = as configured, 0.5 = half, 2.0 = double + // + // EXAMPLE: + // "Crystal Caverns" strate might reference: + // - DA_Op_TallColumns (Weight 1.0) → floor-to-ceiling pillars + // - DA_Op_WideDomes (Weight 0.8) → cathedral ceilings + // - DA_Op_LightScallop (Weight 0.5) → subtle erosion texture + // + // The same op asset can be shared across multiple strate definitions. + // At generation time, these are merged into FStrateGenerationParams + // before being passed to the density pipeline. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Terrain Operations") + TArray TerrainOperations; + + //========================================================================= + // VISUALS + //========================================================================= + + // Material override for this strate (null = use the default VoxelMaterial) + // Allows each strate to have its own rock/ground look + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + UMaterialInterface* OverrideMaterial = nullptr; + + // Material for the SKY-CAP CEILING tiles of a surface-like strate (the rocky "night sky" overhead). + // Null = ceiling uses the same material as the ground (OverrideMaterial / default VoxelMaterial). + // The ceiling casts no shadow, so under it the ground material reads as flat/bright — give the + // ceiling its own darker/tinted material (or a tinted instance of the terrain material) to sell the + // "lit from within, dark rock overhead" look. Applies only to tiles classified as ceiling. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + UMaterialInterface* CeilingMaterial = nullptr; + + // Fog color for this strate (used by a future fog system or post-process) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f); + + // Fog density (0 = no fog, 1 = very thick) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float FogDensity = 0.3f; + + // Ambient light tint (color of the "base" underground light) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + FLinearColor AmbientLightColor = FLinearColor(0.1f, 0.1f, 0.15f, 1.0f); + + // Ambient light brightness + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals", meta = (ClampMin = "0.0")) + float AmbientLightIntensity = 0.5f; + + // Render the managed height fog volumetrically (softer, light-shaft-friendly, costlier). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + bool bVolumetricFog = false; + + //========================================================================= + // ATMOSPHERE LAYERS (persistent ceiling / floor actors — e.g. seas of clouds) + //========================================================================= + // These spawn ONCE when the player enters this strate (NOT per chunk, so they + // don't churn with streaming) and follow the player in XY while hugging the + // strate's ceiling / floor. Perfect for the "sky island" look: open space + // between two cloud seas. Author the look as a Blueprint (a large plane with a + // cloud/fog material, or a Niagara system). Leave null to disable. + // This is also how you get "fog emitting from ceiling and floor". + + // FULL atmosphere override: a Blueprint you author with your own ExponentialHeightFog, + // SkyLight, PostProcessVolume, SkyAtmosphere, etc. — tuned exactly how you want. + // When set, this REPLACES the simple Fog/Ambient knobs above for this strate (the + // plugin's managed fog + skylight are disabled so there's no double-up). The plugin + // spawns one instance when you enter the strate and keeps it anchored to the player. + // Leave null to use the simple knobs instead. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere") + TSubclassOf AtmosphereActor; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere") + TSubclassOf CeilingLayerActor; + + // Vertical offset (Unreal units) from the strate ceiling. Negative = below the ceiling. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "CeilingLayerActor != nullptr")) + float CeilingLayerZOffset = -200.0f; + + // Rotation applied to the ceiling layer actor each update. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "CeilingLayerActor != nullptr")) + FRotator CeilingLayerRotation = FRotator::ZeroRotator; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere") + TSubclassOf FloorLayerActor; + + // Vertical offset (Unreal units) from the strate floor. Positive = above the floor. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "FloorLayerActor != nullptr")) + float FloorLayerZOffset = 200.0f; + + // Rotation applied to the floor layer actor each update. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "FloorLayerActor != nullptr")) + FRotator FloorLayerRotation = FRotator::ZeroRotator; + + //========================================================================= + // CONTENT LISTS + //========================================================================= + // These arrays define what gets spawned in this strate. + // Future systems (decoration placer, creature spawner) consume these lists. + + // Decorations: actors placed ON cave surfaces (stalactites, mushrooms, crystals) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray Decorations; + + // Landmarks: RARE, large, far-visible objects placed on a coarse hash lattice (the underground + // "mini-suns" etc.). Strate-wide; each entry has its own spacing/biome/placement/transform settings. + // Cheap at any radius — see FStrateLandmark / §8.5. (NOT part of the per-chunk decoration grid.) + // Landmarks now cover set-pieces too (AnchorMode HashLattice/PassageMouth + exclusion + orb). See + // FStrateLandmark / §8.5 (F7). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray Landmarks; + + // Ambient actors: things floating in cave space (fog volumes, particles, lights) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray AmbientActors; + + // Creatures: enemies/NPCs that spawn in this strate + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray Creatures; + + //========================================================================= + // AUDIO + //========================================================================= + + // Ambient sound loop for this strate (dripping water, wind, etc.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Audio") + USoundBase* AmbientSound = nullptr; + + // Background music for this strate + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Audio") + USoundBase* Music = nullptr; + + // Music volume multiplier + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Audio", meta = (ClampMin = "0.0", ClampMax = "2.0")) + float MusicVolume = 1.0f; + + //========================================================================= + // GAMEPLAY TAGS + //========================================================================= + // Extensible tag system for gameplay rules. + // Game code checks these tags to apply effects (damage, movement restrictions, etc.) + // + // Examples: + // "Strate.Hazard.Flooded" → water everywhere, need diving gear + // "Strate.Hazard.Toxic" → air damages without mask + // "Strate.Lighting.Dark" → almost no ambient light + // "Strate.Rule.NeedEquipment.Rope" → climbing areas + // + // The plugin doesn't act on these — it just stores them. + // Your game code reads them and applies the appropriate effects. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Gameplay") + FGameplayTagContainer GameplayTags; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelStrateManager.h b/Standalone/Source/VoxelForge/Public/VoxelStrateManager.h new file mode 100644 index 0000000..40933a7 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStrateManager.h @@ -0,0 +1,556 @@ +// VoxelStrateManager.h +// Runtime system that maps world Z coordinates to strate definitions. +// +// HOW IT WORKS: +// ------------- +// 1. At world startup, Initialize() builds the strate layout: +// - Fixed strates go into their assigned slots +// - Random strates are shuffled from a pool using the world seed +// - Each strate's height (in chunks) is accumulated to compute Z ranges +// +// 2. During gameplay, GetStrateAt(WorldZ) returns which strate definition +// applies at a given depth. GetGenerationParams() returns blended params +// (with smooth transitions at strate boundaries). +// +// 3. The generator, world manager, and future systems (decorations, creatures, +// audio) all query this manager to know "what goes here." + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelStrateTypes.h" +#include "VoxelStrateDefinition.h" +#include "VoxelSeasonManifest.h" +#include "VoxelCaveMorphology.h" +#include "VoxelStrateManager.generated.h" + +class UVoxelSettings; + +#if WITH_EDITOR +struct FVoxelStrateRegionManifest; +struct FVoxelStrateComposerSlotOverride; +#endif + +/** + * FVoxelPassage — A navigable connection between two strates. + * + * Generated deterministically from the world seed during Initialize(). + * Each passage is a path from a point in strate A down through the + * boundary seal to a point in strate B. It's carved as a series of + * capsule SDFs in the density function. + * + * Passages are the PRIMARY progression path — players find these + * through exploration. The elevator is a FAST TRAVEL shortcut unlocked later. + */ +USTRUCT() +struct FVoxelPassage +{ + GENERATED_BODY() + + // Which strates this passage connects (indices into StrateLayout) + int32 UpperStrateIndex = 0; // The strate above + int32 LowerStrateIndex = 0; // The strate below + + // World-space positions of the passage endpoints + FVector UpperPoint = FVector::ZeroVector; // Entry in upper strate + FVector LowerPoint = FVector::ZeroVector; // Exit in lower strate + + // The deterministic upper-mouth request before source landing-site adjustment. Kept for + // diagnostics/tests so the bounded lateral snap can be measured against the original spine- + // relative placement; not used by the SDF evaluator. + FVector RequestedUpperPoint = FVector::ZeroVector; + + // The deterministic lower-mouth request before destination landing-site adjustment. Kept for + // diagnostics/tests so the bounded lateral snap can be measured against the original spine- + // relative placement; not used by the SDF evaluator. + FVector RequestedLowerPoint = FVector::ZeroVector; + + // Passage dimensions — how wide the carved tunnel is (in voxels). + // The default SlopedTunnel is a single-file 2.5 m clear bore at its narrowest point; + // authored exotic styles retain their own widths and are not covered by the walkable law. + float Radius = 5.0f; + + // The shape/style of this passage. Determines how control points are generated + // and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.). + EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel; + + // Multi-segment control points for passage shapes. The default SlopedTunnel contains the two + // gentle ramp legs of its deterministic switchback; exotic styles retain their authored chain. + // When non-empty, the SDF evaluator walks this array as a capsule chain. + TArray ControlPoints; + + // Per-control-point tube radius (parallel to ControlPoints) for tapered tunnels — + // wider chambers at the mouths, a squeeze in the middle, etc. If shorter than + // ControlPoints, the uniform Radius is used as a fallback. + TArray ControlRadii; + + // Body-sized, flat-floored chambers at both tube ends. UpperPoint/LowerPoint remain the + // standing anchors returned by the source query; the tube enters each room at DoorPoint. + FVoxelPassageLanding UpperLanding; + FVoxelPassageLanding LowerLanding; + + // Construction-time walkability facts for diagnostics and the commandlet report. These are + // metadata, not a second runtime decision: the final-density sampler remains authoritative. + bool bWalkableTunnelContract = false; + // Passage floors are authored once, after the final control chain and both landing floors are + // known. The evaluator uses this immutable profile to turn each tapered capsule into a D + // section; it never invents a support plane from the current voxel. + bool bNativeFloorEnabled = false; + TArray NativeFloorProfileZ; + TArray NativeFloorReliefScales; + float NativeFloorReliefStrength = 0.0f; + float NativeFloorReliefFrequency = 0.015f; + uint32 NativeFloorSeed = 0; + float TunnelMaxGradientDegrees = 0.0f; + float TunnelVerticalDropVoxels = 0.0f; + float TunnelRequiredHorizontalRunVoxels = 0.0f; + float TunnelHorizontalPathLengthVoxels = 0.0f; + float TunnelMinimumClearWidthVoxels = 0.0f; + float TunnelClearHeightVoxels = 0.0f; + + // Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords. + // Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with + // a single squared-distance test instead of walking every segment per voxel. + // BoundRadius (linear) feeds the per-chunk shortlist reach; BoundRadiusSq the per-voxel test. + FVector BoundCenter = FVector::ZeroVector; + float BoundRadius = 0.0f; + float BoundRadiusSq = 0.0f; +}; + +/** + * FStrateSlot — Runtime info for one strate in the layout. + * + * Created during Initialize() and stored in the StrateLayout array. + * Each slot knows its definition, its Z-range in chunk coordinates, + * and its index in the sequence. + */ +USTRUCT() +struct FStrateSlot +{ + GENERATED_BODY() + + // The strate definition asset (content bag) + UPROPERTY() + UVoxelStrateDefinition* Definition = nullptr; + + // Strate index (0 = topmost, increases downward) + int32 StrateIndex = 0; + + // Z chunk range (inclusive). TopZ > BottomZ since Z decreases downward. + // Example: TopZ = 0, BottomZ = -3 means this strate spans chunks Z=0 to Z=-3 + int32 TopChunkZ = 0; + int32 BottomChunkZ = 0; + + // Height in chunks (from the definition) + int32 HeightInChunks = 4; +}; + +/** + * UVoxelStrateManager — Maps depth to strate definitions at runtime. + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelStrateManager : public UObject +{ + GENERATED_BODY() + +public: + UVoxelStrateManager(); + + //========================================================================= + // INITIALIZATION + //========================================================================= + + /** + * Build the strate layout from settings and seed. + * + * STEPS: + * 1. For each strate index (0 to TotalStrates-1): + * - If it's in FixedStrates → use that definition + * - Else → pick from the shuffled pool (seeded random) + * 2. Stack strates top-to-bottom, accumulating heights + * 3. Store the layout in StrateLayout array + * + * @param Settings - VoxelSettings with pool/fixed strate config + * @param WorldSeed - Seed for randomizing non-fixed strates + */ + /** Returns false without a usable layout; an assigned invalid/stale season never falls back. */ + bool Initialize(UVoxelSettings* Settings, int32 WorldSeed); + + /** + * Copy the immutable recipe payload for this chunk. In packaged builds this comes only from + * the cooked season; editor candidate overrides use the same read-only worker hand-off. + */ + bool GetRecipeForChunk( + const FIntVector& ChunkCoord, int32& OutRecipeSeed, + ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, + FVoxelOpStackRecipe& OutRecipe) const; + + bool IsUsingSeason() const { return !ActiveSeasonContentHash.IsEmpty(); } + const FString& GetSeasonContentHash() const { return ActiveSeasonContentHash; } + int32 GetWorldSeed() const { return CachedSeed; } + + /** + * Monotonic identity of this manager UObject instance. + * + * PassagesVersion intentionally starts over for every layout, so it cannot distinguish a + * cached stack built by an older PIE manager that happened to have the same layout version. + * Worker-local generator caches use this identity as their manager-lifetime boundary. + */ + uint64 GetCacheLifetimeId() const { return CacheLifetimeId; } + +#if WITH_EDITOR + /** + * Install an editor-only candidate over one already-built slot. The slot's definition, + * dimensions, content, and passages remain intact; only the density archetype/parameters + * (or the materialised recipe) are replaced. The caller must hold the world's generation pause. + */ + bool SetComposerOverrideForStrate( + int32 StrateIndex, int32 CandidateSeed, ECaveGeneratorType Archetype, + const FVoxelStrateArchetypeParams& Params, bool bUseRecipe, + const FVoxelOpStackRecipe* Recipe, FString& OutError, + const FVoxelStrateRegionManifest* Regions = nullptr); + + /** Read the immutable editor override for a chunk into worker-local storage. */ + bool GetComposerOverrideForChunk( + const FIntVector& ChunkCoord, int32& OutCandidateSeed, + ECaveGeneratorType& OutArchetype, FVoxelStrateArchetypeParams& OutParams, + bool& bOutUseRecipe, FVoxelOpStackRecipe& OutRecipe) const; + + /** Read the immutable editor region override into worker-local storage. */ + bool GetComposerRegionOverrideForChunk( + const FIntVector& ChunkCoord, FVoxelStrateRegionManifest& OutRegions) const; + +#endif + + //========================================================================= + // QUERIES + //========================================================================= + + /** + * Get the strate definition at a world Z coordinate. + * + * @param WorldZ - Z position in world space (Unreal units) + * @return The strate definition, or nullptr if above/below all strates + */ + UFUNCTION(BlueprintCallable, Category = "Strate") + UVoxelStrateDefinition* GetStrateAt(float WorldZ) const; + + /** + * Get the strate index at a world Z coordinate. + * + * @param WorldZ - Z position in world space + * @return Strate index (0 = topmost), or -1 if outside strate range + */ + UFUNCTION(BlueprintCallable, Category = "Strate") + int32 GetStrateIndex(float WorldZ) const; + + /** Layout/passage generation counter (bumped by every Initialize → GeneratePassages). + * Hot-path callers key thread_local memos on this so an editor rebuild (RebuildStrates / + * live edit) can never serve a stale strate index or passage shortlist. */ + uint32 GetLayoutVersion() const { return PassagesVersion; } + + /** Diagnostic-only switch for isolating the inter-strate support writer. */ + void SetPassageSupportFloorWritesEnabledForDiagnostics(bool bEnabled) + { + bPassageSupportFloorWritesEnabled = bEnabled; + } + + bool ArePassageSupportFloorWritesEnabledForDiagnostics() const + { + return bPassageSupportFloorWritesEnabled; + } + + /** + * Get the strate definition for a specific chunk coordinate. + * + * @param ChunkCoord - The chunk position + * @return The strate definition, or nullptr if outside strate range + */ + UVoxelStrateDefinition* GetStrateForChunk(const FIntVector& ChunkCoord) const; + + /** + * Strate-aware vertical streaming: fills the chunk-Z span of the strate containing + * ChunkZ. Returns false if ChunkZ is in the inter-strate bedrock gap (or outside the + * layout) — the caller then leaves the vertical view unclamped (the gap is a brief + * see-both-sides descent transition). TopChunkZ > BottomChunkZ (Z decreases downward). + */ + bool GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const; + + /** + * Get generation params for a chunk, with boundary blending. + * + * BLENDING CONCEPT: + * When a chunk is near a strate boundary (within the definition's TransitionBlendChunks of the edge), + * the params are lerped between the two adjacent strates. This prevents + * hard visual seams where one strate ends and another begins. + * + * @param ChunkCoord - The chunk position + * @return Blended generation params for this chunk + */ + FStrateGenerationParams GetGenerationParams(const FIntVector& ChunkCoord) const; + + /** + * Get the generator type for a specific chunk. + * + * Returns TunnelNetwork if the chunk is outside all strates or if the + * strate definition is null. The generator uses this to pick which + * operator stack to build for the chunk. + * + * @param ChunkCoord - The chunk position + * @return The ECaveGeneratorType for the strate containing this chunk + */ + ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const; + + /** + * True when this chunk lies in a strate slot whose archetype has an operator stack (all eight + * do). False outside the layout or for a slot without a definition. This predicate is the ONLY + * place the archetype list is written down. + */ + bool UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const; + + /** + * True if this chunk is in the solid-bedrock GAP between two strates (inside the + * overall stack's Z range but not in any strate slot). Chunks above the top strate + * or below the bottom strate are NOT gaps (they're open air). Driven by + * VoxelSettings::InterStrateGapChunks. + */ + bool IsGapChunk(const FIntVector& ChunkCoord) const; + + /** + * Get slab generation params for a chunk, with runtime Z bounds filled in. + * + * Used when GetGeneratorTypeForChunk returns FlatPlain or CrystalChamber. + * Does NOT blend between adjacent strates — slab strates use Hard transition + * at their boundaries (blending between fundamentally different generator types + * is not meaningful). + * + * @param ChunkCoord - The chunk position + * @return FSlabGenerationParams with StrateTopWorldZ / StrateBottomWorldZ set + */ + FSlabGenerationParams GetSlabParamsForChunk(const FIntVector& ChunkCoord) const; + + /** + * Per-archetype param getters. Each copies the designer params from the strate + * definition and fills in the runtime Z bounds (voxel coords). Like the slab + * getter, these do NOT blend across boundaries — fundamentally different + * archetypes meet at Hard boundaries. + */ + FMazeGenerationParams GetMazeParamsForChunk(const FIntVector& ChunkCoord) const; + FSurfaceGenerationParams GetSurfaceParamsForChunk(const FIntVector& ChunkCoord) const; + FVerticalShaftParams GetVerticalShaftParamsForChunk(const FIntVector& ChunkCoord) const; + FFloatingIslandParams GetFloatingIslandParamsForChunk(const FIntVector& ChunkCoord) const; + + /** + * Flatten the strate's Biomes[] + BiomeMapParams into a POD FBiomeContext for the + * biome field. Returns an empty (invalid) context when the strate has no biomes — + * callers treat that as "biomes disabled" and fall back to base params. The result + * is window-invariant (depends only on the strate at this Z, not the chunk window). + */ + FBiomeContext GetBiomeContextForChunk(const FIntVector& ChunkCoord) const; + + /** + * World-space Z (voxel coords) of this strate's water surface, or -FLT_MAX if the + * strate has no water. Derived from the active archetype's WaterLevelRelative and + * the strate's Z range. Used by the water render system. + */ + float GetWaterLevelWorldZForChunk(const FIntVector& ChunkCoord) const; + + /** + * Unreal-unit (cm) world Z range of the strate containing WorldZ (Unreal units). + * OutTopZ = ceiling, OutBottomZ = floor. Returns false if WorldZ is outside all strates. + * Used by the atmosphere system to glue ceiling/floor layers to the strate. + */ + bool GetStrateUnrealZRange(float WorldZ, float& OutTopZ, float& OutBottomZ) const; + + /** + * Disturbance params for a chunk (chasms/bridges/ridges), with runtime Z bounds + * filled in. Returns an all-disabled default when outside the strate range. + */ + FStrateDisturbanceParams GetDisturbanceParamsForChunk(const FIntVector& ChunkCoord) const; + + /** + * Get the full strate layout (for debug display or UI). + */ + const TArray& GetLayout() const { return StrateLayout; } + + /** + * Get the total number of strates in the layout. + */ + int32 GetNumStrates() const { return StrateLayout.Num(); } + + //========================================================================= + // PASSAGES & ELEVATOR + //========================================================================= + + /** + * Evaluate the SDF for all passages and the elevator shaft at a world position. + * Called by the density function to carve these modifiers into the terrain. + * + * Returns: negative = inside a passage/shaft, positive = solid rock. + * FLT_MAX = no modifier nearby. + */ + float EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const; + + /** Apply the passage tube/landing carve and the guaranteed landing floor in internal density. */ + void ApplyPassageModifier(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Apply only the passage SDF carve. The shared MC post owns landing/tunnel support writes. */ + void ApplyPassageCarvingOnly(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Reapply the landing-only air carve in internal density (used after visual disturbances). */ + void ApplyPassageLandingAir(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Reassert the default walkable tunnel's clear air in internal density after any post. */ + void ApplyPassageTunnelAir(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** Apply the landing/tunnel MC post in one cached passage scan after disturbances. */ + void ApplyPassageStructuralPostsMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + + /** + * Compose the build-authored passage D-floor after generic MC writers. This is the native + * passage shape's final ownership step, not the diagnostic legacy support slab controlled by + * bPassageSupportFloorWritesEnabled. + */ + void ApplyPassageNativeFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity) const; + + /** Re-assert a landing floor after the landing air pass, still before the final XY seal. */ + void ApplyPassageLandingFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity) const; + + /** Re-assert only the flat room floors after the tunnel-air post; never writes tunnel floors. */ + void ApplyPassageLandingRoomFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity) const; + + /** + * True si la sphère englobante d'un passage (élargie du rayon de blend de carve) touche la + * boîte VOXEL [MinVoxel, MaxVoxel]. Test conservatif O(Passages) — utilisé par ClassifyTile + * (rejet des tuiles trivialement pleines) une fois PAR TUILE, jamais par voxel. + */ + bool AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + + /** Conservative box guard for the floor fill; unlike a tube it can affect the all-air proof. */ + bool AnyPassageLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + + /** Exact-lattice counterparts used by ClassifyTile's MC-grid proof. */ + bool AnyPassageNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + /** Exact maximum of the passage carve factor on the mesher lattice; 0 means no tube/landing + * carve reaches a sampled point. Invalid geometry returns 1 conservatively. */ + float MaxPassageCarveFactorNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + /** Exact conservative candidate for the post-stack passage air writers on the MC lattice. + * This deliberately excludes the passage modifier already folded by FPassageCarveOp. */ + bool AnyPassageAirPostNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float BaseDensity, float SealThickness) const; + bool AnyPassageLandingFloorNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + /** Conservative landing-room plus floor reach for the exact MC lattice and its halo. */ + bool AnyPassageLandingNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float ReachScale = 1.0f) const; + /** Per-tile reach for the modifier itself: capsule chain, landing terms, and carve blend. */ + bool AnyPassageCarvingNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float ReachScale = 1.0f) const; + /** Conservative sphere guard for all passage-owned structural posts and native D-floors. */ + bool AnyPassageStructuralPostNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float ReachScale = 1.0f) const; + /** Exact presence test for a structural floor write on the MC lattice. */ + bool AnyLandingFloorAtLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + + /** Conservative box guards for the per-strate (0,0) landing rooms and their floor slabs. */ + bool AnyOriginLandingNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + bool AnyOriginLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + + bool AnyOriginLandingNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float ReachScale = 1.0f) const; + /** Exact conservative candidate for the post-disturbance origin-room air reassertion. */ + bool AnyOriginLandingAirNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float BaseDensity, float SealThickness) const; + bool AnyOriginLandingFloorNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + + /** Get all generated passages (for debug display). */ + const TArray& GetPassages() const { return Passages; } + +protected: + //========================================================================= + // INTERNAL DATA + //========================================================================= + + // The stacked strate layout (index 0 = topmost strate) + UPROPERTY() + TArray StrateLayout; + + // Parallel to StrateLayout only while a cooked season is active. Plain immutable data is + // copied into worker-local caches on the existing layout-versioned refetch path. + TArray SeasonStrates; + FString ActiveSeasonContentHash; + + // Passages connecting consecutive strates + TArray Passages; + + // Bumped every time Passages is rebuilt (GeneratePassages), and for an editor composer + // override. EvaluateModifierSDF and all generator thread_local memos use this to invalidate + // cached passage/strate data after either kind of live change. + uint32 PassagesVersion = 0; + + // Never reused for the lifetime of the process. This is deliberately not a UObject pointer: + // allocator address reuse must not make a stale worker-local stack look current. + uint64 CacheLifetimeId = 0; + + // World seed (stored for passage generation) + int32 CachedSeed = 0; + + // Whether to auto-open the (0,0) origin landing room from above in strate 0. + // Copied from VoxelSettings::bOpenSurfaceEntry during Initialize(). + bool bOpenSurfaceEntry = true; + + // Authored radius (voxels) of the origin landing room and its optional surface opening. + // Copied from VoxelSettings::OriginSpineRadius. + float OriginSpineRadius = 28.0f; + + // Solid-bedrock gap between consecutive strates, in chunks. Copied from + // VoxelSettings::InterStrateGapChunks during Initialize(). + int32 InterStrateGapChunks = 0; + + // Production keeps the legacy writer available only as a fail-closed fallback for a + // non-native/malformed passage; native D-floors bypass it. This switch never changes the + // passage carve or air contract. + bool bPassageSupportFloorWritesEnabled = true; + + // Per-passage tunnel shape now lives on each UVoxelStrateDefinition::PassageConfig + // (the upper strate of each boundary controls its own descent tunnels). + + //========================================================================= + // HELPERS + //========================================================================= + + // Find which slot a chunk Z coordinate falls into. + // Returns INDEX into StrateLayout, or -1 if not found. + int32 FindSlotIndexForChunkZ(int32 ChunkZ) const; + +#if WITH_EDITOR + const FVoxelStrateComposerSlotOverride* FindComposerOverride(int32 StrateIndex) const; + TMap> ComposerOverrides; +#endif + + // Build FStrateGenerationParams from a strate definition: + // copies base GenerationParams, then applies all referenced terrain op assets. + // This is the single point where terrain op data assets are merged into params. + static FStrateGenerationParams BuildParamsFromDefinition(const UVoxelStrateDefinition* Definition); + + // Generate passages between consecutive strates. Called from Initialize(). + void GeneratePassages(); +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Standalone/Source/VoxelForge/Public/VoxelStrateMeasure.h new file mode 100644 index 0000000..80bcd91 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -0,0 +1,540 @@ +// Read-only, headless measurements over a coarse sample of one strate. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelPassageGeometry.h" + +class UVoxelGenerator; +class UVoxelStrateManager; + +/** + * The pinned metric capsule dimensions in voxel-space. + * + * Capsule measurements are authored in centimetres and converted through VOXEL_SIZE. At + * 12.5 cm/voxel the radius is 2.72 voxels, half-height is 7.04 voxels, and total height is + * 14.08 voxels. + * The measure settings copy these values so a future character can change the query without + * changing generation. The diagnosis test separately reads movement limits from the project's + * character CDO. + */ +struct VOXELFORGE_API FVoxelPlayerCapsuleConstants +{ + static constexpr float VoxelSizeCentimeters = VOXEL_SIZE; + static constexpr float VoxelSizeMeters = VoxelPassageGeometry::VoxelSizeMeters; + static constexpr float RadiusCentimeters = 34.0f; + static constexpr float HalfHeightCentimeters = 88.0f; + // UCharacterMovementComponent's UE 5.7 default. Callers that have a character CDO should + // copy its authored value into PlayerMaxStepHeightMeters. + static constexpr float MaxStepHeightCentimeters = 45.0f; + static constexpr float MaxStepHeightMeters = MaxStepHeightCentimeters / 100.0f; + // UE 5.7's WalkableFloorZ=0.71 resolves to about 44.8 degrees; a character CDO may expose + // the precise angle. The diagnosis test copies that precise project value when available. + static constexpr float WalkableFloorAngleDegrees = 44.8f; + static constexpr float RadiusVoxels = RadiusCentimeters / VoxelSizeCentimeters; + static constexpr float HalfHeightVoxels = HalfHeightCentimeters / VoxelSizeCentimeters; + static constexpr float HeightVoxels = VoxelPassageGeometry::PlayerHeightVoxels; +}; + +/** + * Read-only density source used by the offline Tier 2 pass for a materialised custom stack. + * + * SampleDensity is called concurrently during grid sampling; implementations must be const, + * re-entrant, and safe for concurrent read-only calls, just like UVoxelGenerator::GetDensityAt. + */ +class VOXELFORGE_API IVoxelStrateDensitySampler +{ +public: + virtual ~IVoxelStrateDensitySampler() = default; + virtual float SampleDensity(float WorldX, float WorldY, float WorldZ) const = 0; +}; + +/** Explicit outcome of a coarse connectivity query plus its full-resolution route re-check. */ +enum class EVoxelConnectivityResult : uint8 +{ + Connected, + NotConnectedAtThisResolution, // Evidence only at this grid's resolution; corridors thinner than SampleStep are invisible. + StartCellSolid, + GoalCellSolid, + OutOfWindow, + CoarseLiedBudgetExhausted, // Unknown: retries are exhausted, or the refutation has no coarse edge to exclude. + // Appended to preserve the underlying values of the legacy result codes above. + StartCellNotPlayerFit, + GoalCellNotPlayerFit +}; + +/** Settings for the deterministic coarse grid used by the strate measurement pass. */ +struct VOXELFORGE_API FVoxelStrateMeasureSettings +{ + int32 SampleStep = 8; // Voxels between 1 m samples at 12.5 cm/voxel. + int32 RadiusInVoxels = 512; // Half-extent in X and Y around CenterXY (64 m). + FVector2D CenterXY = FVector2D::ZeroVector; // Actor-space voxel coordinates. + // When both points are set, replace the square centered on CenterXY with the XY AABB of the + // two points expanded by CoverMarginVoxels. This keeps a mouth-to-mouth measurement sized by + // the mouths' separation instead of by their distance from the world origin. + TOptional CoverPointA; + TOptional CoverPointB; + float CoverMarginVoxels = 96.0f; // 12 m. + // Origin-rooted topologies can route from one mouth to the other through the finite (0,0) + // landing room and its local room/join geometry. There is no hidden radial connector network. + // When true, include (0,0) in the fitted AABB before applying MaxCells. A caller must keep + // this enabled for that topology; an over-cap route window is then refused honestly instead + // of being cropped back to a mouth-only box. + bool bIncludeOriginInCoverWindow = false; + // Diagnostic-only strengthening of the preceding policy: ensure the sampled box contains an + // actual one-voxel column around (0,0), rather than ending exactly at the exclusive upper + // bound when both mouths lie on one side of the origin. + bool bForceOriginColumnInCoverWindow = false; + int32 MaxCells = 8000000; // Refuse a grid larger than this many cells. + int32 MaxRouteRetries = 16; // Alternate coarse routes to full-resolution-check after the first route. + int32 HeadroomCells = 2; // Legacy walkable-column metric only. + int32 InteriorMarginVoxels = -1; // <0 = derive from BoundarySealThickness (2x, clamped); explicit 0 includes the seal. + + // Base ACharacter dimensions are authored in centimetres and converted by the voxel scale. + // These are parameters, not + // generation values, so another playable capsule can be measured without a code change. + float PlayerCapsuleRadiusVoxels = FVoxelPlayerCapsuleConstants::RadiusVoxels; + float PlayerCapsuleHalfHeightVoxels = FVoxelPlayerCapsuleConstants::HalfHeightVoxels; + + // Character-derived fit parameters. MaxStepHeight is deliberately named and stored in + // metres, matching UCharacterMovementComponent's authored unit. The measurement converts it + // to voxels only for the bounded downward support search. + float PlayerMaxStepHeightMeters = FVoxelPlayerCapsuleConstants::MaxStepHeightMeters; + float PlayerWalkableFloorAngleDegrees = FVoxelPlayerCapsuleConstants::WalkableFloorAngleDegrees; + // A support patch is sampled at the integer columns covered by the capsule radius. 0.75 with + // the default 1.36-voxel radius requires 4 of 5 columns, including the centre column. + float PlayerSupportPatchMinCoverageFraction = 0.75f; + + // Traversal is still constrained by the exact player-fit mask, but a planned climbing + // mechanic may cross a higher supported neighbouring column. This is a measurement policy, + // not a runtime movement change. The surface remains required to be supported; the extra + // height only changes whether that supported edge is traversable. + bool bPlayerClimbingEnabled = true; + float PlayerMaxClimbHeightMeters = 3.0f; +}; + +/** + * Optional capture of the exact grid used by one measurement. + * + * Air is deliberately stored as a polarity bit: 1 means density > 0 (air), 0 means solid. + * Density retains the scalar sample that produced that bit, so an editor preview can draw a + * density=0 contour without inventing geometry by blurring the binary view. Both arrays are + * opt-in so ordinary metrics callers do not retain a multi-million-cell buffer. + * It is an editor/automation hand-off for tools such as the composer preview, not a runtime + * generation cache. + */ +struct VOXELFORGE_API FVoxelStrateSampleGrid +{ + bool bValid = false; + int32 NumX = 0; + int32 NumY = 0; + int32 NumZ = 0; + int32 SampleStep = 1; + int32 CellCount = 0; + + float MinX = 0.0f; + float MinY = 0.0f; + float MinZ = 0.0f; + float MaxX = 0.0f; + float MaxY = 0.0f; + float MaxZ = 0.0f; + + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + + // 1 = air (density > 0), 0 = solid (density <= 0), matching the measurement polarity. + TArray Air; + + // Exact scalar density at the same cell centre as Air. Present on measurement captures; + // empty on legacy/binary-only callers. This is never used by generation. + TArray Density; + + // 1 = the exact player-fit pose predicate accepted this cell, 0 = rejected. This is + // populated when a player-fit walk explicitly captures its shared grid. It is kept beside + // Air so render, walk, and diagnostics can consume one sampled field and one fit mask. + TArray PlayerFitMask; + int64 PlayerFitCellCount = 0; + + FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const + { + return static_cast( + static_cast(X) + + static_cast(NumX) * (static_cast(Y) + + static_cast(NumY) * static_cast(Z))); + } + + bool IsValid() const + { + return bValid && NumX > 0 && NumY > 0 && NumZ > 0 + && static_cast(CellCount) + == static_cast(NumX) * static_cast(NumY) + * static_cast(NumZ) + && Air.Num() == CellCount; + } + + bool HasScalarDensity() const + { + return IsValid() && Density.Num() == CellCount; + } + + bool HasPlayerFitMask() const + { + return IsValid() && PlayerFitMask.Num() == CellCount; + } + + bool ContainsPoint(const FVector& Point) const + { + return IsValid() + && Point.X >= MinX && Point.X < MaxX + && Point.Y >= MinY && Point.Y < MaxY + && Point.Z >= MinZ && Point.Z < MaxZ; + } +}; + +/** Metrics derived from one coarse grid and its single air flood fill. */ +struct VOXELFORGE_API FVoxelStrateMetrics +{ + bool bValid = false; + FString RefusalReason; + + int64 NumSampled = 0; + int64 NumAir = 0; + int64 NumSolid = 0; + + float AirFraction = 0.f; + int32 NumAirComponents = 0; + float LargestComponentShare = 0.f; + FVector LargestComponentPoint = FVector::ZeroVector; // Actor-space voxel coords; valid iff NumAir > 0. + int64 LargestComponentCells = 0; + int32 NumComponentsAtLeast1Pct = 0; + float WalkableFraction = 0.f; + // Distinct sampled XY columns that contain at least one walkable cell, divided by the + // sampled XY footprint. This is a floor-area proxy, not a volume ratio: a large chamber + // can have a small WalkableFraction while retaining a substantial walkable floor. + int64 WalkableFloorColumns = 0; + float WalkableFloorAreaFraction = 0.f; + // Four-neighbour connected components of the projected walkable columns. This deliberately + // remains a 2D surface indicator; it does not prove that floors at different Z values are + // traversable in 3D. The separate arrival/departure route check is the traversal gate. + int32 NumWalkableSurfaceComponents = 0; + int64 LargestWalkableSurfaceColumns = 0; + float LargestWalkableSurfaceShare = 0.f; + float MedianFeatureScale = 0.f; + int32 MedianVerticalClearance = 0; + + // Player-fit metrics are answered only by a one-voxel grid. A coarse grid refuses to infer + // support or clearance because SampleStep=4 cannot resolve a 1.36-voxel radius capsule. + bool bPlayerFitResolved = false; + FString PlayerFitRefusalReason; + int64 NumPlayerFitCells = 0; + float PlayerFitFraction = 0.0f; // Accepted free supported poses / all air cells. + int32 NumTraversableComponents = 0; + int64 LargestTraversableComponentCells = 0; + float TraversableComponentShare = 0.0f; // Largest player-fit component / player-fit cells. + // Minimum horizontal Chebyshev clearance, in voxels, among accepted supported poses. This is + // a diagnostic only; player occupancy itself uses a direct capsule stencil, never the old + // Manhattan distance transform. + float MinimumPlayerClearanceVoxels = 0.0f; + + // The exact vertical window used by the measurement: [SampledMinZ, SampledMaxZ). + int32 ResolvedMarginVoxels = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + + // The exact sampled XY box and dimensions. For a fitted window these are the AABB-derived + // dimensions; for the legacy window they describe CenterXY +/- RadiusInVoxels. + int32 SampledNumX = 0; + int32 SampledNumY = 0; + int32 SampledNumZ = 0; + float SampledMinX = 0.0f; + float SampledMaxX = 0.0f; + float SampledMinY = 0.0f; + float SampledMaxY = 0.0f; + + // Air-component sizes in deterministic flood-fill discovery order. Keeping these facts from + // the one required flood fill lets diagnostics classify small non-largest components without + // sampling the source a second time. + TArray AirComponentCells; +}; + +/** + * Component facts for one coarse mouth-to-mouth query. + * + * The component shares are fractions of the sampled air cells. The two component distances are + * exact spatial Euclidean distances in coarse-cell coordinates to the nearest cell of the other + * mouth's component. Solid cells do not participate in that proximity calculation because it + * measures geometric separation between air components, not an alternate route. A distance of + * zero means both mouths resolve to the same component. Negative distances mean that the + * requested grid or endpoint could not be analyzed. + */ +struct VOXELFORGE_API FVoxelConnectivityDiagnostics +{ + bool bValid = false; + EVoxelConnectivityResult Result = EVoxelConnectivityResult::OutOfWindow; + bool bPlayerFitRestricted = false; + bool bPlayerFitResolved = false; + FString RefusalReason; + bool bStartSnapped = false; + bool bGoalSnapped = false; + int32 NumRouteRetries = 0; // Alternate routes full-resolution-checked after the first route. + + int64 NumAirCells = 0; + int64 StartComponentCells = 0; + int64 GoalComponentCells = 0; + int64 LargestComponentCells = 0; + float StartComponentShare = 0.0f; + float GoalComponentShare = 0.0f; + float LargestComponentShare = 0.0f; + bool bStartComponentIsLargest = false; + bool bGoalComponentIsLargest = false; + + float StartToGoalComponentDistanceCells = -1.0f; + float GoalToStartComponentDistanceCells = -1.0f; +}; + +/** + * Experiential traversal facts over the exact VF player-fit mask. + * + * This is intentionally a report, not a new movement implementation. The mask is built by the + * same direct capsule stencil used by VF_DiagnosePlayerFitConnectivityWithSampler; the explorer's + * deterministic graph walk only instruments that already-resolved six-neighbour pose graph. + * Distances are metres so the result can be read beside a human-scale screenshot. + */ +struct VOXELFORGE_API FVoxelPlayerFitWalkReport +{ + bool bValid = false; + FString RefusalReason; + EVoxelConnectivityResult Result = EVoxelConnectivityResult::OutOfWindow; + bool bCanReachDeparture = false; + bool bStartSnapped = false; + bool bGoalSnapped = false; + int32 NumRouteRetries = 0; + bool bClimbingEnabled = false; + float MaxClimbHeightMeters = 0.0f; + // The primordial capability contract is walk-only. The existing reachable fields below + // therefore remain walk-only; climb is reported as a separate tier and never makes the hard + // connectivity verdict pass. + bool bWalkOnlyCanReachDeparture = false; + bool bClimbCanReachDeparture = false; + + int64 PlayerFitVolumeCells = 0; + int64 ReachablePlayerFitCells = 0; + int64 WalkOnlyReachablePlayerFitCells = 0; + int64 ClimbReachablePlayerFitCells = 0; + int64 ClimbEdges = 0; + // Fit poses outside the walk+climb reachable set need an edit/mining action to become + // reachable. This is a reporting remainder, not a promise that arbitrary mining can reach + // every such pose without a separate excavation model. + int64 MineRequiredPlayerFitCells = 0; + float ReachablePlayerFitFraction = 0.0f; + float ClimbReachablePlayerFitFraction = 0.0f; + int32 SampledNumX = 0; + int32 SampledNumY = 0; + int32 SampledNumZ = 0; + int32 SampledMinZ = 0; + int32 SampledMaxZ = 0; + float SampledMinX = 0.0f; + float SampledMaxX = 0.0f; + float SampledMinY = 0.0f; + float SampledMaxY = 0.0f; + + // The agent follows a fixed-order depth-first walk over the already-resolved fit graph. A + // branch edge is counted twice when the agent backtracks, which makes this an instrument of + // traversal experience rather than a shortest-path length. + int64 AgentGraphTraversals = 0; + int64 ClimbTraversals = 0; + int64 DeadEndsEncountered = 0; + float DeadEndsPer100m = 0.0f; + float DistanceTravelledMeters = 0.0f; + float StraightLineMeters = 0.0f; + float Tortuosity = 0.0f; + + // A narrow-gap event is a traversed graph edge touching a route cell whose widest + // axis-aligned horizontal air span is below the threshold. This deliberately reports the + // operational proxy instead of pretending the voxel mask contains an oriented corridor width. + int64 NarrowGapTraversals = 0; + float NarrowGapFraction = 0.0f; + float NarrowGapThresholdMeters = 0.0f; + float NarrowGapEventsPer100m = 0.0f; + + float CapsuleRadiusMeters = 0.0f; + float CapsuleWidthMeters = 0.0f; + float CapsuleHeightMeters = 0.0f; + FString NarrowGapDefinition; + + // Component facts are retained from the same fit-mask flood fill that drives the route + // verdict. They are exposed here so an editor diagnostic does not need to sample the field + // a second time just to explain a failed mouth-to-mouth walk. + int32 PlayerFitComponents = 0; + int64 LargestPlayerFitComponentCells = 0; + int64 ArrivalComponentCells = 0; + int64 DepartureComponentCells = 0; + float MouthComponentGapVoxels = -1.0f; + + // Optional room probes evaluated against this same grid and start component. The arrays are + // empty for ordinary callers; when supplied, each entry is 1 only when the probe is both a + // player-fit cell and in the arrival/start component. + TArray ComponentProbePlayerFit; + TArray ComponentProbeInStartComponent; + + // The DFS returns to its root when the target component is unreachable. Keep both the + // physical end of that walk and the last newly reached pose: the latter is the useful + // failure-frontier point for a render, while the former is the literal final pose. + bool bHasAgentFinalPosition = false; + FVector AgentFinalVoxels = FVector::ZeroVector; + bool bHasLastReachedPosition = false; + FVector LastReachedVoxels = FVector::ZeroVector; + bool bHasTargetComponentNearestCell = false; + FVector TargetComponentNearestVoxels = FVector::ZeroVector; + float AgentToTargetComponentGapVoxels = -1.0f; + + // Origin checks are facts about the sampled fit mask. An origin-inclusive secondary probe + // can populate these even when the primary mouth-sized window deliberately excludes (0,0). + bool bOriginColumnInSampledWindow = false; + bool bOriginColumnHasPlayerFit = false; + int64 OriginColumnPlayerFitCells = 0; + bool bOriginColumnReachable = false; + float ReachableSetToOriginColumnVoxels = -1.0f; +}; + +/** + * Measure one strate in actor-space voxel coordinates. + * + * The pass samples the requested strate between a resolved margin inside its top and bottom + * boundaries, flood-fills its air once, and derives all returned metrics from that same grid. + * It never mutates either input object. + */ +VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrate( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid = nullptr); + +/** The same Tier 2 pass over an explicit voxel Z window and a custom read-only sampler. */ +VOXELFORGE_API FVoxelStrateMetrics VF_MeasureStrateWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateSampleGrid* OutSampleGrid = nullptr); + +/** + * Test coarse connectivity and re-check recovered coarse routes at full resolution. + * + * If an endpoint's own coarse cell is solid, the query searches the 26 neighbouring cells and + * uses the nearest air cell when one exists. The snap flags report those repairs. Solid-cell + * results are returned only when no adjacent air cell exists. When a route fails its + * full-resolution re-check, the failed coarse edge is excluded from a deterministic local + * blocklist and BFS is retried up to MaxRouteRetries. A missing route after exclusions returns + * NotConnectedAtThisResolution. If the retry budget is exhausted while routes are still being + * refuted, the result is CoarseLiedBudgetExhausted: unknown, not disconnected. The same unknown + * result is used when a refutation is in an endpoint-to-cell segment with no coarse edge to + * exclude. Every Connected result has a fully re-walked air route. + */ +VOXELFORGE_API EVoxelConnectivityResult VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped); + +/** The same query as the simple overload, with the number of alternate routes actually tried. */ +VOXELFORGE_API EVoxelConnectivityResult VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + int32& OutNumRouteRetries); + +/** + * The same connectivity query with component facts returned from the same sampled grid. This + * overload exists for diagnostics that need both the required route verdict and the component + * containing one endpoint without sampling the strate a second time. + */ +VOXELFORGE_API EVoxelConnectivityResult VF_AreConnected( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + FVoxelConnectivityDiagnostics& OutDiagnostics); + +/** + * Return the component sizes and spatial separation facts for the same query performed by + * VF_AreConnected. The returned Result uses the same retrying full-resolution route re-check, + * and NumRouteRetries reports the number of alternate routes actually checked. bValid means the + * sample grid was built and both endpoint coordinates were in its bounds; component fields + * remain zero/negative when an endpoint has no air cell to analyze. Largest-component membership + * treats equal-sized components as tied for largest. + */ +VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( + const UVoxelGenerator& Generator, + const UVoxelStrateManager& Manager, + int32 StrateIndex, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings); + +/** Connectivity variant for the explicit custom-stack measurement window. */ +VOXELFORGE_API EVoxelConnectivityResult VF_AreConnectedWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + bool& bOutStartSnapped, + bool& bOutGoalSnapped, + FVoxelConnectivityDiagnostics* OutDiagnostics = nullptr); + +/** Full diagnostics variant for a custom stack. */ +VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings); + +/** Full diagnostics variant for the player-fit custom-stack route. */ +VOXELFORGE_API FVoxelConnectivityDiagnostics VF_DiagnosePlayerFitConnectivityWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + FVoxelStrateMetrics* OutPlayerMetrics = nullptr); + +/** + * Instrument a deterministic agent over the same exact player-fit graph used by the route gate. + * The return value is false only when the bounded fine grid/stencil could not be built; a valid + * report may still say NotConnectedAtThisResolution or another explicit connectivity result. + */ +VOXELFORGE_API bool VF_MeasurePlayerFitWalkWithSampler( + const IVoxelStrateDensitySampler& Sampler, + int32 StrateBottomWorldZ, + int32 StrateTopWorldZ, + float BoundarySealThickness, + const FVector& AVoxel, + const FVector& BVoxel, + const FVoxelStrateMeasureSettings& Settings, + FVoxelPlayerFitWalkReport& OutReport, + const TArray* ComponentProbePoints = nullptr, + FVoxelStrateSampleGrid* OutSampleGrid = nullptr); diff --git a/Standalone/Source/VoxelForge/Public/VoxelStrateTypes.h b/Standalone/Source/VoxelForge/Public/VoxelStrateTypes.h new file mode 100644 index 0000000..5578cf4 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -0,0 +1,2442 @@ +// VoxelStrateTypes.h +// Shared structs and enums for the strate (layer) system. +// +// WHAT ARE STRATES? +// ----------------- +// The Depths world is divided into vertical layers called "strates". +// Each strate is radically different — unique cave shapes, decorations, +// creatures, materials, and even gameplay rules (flooded, toxic, etc.). +// +// This file defines the data structures that carry strate information +// between systems (generator, world manager, future decoration/creature spawners). + +#pragma once + +#include "CoreMinimal.h" +#include "GameplayTagContainer.h" +#include "Templates/SubclassOf.h" // IWYU : TSubclassOf (FPlacementProfile & co) +#include "VoxelStrateTypes.generated.h" + +class UVoxelBiomeDefinition; // FPlacementProfile::RequiredBiome (optional per-entry biome filter) +class AActor; // IWYU : paramètre de TSubclassOf seulement / TSubclassOf param only + +//============================================================================= +// ENUMS +//============================================================================= + +/** + * EVoxelPassageType — The shape/style of an inter-strate passage. + * + * Each passage connecting two adjacent strates is randomly assigned one of + * these types during GeneratePassages(). The type determines the geometry + * of the carved tunnel (control point layout, radius, overall feel). + * + * Types: + * SlopedTunnel — The default walkable, gentle-gradient tunnel between landing rooms. + * VerticalShaft — A vertical drop; never a continuous walkable descent. + * SpiralDescent — An exotic helix; walkability requires a separately designed ramp contract. + * CascadingDrops — Drops and ledges; never a continuous walkable descent as authored. + * CrackCrevice — A narrow fracture; no player-fit guarantee. + */ +UENUM(BlueprintType) +enum class EVoxelPassageType : uint8 +{ + // Walkable single-file tunnel. The runtime builds a deterministic switchback whose each ramp + // is below VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees. + SlopedTunnel UMETA(DisplayName = "Sloped Tunnel (walkable default)"), + + // Straight vertical drop between strates. Same XY for upper and lower points. + // Wider radius (~7-8 voxels) to feel like a natural shaft or sinkhole. + // Players drop or climb directly between strates. + VerticalShaft UMETA(DisplayName = "Vertical Shaft (straight drop)"), + + // Helical corkscrew path with 6-10 control points spiraling downward. + // Narrower radius (~4 voxels). The spiral gives a sense of descending + // through layers of rock, and the tight turns create interesting navigation. + SpiralDescent UMETA(DisplayName = "Spiral Descent (corkscrew)"), + + // Alternating horizontal ledges and short vertical drops. + // 3-5 drop segments, each ~8-12 voxels deep with ~10-voxel horizontal runs. + // Medium radius (~5 voxels). Feels like a natural cascading waterfall path. + CascadingDrops UMETA(DisplayName = "Cascading Drops (ledge series)"), + + // Narrow fracture passage — like squeezing through a crack in the rock. + // Similar shape to SlopedTunnel but much narrower radius (~2-3 voxels) + // and more pronounced horizontal offset at the midpoint. + CrackCrevice UMETA(DisplayName = "Crack/Crevice (narrow fracture)") +}; + +/** + * ESurfaceType — Where on a cave surface a decoration can be placed. + * + * Used by the decoration system to filter placement locations based + * on the surface normal direction at each point. + * + * Floor: Normal pointing up (dot with Z > 0.7) + * Wall: Normal roughly horizontal + * Ceiling: Normal pointing down (dot with Z < -0.7) + * Any: No restriction — place on any surface + */ +UENUM(BlueprintType) +enum class ESurfaceType : uint8 +{ + Floor UMETA(DisplayName = "Floor (normal up)"), + Wall UMETA(DisplayName = "Wall (normal horizontal)"), + Ceiling UMETA(DisplayName = "Ceiling (normal down)"), + Any UMETA(DisplayName = "Any surface") +}; + +/** + * EDecoStreamTier — Which of the two decoration streaming grids an entry uses (§8.5). + * + * Decorations stream on a fixed world XY grid by distance (no LOD pop). To keep that flicker-free, + * the STREAM RADIUS is a property of the GRID, never of an entry — mixing radii inside one grid would + * force a region to re-stream in place when the player crosses an entry's radius (the old tier system's + * flicker bug). So there are exactly two grids, and an entry just PICKS one: + * + * Far — full radius (VoxelSettings::DecorationRadiusChunks) + COARSE column spacing + * (DecorationFarSpacingVoxels). The coarse grid is what makes a RARE prop you want visible at + * every distance cheap: the worker ray-march cost scales with column count, and a sparse prop + * does not need the dense near grid. Default — and the far spacing defaults to the fine value, + * so existing assets are byte-identical until you opt in to a coarser far grid. Trees, landmarks. + * + * Near — short radius (DecorationNearRadiusChunks) + FINE column spacing (DecorationSpacingVoxels). + * For dense groundcover (grass, small clutter) that only needs to exist near the player: keeping + * it out of the far regions saves their HISM cluster-tree build + instance memory. Pair with the + * per-entry CullDistance (GPU draw bound) for the full picture. + */ +UENUM(BlueprintType) +enum class EDecoStreamTier : uint8 +{ + Far UMETA(DisplayName = "Far (full radius, coarse grid — trees/landmarks/rare props)"), + Near UMETA(DisplayName = "Near (short radius, fine grid — dense groundcover)") +}; + +//============================================================================= +// NOISE TYPE +//============================================================================= + +/** + * EVoxelNoiseType — Which noise function to use for cave surface roughness. + * + * Different noise types give radically different visual character to cave walls: + * - fBM: smooth, organic, blobby surfaces (default, works everywhere) + * - Ridged: sharp ridge-like features, great for craggy cliffs and tunnels + * - Mixed: blend of both — ridged structure with fBM softness + */ +UENUM(BlueprintType) +enum class EVoxelNoiseType : uint8 +{ + // Standard fractional Brownian motion (layered Perlin). + // Smooth, organic look. Blobby hills and valleys on cave walls. + FBM UMETA(DisplayName = "fBM (smooth, organic)"), + + // Ridged multifractal — folds the noise at zero-crossings, + // creating sharp ridge-like features. Looks like craggy rock, + // natural corridors, and erosion patterns. + Ridged UMETA(DisplayName = "Ridged (sharp, craggy)"), + + // 50/50 blend of fBM and Ridged — ridged structure softened + // by fBM. Good default for "interesting but not too alien." + Mixed UMETA(DisplayName = "Mixed (fBM + Ridged)"), + + // Worley/cellular noise — distance-to-nearest-feature-point. + // Creates rounded, cell-like patterns: bubble walls, grotto + // pockets, honeycomb textures. Great for alien or crystalline strates. + Cellular UMETA(DisplayName = "Cellular (grotto, bubbles)") +}; + +//============================================================================= +// STRATE TRANSITION TYPE +//============================================================================= + +/** + * ECaveGeneratorType — Which density strategy this strate uses. + * + * Each strate is its own "tiny world" and can use a fundamentally different + * density function. The generator branches on this value before building + * the density field for a chunk. + * + * TunnelNetwork: The default. Room-and-corridor SDF graph + worm tunnels. + * Classic cave networks with interconnected passages and chambers. + * Uses FStrateGenerationParams (all the room/tunnel/worm settings). + * + * FlatPlain: Underground open plains. A horizontal void between a flat(ish) + * floor and a gently roughened ceiling. Scattered columns. + * Looks like a massive underground prairie — flat ground, low + * ceiling, occasional stone pillars. Uses FSlabGenerationParams. + * + * CrystalChamber: Like FlatPlain but with a heavily roughened ceiling that creates + * crystal-like formations hanging downward. Combine with crystal + * decoration actors on the ceiling for the "fake sky" effect. + * Uses FSlabGenerationParams (same as FlatPlain, different defaults). + */ +UENUM(BlueprintType) +enum class ECaveGeneratorType : uint8 +{ + // Classic cave network: room SDF + tunnels + worm noise. Uses FStrateGenerationParams. + TunnelNetwork UMETA(DisplayName = "Tunnel Network (rooms + corridors)"), + + // Horizontal slab void: flat floor + gentle ceiling + scattered columns. Uses FSlabGenerationParams. + FlatPlain UMETA(DisplayName = "Flat Plain (underground prairie)"), + + // Horizontal slab void: flat floor + heavily roughened ceiling (crystal formations). Uses FSlabGenerationParams. + CrystalChamber UMETA(DisplayName = "Crystal Chamber (formations hanging from ceiling)"), + + // Tight branching corridors on a deterministic 3D lattice. Uses FMazeGenerationParams. + Maze UMETA(DisplayName = "Maze (tight branching tunnels)"), + + // Open-sky terrain: hills/mountains/plains/beaches under a high solid ceiling, + // lit by placed skylight actors, with an optional water table. Uses FSurfaceGenerationParams. + SurfaceWorld UMETA(DisplayName = "Surface World (skylight terrain: hills, rivers, beaches)"), + + // Mostly-vertical cave system: tall shafts with ledges + sparse horizontal links. Uses FVerticalShaftParams. + VerticalShafts UMETA(DisplayName = "Vertical Shafts (mostly-vertical cave system)"), + + // Suspended land masses in a large open void — "floating islands". Uses FFloatingIslandParams. + FloatingIslands UMETA(DisplayName = "Floating Islands (suspended land in open void)"), + + // Flooded cavern network: TunnelNetwork rock with a high water table. Uses FStrateGenerationParams + water. + Underwater UMETA(DisplayName = "Underwater (flooded cavern system)"), +}; + +/** + * EVoxelStrateTransition — How two adjacent strates blend at their shared boundary. + * + * Each strate boundary can use a different transition style. The transition type + * is defined on the UPPER strate's definition and controls the boundary between + * itself and the strate below it. + * + * Gradient: Smooth linear interpolation of generation params across TransitionBlendChunks. + * This is the classic approach — no seam visible, but also no dramatic + * geological feature at the boundary. Best for similar-looking strates. + * + * Hard: No blending at all. The strate's params apply right up to the boundary, + * then instantly switch to the neighbor's params. The abrupt change in + * density and cave shape naturally creates a cliff, ledge, or material + * discontinuity at the boundary. Best for strates that should feel like + * distinct geological layers with a visible dividing line. + * + * Interleaved: The boundary position is warped by a 3D noise field, so "fingers" + * of one strate reach into the other. Some XY columns transition early, + * others late, creating an irregular, interlocking boundary. The warp + * amplitude is ~2 chunks, and Z frequency is lower than XY to produce + * horizontal finger-like intrusions. Best for strates that should feel + * like they grew into each other over geological time. + */ +UENUM(BlueprintType) +enum class EVoxelStrateTransition : uint8 +{ + // Smooth linear interpolation of all generation params across the blend zone. + // No visible boundary — params change gradually over TransitionBlendChunks distance. + Gradient UMETA(DisplayName = "Gradient (smooth blend)"), + + // No blending — params switch instantly at the boundary. + // Creates natural cliffs, ledges, or material discontinuities. + Hard UMETA(DisplayName = "Hard (sharp boundary with cliff)"), + + // 3D noise warps the boundary position per XY column. + // Creates finger-like intrusions of one strate into the other. + Interleaved UMETA(DisplayName = "Interleaved (domain-warped fingers)") +}; + +//============================================================================= +// GENERATION PARAMS +//============================================================================= + +// X-macro field list for FStrateGenerationParams — the ONE place that enumerates +// every param participating in strate-boundary blending (Lerp expands it below). +// ADDING A FIELD TO THE STRUCT? ADD IT HERE TOO — with the old hand-written Lerp, +// a forgotten field silently reset to its default value inside blend zones. +// Liste X-macro des champs blendés aux frontières de strates (une seule source). +// LERPF(Name) — continuous value, FMath::Lerp between the two strates +// SNAPF(Name) — discrete value (bool / int / enum), snaps at Alpha = 0.5 +#define VF_STRATE_PARAM_FIELDS(LERPF, SNAPF) \ + /* Rock */ \ + LERPF(BaseDensity) \ + LERPF(VerticalScale) \ + /* Worm tunnels */ \ + LERPF(WormFrequency) \ + LERPF(WormHorizontalBias) \ + LERPF(WormThreshold) \ + LERPF(WormStrength) \ + LERPF(WormNetworkRange) \ + /* Cave morphology — rooms */ \ + LERPF(RoomSpacing) \ + LERPF(RoomDensity) \ + LERPF(MinRoomRadius) \ + LERPF(MaxRoomRadius) \ + LERPF(RoomHeightRatio) \ + LERPF(RoomShapeVariety) \ + LERPF(RoomFloorCutMin) \ + LERPF(RoomFloorCutMax) \ + LERPF(FloorReliefStrength) \ + LERPF(FloorReliefFrequency) \ + LERPF(RoomMouthRiseStrength) \ + LERPF(RoomMouthRiseBlendVoxels) \ + LERPF(OriginRoomRadius) \ + SNAPF(OriginRoomMaxConnections) \ + /* Cave morphology — tunnels */ \ + LERPF(TunnelMinRadius) \ + LERPF(TunnelMaxRadius) \ + LERPF(TunnelDensity) \ + LERPF(MaxTunnelLength) \ + LERPF(TunnelWarpStrength) \ + LERPF(TunnelHorizontalBias) \ + SNAPF(bTunnelsFlowTowardOrigin) \ + LERPF(TunnelEndpointZOffset) \ + SNAPF(bTunnelFloorTerracingEnabled) \ + LERPF(TunnelFloorTerraceStepHeight) \ + LERPF(TunnelFloorMaxLedgeHeight) \ + LERPF(TunnelFloorGentleSlopeThreshold) \ + SNAPF(TunnelFloorLedgeCountPreference) \ + SNAPF(TunnelFloorMaxLedges) \ + LERPF(SDFBlendRadius) \ + LERPF(WaterLevelRelative) \ + /* Cave warp */ \ + LERPF(CaveWarpStrength) \ + LERPF(CaveWarpFrequency) \ + /* Roughness */ \ + LERPF(SurfaceRoughness) \ + LERPF(RoughnessFrequency) \ + /* Boundary seal + runtime Z range */ \ + LERPF(BoundarySealThickness) \ + LERPF(StrateTopWorldZ) \ + LERPF(StrateBottomWorldZ) \ + /* Noise profile */ \ + SNAPF(RoughnessNoiseType) \ + LERPF(DomainWarpStrength) \ + LERPF(DomainWarpFrequency) \ + LERPF(FloorBias) \ + /* Terrain ops — terracing / layer lines / overhangs */ \ + LERPF(TerraceStepHeight) \ + LERPF(TerraceHardness) \ + LERPF(TerraceNoiseDisplacement) \ + LERPF(LayerLineSpacing) \ + LERPF(LayerLineDepth) \ + LERPF(OverhangStrength) \ + LERPF(OverhangDepth) \ + LERPF(OverhangFrequency) \ + /* Ribbing */ \ + LERPF(RibbingSpacing) \ + LERPF(RibbingDepth) \ + /* Cliff */ \ + LERPF(CliffStrength) \ + /* Scallop */ \ + LERPF(ScallopStrength) \ + LERPF(ScallopFrequency) \ + /* Arch */ \ + LERPF(ArchDensity) \ + LERPF(ArchMinRadius) \ + LERPF(ArchMaxRadius) \ + /* Columns */ \ + LERPF(ColumnDensity) \ + LERPF(ColumnMinRadius) \ + LERPF(ColumnMaxRadius) \ + /* Pits */ \ + LERPF(PitDensity) \ + LERPF(PitMinRadius) \ + LERPF(PitMaxRadius) \ + LERPF(PitDepth) \ + /* Chimneys */ \ + LERPF(ChimneyDensity) \ + LERPF(ChimneyMinRadius) \ + LERPF(ChimneyMaxRadius) \ + LERPF(ChimneyHeight) \ + /* Domes */ \ + LERPF(DomeDensity) \ + LERPF(DomeMinRadius) \ + LERPF(DomeMaxRadius) \ + LERPF(DomeHeightRatio) \ + /* Pinch */ \ + LERPF(PinchDensity) \ + LERPF(PinchStrength) \ + LERPF(PinchLength) + +// Per-field expansions used by FStrateGenerationParams::Lerp. They reference the +// locals A / B / Alpha / Result of that function (lexical expansion). Defined at +// file scope so no preprocessor directive sits inside the USTRUCT body (UHT-safe). +#define VF_PARAM_LERP(Name) Result.Name = FMath::Lerp(A.Name, B.Name, Alpha); +#define VF_PARAM_SNAP(Name) Result.Name = (Alpha < 0.5f) ? A.Name : B.Name; + +/** + * FStrateGenerationParams — Cave generation parameters for one strate. + * + * Extracted from a UVoxelStrateDefinition at runtime. + * At strate boundaries, two sets of params are blended (Lerp) so there's + * no hard visual seam between strates. + * + * The generator reads these instead of its own member variables when the + * strate system is active. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateGenerationParams +{ + GENERATED_BODY() + + // ----- Rock density ----- + + // How solid the rock is BEFORE any carving. + // The entire strate volume starts at this density (all solid rock). + // Worm tunnels and cavern noise then subtract from it to create air. + // + // Higher = more rock survives carving = fewer, thinner caves. + // The carving systems must overcome this value to create air (density < 0). + // 5-6 → moderate rock, lots of tunnels get through + // 8-10 → thick rock, only the strongest carving creates caves (good default) + // 12+ → very dense, barely any caves + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rock") + float BaseDensity = 8.0f; + + // Vertical stretch factor for ALL noise (worms + caverns). + // Scales the Z coordinate before noise sampling: + // >1 = tall galleries, vertical shafts + // <1 = flat chambers, horizontal tunnels + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rock") + float VerticalScale = 1.0f; + + // ===== WORM TUNNELS (primary cave structure) ===== + // + // Worm tunnels are the main way caves form. The technique: + // Sample TWO 3D noise fields at the same position. + // Take abs() of each → both form "sheets" near their zero-crossings. + // Where both sheets intersect → a thin, winding 1D curve → the tunnel. + // + // Worm = abs(Noise1) + abs(Noise2) + // Where Worm < WormThreshold → air (tunnel) + // + // This naturally creates connected, winding tunnel networks. + // The tunnels branch, merge, and snake through the rock organically. + + // How tightly the tunnels wind. Lower = long gentle curves, higher = tight turns. + // This controls the SCALE of tunnels — how far apart they are and how long + // each straight section is before curving. + // 0.008 → very long winding tunnels spanning many chunks + // 0.015 → natural cave tunnels (good default) + // 0.03 → tighter turns, shorter sections + // 0.06+ → chaotic maze (hard to navigate) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels") + float WormFrequency = 0.015f; + + // Bias tunnels toward horizontal orientation. + // Multiplies the Z component of worm noise frequency, making the noise + // change faster vertically. The result: tunnel paths prefer to stay + // at similar Z levels, giving natural floors and ceilings. + // 1.0 → no bias (tunnels go in all directions, including vertical shafts) + // 3.0 → mostly horizontal tunnels with gentle slopes (good default) + // 5.0 → very flat tunnels, almost no vertical movement + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels", meta = (ClampMin = "1.0", ClampMax = "10.0")) + float WormHorizontalBias = 3.0f; + + // Tunnel width. Lower = thinner tunnels, higher = wider passages. + // This is the threshold below which the worm noise creates air. + // KEEP THIS SMALL — it controls what fraction of volume becomes tunnel. + // 0.03 → very narrow crawlspaces + // 0.06 → walking-width tunnels (good default) + // 0.10 → wide corridors + // 0.15+ → WARNING: starts creating too much air + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels", meta = (ClampMin = "0.01", ClampMax = "0.5")) + float WormThreshold = 0.06f; + + // How aggressively worm tunnels carve through rock. + // At the tunnel center (WormValue ≈ 0), the full WormStrength is applied. + // Must exceed BaseDensity to create air. With BaseDensity=8, you need + // WormStrength > 8 for tunnels to fully punch through. + // 9-10 → tunnels just barely pierce through BaseDensity=8 + // 12 → clean tunnels with smooth walls + // 15+ → very wide tunnel profile + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels") + float WormStrength = 10.0f; + + // Worms only carve within this distance (voxels) of the room/tunnel network, fading + // smoothly to zero at the edge. Keeps worms as organic braids and shortcuts that HUG + // the cave system instead of spraying disconnected noise pockets through the whole + // strate (the far-field "confetti"). 0 = unlimited (legacy unmasked behaviour). + // 16 → tight braiding right along rooms/tunnels + // 64 → 16 m braids + side passages (human-scale default) + // 96+ → loose, wandering side-passages + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels", meta = (ClampMin = "0.0")) + float WormNetworkRange = 64.0f; + + // ===== CAVE MORPHOLOGY (room-and-corridor) ===== + // + // Cave shape is defined by SDF (Signed Distance Field) primitives: + // - ROOMS: ellipsoid voids placed on a hash grid + // - TUNNELS: capsule corridors connecting nearby rooms + // Combined with smooth union for organic, rounded junctions. + // + // This replaces the old 2D cave slab system. Instead of a flat + // heightfield cave, you get actual rooms connected by corridors — + // explorable spatial structure with clear "this is a room" / "this + // is a corridor" readability. + + // Distance between room grid cells (in voxels). + // This controls room SPACING, not size. Larger = rooms further apart. + // 64 → dense room network + // 128 → 32 m cell spacing (human-scale default) + // 160 → sparse rooms, long corridors + // 200+→ isolated chambers with long tunnel treks + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float RoomSpacing = 128.0f; + + // Probability of a room existing in each grid cell (0-1). + // Not every cell gets a room — this controls how many are filled. + // 0.2 → sparse — only 20% of cells have rooms (many empty areas) + // 0.35→ moderate (good default) + // 0.5 → dense — half of all cells have rooms + // 0.7+→ very crowded, almost solid cave network + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomDensity = 0.35f; + + // Smallest possible room radius (in voxels). + // Small rooms feel like alcoves or nooks. + // 8-12 → alcoves and small rooms + // 16 → 4 m diameter room at 12.5 cm/voxel (unchanged assets are half-scale) + // 24+ → spacious small rooms + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float MinRoomRadius = 16.0f; + + // Largest possible room radius (in voxels). + // Large rooms are cathedral chambers. + // 24-32→ moderate rooms + // 40 → 20 m diameter chamber (large-room default) + // 80+ → massive cathedral spaces + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float MaxRoomRadius = 40.0f; + + // How vertically squished rooms are. + // 1.0 = perfect sphere (tall as wide). + // Lower = flatter, more horizontal chambers. + // 0.2-0.3 → very flat caverns (strate-like, wide and low) + // 0.4-0.5 → natural cave chambers + // 0.85 → tall rooms (17 m diameter-height envelope at radius 40) + // 0.7-1.0 → tall, cathedral-like rooms + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", meta = (ClampMin = "0.1", ClampMax = "1.0")) + float RoomHeightRatio = 0.85f; + + // Per-room floor flatness range. Each room hash-rolls a value in [Min, Max]. + // FloorCutZ = RoomCenter.Z - RoomRadiusZ * roll + // + // The floor is a soft intersection (SmoothMax) so tunnels/pits pass through + // without a hard seam — the floor rounds off naturally near openings. + // + // 1.0 / 1.0 → all rooms are full ellipsoid bubbles (no flat floor) + // 0.7 / 1.0 → mix: some rooms flat, some bubbles + // 0.5 / 0.8 → all rooms have a flat floor, varying how low it sits + // 0.0 → floor at room center — only the top dome is carved + // + // Combine with RoomHeightRatio = 1.0 and Min~0.7 to get tall dome rooms + // with navigable flat floors. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomFloorCutMin = 0.70f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomFloorCutMax = 0.90f; + + // Large-scale floor undulation — how many voxels the floor plane rises and falls. + // This is NOT surface roughness (which adds rock texture). This shifts the entire + // floor height across the room, creating genuine terrain: hills, shallow basins, + // gradual ramps. A large room with FloorReliefStrength=5 has floor variation of ±5 + // voxels — navigable, but with noticeable topography. + // + // Only meaningful when RoomFloorCutMin < 1.0 (flat floor mode active). + // + // 0 → perfectly flat floor + // 4 → 1 m variation (human-scale default; subtle beside a room) + // 2-4 → subtle floor variation, barely perceptible gradient + // 5-10 → clear hills and basins — interesting to walk across + // 15+ → dramatic terrain — slopes and drops within the room + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.0", EditCondition = "RoomFloorCutMin < 1.0")) + float FloorReliefStrength = 4.0f; + + // How wide the floor hills are. Lower = broader, gentler undulations. + // 0.005 → very broad (one hill spans the whole room) + // 0.015 → moderate rolling terrain (good default) + // 0.04 → tighter, more frequent hills + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.001", EditCondition = "RoomFloorCutMin < 1.0")) + float FloorReliefFrequency = 0.015f; + + // Optional local room-floor bench that rises toward a higher tunnel mouth. It is authored + // from the build-time tunnel descriptor, not discovered by a post-generation validator. + // Strength 0 keeps the legacy room/tunnel hand-off; 1 reaches the compatible mouth floor. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms|Mouth Blend", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomMouthRiseStrength = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms|Mouth Blend", + meta = (ClampMin = "8.0", ClampMax = "128.0")) + float RoomMouthRiseBlendVoxels = 8.0f; + + // How much variety in room shapes (0 = all ellipsoids, 1 = full variety). + // At 0: every room is a smooth ellipsoid (uniform, organic caves). + // At 0.5: some rooms become angular (rounded boxes) or elongated (capsules). + // At 1.0: maximum variety — ellipsoids, angular chambers, and elongated halls. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomShapeVariety = 0.5f; + + // ===== ORIGIN ROOM (guaranteed hub at world center) ===== + // + // A guaranteed large room at (0, 0) in the center of each strate. + // This is where the cave network originates — the designer can later + // place the elevator, rest area, or other key features here. + // All nearby hash rooms will have tunnels forced to connect to it, + // making it the natural hub of the strate's cave network. + // 0 → disabled (no guaranteed origin room) + // 15 → moderate hub room + // 32 → large central chamber + // 48 → 24 m hub (three-chunk boss-space default) + // 64+ → massive starting cavern + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float OriginRoomRadius = 48.0f; + + // Maximum number of backbone tunnels that can force-connect to the origin room. + // Without a limit, every room in the search area that picks origin as its nearest + // neighbor gets a guaranteed tunnel — with large spacing this can create 8-10 + // tunnels all radiating from origin, overwhelming it visually. + // 0 → no limit (legacy behavior — every room can backbone to origin) + // 3-4 → natural hub with a few major exits (good default) + // 6+ → busy hub + // Rooms beyond the cap can still connect via TunnelDensity random chance, + // so connectivity isn't broken — only the "guaranteed" aspect is limited. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0", ClampMax = "12")) + int32 OriginRoomMaxConnections = 4; + + // ===== TUNNELS (connecting corridors between rooms) ===== + + // Smallest tunnel radius (in voxels). Creates tight squeeze corridors. + // 4-5 → marginal passage + // 6 → 3 m bore (minimum comfortable round passage) + // 8 → 4 m bore (human-scale wide passage) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float TunnelMinRadius = 6.0f; + + // Largest tunnel radius (in voxels). Creates wide passages. + // 6 → 3 m bore (comfortable walking tunnel) + // 8 → 4 m bore (fight-space passage) + // 10+ → practically small rooms + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float TunnelMaxRadius = 8.0f; + + // Probability that two nearby rooms are connected by a tunnel (0-1). + // 0.2 → sparse connections, mostly dead-end rooms + // 0.4 → moderate connectivity (good default) + // 0.8+→ dense network, nearly everything connected + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TunnelDensity = 0.4f; + + // Maximum tunnel length (in voxels). Rooms further apart won't connect. + // 100 → only close neighbors + // 200 → moderate reach + // 360 → 90 m, reaches the worst jittered neighbour at 32 m cell spacing + // 400+→ long-range connections possible + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float MaxTunnelLength = 360.0f; + + // How much tunnel paths curve (in voxels of sideways displacement). + // Without this, tunnels are straight lines between rooms. + // This adds deterministic hash-jittered control points along each tunnel, creating + // unique wandering chains — some bend left, some right, some are nearly straight. + // Capped at 25% of tunnel length to prevent kinky short tunnels. + // 0 → perfectly straight tunnels (artificial look) + // 8-12→ gentle natural curves + // 24 → 6 m lateral bend (human-scale default) + // 25+ → very curvy, meandering corridors + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float TunnelWarpStrength = 24.0f; + + // Preference for horizontal connections over vertical (0-1). + // Real caves are mostly horizontal — vertical connections are rarer. + // This penalizes Z-separation when deciding which rooms to connect. + // 0.0 → no preference (connects freely in all directions) + // 0.5 → moderate horizontal preference (good default) + // 1.0 → strongly horizontal — vertical connections very unlikely + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TunnelHorizontalBias = 0.5f; + + // Topology of the guaranteed tunnel network. + // true → each room links to its best candidate among rooms CLOSER to (0,0): the whole + // network becomes a tree rooted at the origin room — every cave is reachable + // from the graph root, tunnels flow inward like tributaries (intentional descent + // structure). This graph root is not a radial landing-to-hub road. TunnelDensity + // still adds loops on top. + // false → legacy nearest-neighbor pairing: organic scattered clusters, but connectivity + // between clusters is NOT guaranteed (isolated pockets are common). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + bool bTunnelsFlowTowardOrigin = true; + + // Vertical wander amount for tunnel control points (0-1). Endpoints are always tangent to + // their room's deterministic floor so a room join cannot be vertically severed; this value + // controls only the interior chain's bounded up/down variation. + // 0.0 → level floor-to-floor chain + // 0.25 → restrained vertical wander (default) + // 1.0 → strongest interior vertical variation + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TunnelEndpointZOffset = 0.25f; + + // ===== TUNNEL FLOOR AUTHORING ===== + // + // The corridor floor is authored into the swept tunnel during cache construction. These + // controls are intentionally separate from FCaveTerraceMod::TerraceStepHeight: that field + // terraces walls after morphology, while these fields describe the tunnel's own floor profile. + // FloorReliefStrength/Frequency above are shared with room floors and are reused here. + + // Deprecated compatibility field. The tunnel's carved geometry always owns its floor; + // serialized values continue to load but are deliberately ignored. + UPROPERTY(meta = (DeprecatedProperty, + DeprecationMessage = "Ignored; tunnel floor is always authored by tunnel geometry.")) + bool bTunnelFloorEnabled = true; + + // Enables deterministic selection of a few dramatic ledges on steep graph-redundant or leaf + // tunnels. It never enables staircase quantisation: walk-critical steep edges wind instead. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor") + bool bTunnelFloorTerracingEnabled = true; + + // Retained for serialized asset compatibility. It is no longer used to quantise a tunnel into + // per-segment steps; ledge height is controlled by TunnelFloorMaxLedgeHeight. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0.0")) + float TunnelFloorTerraceStepHeight = 1.71f; + + // Maximum height of one authored ledge in the whole-chain profile, in voxels. This may exceed + // the walking step because ledges are climbable surfaces under the measurement envelope. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0.0")) + float TunnelFloorMaxLedgeHeight = 12.0f; + + // Floor gradient threshold in degrees for a direct route. At or below it, the floor is a + // smooth ramp. A steeper walk-critical edge receives a longer winding route; an eligible edge + // may receive deterministic dramatic ledges instead. Internally this is converted with + // VoxelMath::DetSinCos; the old serialized tangent value is migrated on asset load. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0.0", ClampMax = "90.0", DisplayName = "Gentle Slope Threshold (degrees)")) + float TunnelFloorGentleSlopeThreshold = 44.0f; + + // Preferred number of large ledges for a steep eligible floor. Zero selects the deterministic + // height-driven count; positive values request at least that many transitions, subject to the + // max-height and available control-segment constraints. This field never requests stairs. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0", ClampMax = "32")) + int32 TunnelFloorLedgeCountPreference = 0; + + // Safety bound on the number of dramatic transitions emitted by a floor profile. The runtime + // also caps the actual chain to a small finite number of broad ledges. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "1", ClampMax = "4096")) + int32 TunnelFloorMaxLedges = 4096; + + // ===== SDF BLEND (junction smoothness) ===== + + // Smooth union blend radius. Controls how rounded the junctions + // are where rooms meet tunnels (or rooms meet rooms). + // 1-2 → sharp, angular junctions + // 4 → 1 m rounded junction (default) + // 8+ → very blobby, organic blending + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Blend") + float SDFBlendRadius = 4.0f; + + // ===== CAVE WARP (large-scale skeleton distortion) ===== + // + // Warps the world coordinates BEFORE the SDF is evaluated. + // This is THE key technique that turns graph-like caves into organic shapes: + // rooms become irregular blobs, tunnels become winding passages. + // + // Without this, the SDF skeleton is a clean graph of geometric primitives. + // With this, the entire field bends and flows — like real geological forces + // pushed the rock around over millions of years. + // + // Two octaves are applied internally: + // - Large-scale (CaveWarpFrequency): bends whole rooms/tunnels gently + // - Medium-scale (3x frequency, 0.3x strength): adds irregularity to walls + + // How far the cave shapes shift (in voxels). + // This controls the overall organic distortion of the cave skeleton. + // 0 → disabled — pristine geometric rooms and straight tunnels + // 4-6 → subtle natural variation (rooms slightly irregular) + // 16 → 4 m skeleton distortion (human-scale default) + // 16+ → heavily distorted, surreal cave shapes + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Cave Warp") + float CaveWarpStrength = 16.0f; + + // Frequency of the warp noise. Lower = broader, smoother bends. + // This controls the SCALE of the distortion. + // 0.008 → very broad sweeping curves (whole strate-scale) + // 0.015 → natural cave bending — one curve every ~2 chunks (good default) + // 0.03 → tighter curves, more chaotic layout + // 0.06+ → extreme — small-scale jittering (usually too noisy) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Cave Warp") + float CaveWarpFrequency = 0.015f; + + // ===== SURFACE ROUGHNESS (rocky, craggy walls) ===== + // + // 3D noise applied near cave surfaces to break up the smooth SDF shapes. + // Creates overhangs, ledges, rocky protrusions, bumps, and cracks. + // Without this, rooms are perfect ellipsoids. With this, they're rock. + + // How strong the roughness is (in voxels of displacement). + // 0 → perfectly smooth SDF shapes + // 2-3 → subtle rocky texture + // 2 → 0.5 m wall texture (human-scale default; bounded reach 0.94 m) + // 4-6 → stronger natural rocky cave surfaces + // 8+ → very rough, jagged rock + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness") + float SurfaceRoughness = 2.0f; + + // Frequency of the roughness noise. Higher = finer details. + // 0.05 → large rocky features (boulders, ledges) + // 0.1 → natural rock grain (good default) + // 0.15+ → fine cracks and bumps + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness") + float RoughnessFrequency = 0.1f; + + // Which noise function to use for surface roughness. + // Different types give caves very different visual character: + // fBM → smooth organic (lava tubes, rounded galleries) + // Ridged → sharp craggy (erosion cracks, natural corridors) + // Mixed → blend of both (good general default) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness") + EVoxelNoiseType RoughnessNoiseType = EVoxelNoiseType::FBM; + + // ===== DOMAIN WARPING ===== + // + // Distorts the noise input coordinates using a secondary noise field. + // This breaks up repetitive patterns and makes surfaces look more + // organic — like they were shaped by flowing water or geological forces. + // Applied to ALL roughness noise (regardless of noise type). + // + // 0.0 → disabled (default) — noise is sampled at true world positions + // 3-5 → subtle organic distortion (good starting point) + // 8-12 → noticeable warping, very organic cave walls + // 15+ → extreme distortion, alien/surreal look + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Domain Warp") + float DomainWarpStrength = 0.0f; + + // Frequency of the warp noise field. Higher = tighter, more chaotic warps. + // 0.01 → very broad, sweeping distortion + // 0.03 → natural geological warping (good default) + // 0.06+ → tight, detailed warping + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Domain Warp") + float DomainWarpFrequency = 0.03f; + + // ===== FLOOR BIAS ===== + // + // Adds density in the lower portion of rooms to counteract surface roughness + // creating bumpy, uneven floors. Acts like sediment settling on the cave floor — + // the noise still carves relief, but the floor stays generally walkable. + // + // Only applied below room center Z (fades to 0 at center, strongest at floor). + // Does NOT affect the open space above room center or walls/ceiling. + // + // 0 → disabled — full roughness on all surfaces + // 2-3 → subtle flattening, floor still has character + // 4 → 1 m of floor bias (human-scale default) + // 4-6 → noticeably flatter floor while keeping wall/ceiling rough + // 8+ → near-flat floor (good for navigable caves) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness", + meta = (ClampMin = "0.0")) + float FloorBias = 4.0f; + + // ===== TERRAIN OPERATIONS ===== + // + // These modify the density field AFTER morphology + roughness to create + // specific geological features. They run on every voxel near cave surfaces. + // Set the "strength" or "spacing" param to 0 to disable any operation. + // + // Pipeline order: Morphology → Roughness → TerrainOps → Worm Tunnels + // + // NOTE: These fields are INTERNAL TRANSPORT — populated by + // UVoxelTerrainOpDefinition::ApplyTo() and read by the generator. + // They have no UPROPERTY so they don't clutter the strate definition editor. + // Designers configure terrain ops via UVoxelTerrainOpDefinition data assets + // referenced in the strate definition's TerrainOperations array. + + // --- TERRACING --- + // + // Creates step-like horizontal ledges on cave walls. The cave surface + // is broken into a staircase: flat shelf floors connected by short cliffs. + // Great for platforming — players can jump between ledges. + // + // How it works: a smooth staircase function of world Z is used to + // offset the density field near cave surfaces. Where the staircase + // is above the real Z → extra solid (shelf floor). Where below → air gap. + // + // 0 → disabled (default) + // 6-8 → natural stone steps (comfortable jump height in Unreal units) + // 10-12 → tall ledges (need climbing) + // 16+ → dramatic cliff-like terraces + float TerraceStepHeight = 0.0f; + + // How sharp the terrace step edges are. + // 0.0 → rounded, 0.5 → moderate, 0.9 → sharp angular ledges + float TerraceHardness = 0.5f; + + // Noise displacement on terrace edge Z positions. + // 0.0 → perfectly horizontal, 0.5 → natural, 1.0 → very wavy + float TerraceNoiseDisplacement = 0.5f; + + // --- LAYER LINES --- + // + // Horizontal grooves carved into cave walls — visible geological strata. + // Creates thin recessed lines at regular Z intervals, making walls + // look like layered sedimentary rock. Purely geometric — the + // material/shader could also add visual layers, but this is structural. + // + // 0 → disabled (default) + // 4-6 → fine layering (detailed geological look) + // 8-12 → broad visible layers + // 16+ → dramatic thick bands + float LayerLineSpacing = 0.0f; + + // Depth of each groove (how much density is subtracted). + // 0.1 → barely visible, 0.3 → subtle, 0.6 → deep, 1.0+ → dramatic + float LayerLineDepth = 0.3f; + + // --- OVERHANGS --- + // + // Horizontal shelf-like protrusions from cave walls. + // Uses 3D noise with very low Z frequency, so features extend + // horizontally for long distances before varying vertically. + // Creates natural-looking rocky overhangs, shelves, and ledges + // that are independent of the terracing system. + // + // Strength: 0 = disabled, higher = more prominent overhang features. + // 0.0 → disabled (default) + // 0.3 → subtle horizontal features + // 0.5 → moderate overhangs (good default when enabled) + // 0.8 → very prominent horizontal protrusions + float OverhangStrength = 0.0f; + + // How far overhangs protrude from walls (in voxels of density added). + // 8 → 2 m optional shelf (human-scale default) + // 16+ → dramatic + float OverhangDepth = 8.0f; + + // Frequency of the overhang noise. Z freq is auto 5x lower for horizontal features. + // 0.03 → large rare, 0.06 → moderate, 0.1+ → many small + float OverhangFrequency = 0.06f; + + // --- RIBBING --- + // + // Parallel ridge patterns on cave walls and ceilings — like lava tubes. + // Creates evenly-spaced bumps/ribs that run horizontally along the + // cave surface. The ridges ADD density (solid), creating protruding + // bands of rock. This is the complement to layer lines (which subtract). + // + // Ribbing direction is along Z (horizontal ribs on walls), which gives + // the classic lava tube look. Combined with domain warping, the ribs + // become wavy and organic instead of perfectly straight. + // + // 0 → disabled (default) + // 3-5 → fine ribbing (subtle lava tube feel) + // 6-10→ prominent ribs (dramatic geological feature) + // 12+ → thick bands of rock (very stylized) + float RibbingSpacing = 0.0f; + + // How far ribs protrude from the wall (in voxels of density added). + // 0.2 → barely visible, 0.4 → subtle, 0.8 → prominent, 1.5+ → dramatic + float RibbingDepth = 0.4f; + + // --- CLIFF SHARPENING --- + // + // Amplifies vertical gradients in the density field to create sheer + // rock faces. Where the cave surface is already somewhat vertical + // (density changes quickly along Z), this operation steepens it further. + // + // How it works: near cave surfaces, we measure the vertical density + // gradient. Where it's steep (cave wall is already vertical), we ADD + // density below and SUBTRACT above — steepening the transition from + // solid to air. This turns gentle slopes into dramatic cliff faces. + // + // Strength: 0 = disabled, higher = steeper cliffs. + // 0.0 → disabled (default) + // 0.3 → subtle steepening (walls feel more vertical) + // 0.6 → strong cliff faces (good for dramatic strates) + // 1.0 → extreme — nearly all slopes become vertical walls + float CliffStrength = 0.0f; + + // --- SCALLOP --- + // + // Water-erosion-like concave patterns on cave walls. + // Creates bowl-shaped indentations arranged in a semi-regular pattern, + // like the scallops you see in real water-carved limestone caves. + // + // Uses 3D cellular (Worley) noise near cave surfaces to create + // concave pockets. The distance-to-nearest-feature-point creates + // natural bowl shapes when subtracted from density. + // + // 0.0 → disabled (default) + // 0.3 → subtle erosion texture + // 0.6 → visible scalloped walls (good for wet/limestone strates) + // 1.0 → deep scallops, dramatic water-carved look + float ScallopStrength = 0.0f; + + // Size of each scallop bowl. Lower = bigger, higher = smaller. + // 0.05 → large, 0.1 → medium, 0.2 → small frequent + float ScallopFrequency = 0.1f; + + // --- ARCH / BRIDGE --- + // + // Natural rock bridges spanning gaps in cave chambers. + // Hash-placed horizontal cylinders of ADDED density that create + // walkable bridges across open cave spaces. Arches only appear + // inside caves (where SDF < 0), spanning from one wall to another. + // + // Each arch is a horizontal capsule (solid rock tube) that crosses + // through the cave void. The hash determines position, direction, + // and size. Arches are relatively rare, dramatic features. + // + // 0.0 → disabled (default) + // 0.02 → very rare arches (impressive when found) + // 0.06 → moderate (good for cathedral strates) + // 0.1 → frequent bridges + float ArchDensity = 0.0f; + // Radius of an optional bridge tube: 1.5-3 m at the human-scale defaults. + float ArchMinRadius = 6.0f; + float ArchMaxRadius = 12.0f; + + // ===== COLUMNS / PILLARS ===== + // + // Vertical solid cylinders connecting floor to ceiling in cave spaces. + // These are natural rock pillars — where a stalactite and stalagmite + // would have met, or where cave erosion left a supporting column. + // + // Columns are placed using hash-based placement (like rooms) and only + // appear where the cave morphology has already carved an opening. + // They ADD density (solid rock) inside what would otherwise be air. + + // Probability that a hash cell contains a column (0-1). + // 0 = disabled. Higher = more pillars scattered through caves. + // 0.0 → disabled (default) + // 0.05 → rare pillars, impressive when found + // 0.15 → moderate (good for cathedral strates) + // 0.3 → dense forest of pillars + float ColumnDensity = 0.0f; + float ColumnMinRadius = 4.0f; + float ColumnMaxRadius = 8.0f; + + // ===== PITS / VERTICAL SHAFTS ===== + // + // Cylindrical downward voids carved into the cave floor. + // These are natural vertical drops — sinkholes, collapsed ceilings, + // narrow shafts. They create dramatic vertical gameplay: + // the player can fall in, climb down, or discover connections + // to lower areas within the same strate. + // + // Pits are hash-placed and only carve where the cave morphology + // already created solid rock with air above (floor areas). + + // Probability that a hash cell contains a pit (0-1). + // 0 = disabled. Pits are rare, impactful features. + // 0.0 → disabled (default) + // 0.03 → rare sinkholes (dangerous, surprising) + // 0.08 → moderate (good for deep strates with vertical gameplay) + // 0.15 → frequent pits (chaotic, maze-like) + float PitDensity = 0.0f; + float PitMinRadius = 8.0f; + float PitMaxRadius = 16.0f; + float PitDepth = 32.0f; + + // ----- Chimney / Shaft (upward voids — inverse of pits) ----- + + // Chimneys: narrow vertical tubes piercing upward from cave ceilings. + float ChimneyDensity = 0.0f; + float ChimneyMinRadius = 4.0f; + float ChimneyMaxRadius = 8.0f; + float ChimneyHeight = 32.0f; + + // ----- Dome (hemispherical chamber ceilings) ----- + + // Domes: hemispherical chamber ceilings for cathedral-like feel. The default 16-32 voxel + // radius is a 2-4 m optional ceiling feature at 12.5 cm/voxel; the surrounding room is primary. + float DomeDensity = 0.0f; + float DomeMinRadius = 16.0f; + float DomeMaxRadius = 32.0f; + float DomeHeightRatio = 0.8f; + + // ----- Pinch / Bottleneck (passage narrowing) ----- + + // Pinch: passage narrowing for bottlenecks/chokepoints. + float PinchDensity = 0.0f; + float PinchStrength = 5.0f; + float PinchLength = 24.0f; + + // ----- Strate boundary sealing ----- + + // Thickness of the solid shell at the top and bottom of each strate (in voxels). + // Prevents caves from carving through strate boundaries, guaranteeing + // a solid ceiling at the top and solid floor at the bottom. + // The player must find actual passages to move between strates. + // 2-3 → thin seal (some small holes possible near edges) + // 4-5 → solid boundary (good default) + // 8+ → very thick, no chance of breakthrough + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Boundary") + float BoundarySealThickness = 4.0f; + + // ----- Water table (used by the Underwater archetype + water render system) ----- + + // Height of the strate water table as a fraction of the strate's height (0-1). + // 0 = no water. For the Underwater archetype set this high (e.g. 0.9) so almost + // the whole cavern floods. The density field is unaffected — this only tells the + // water render system where the surface sits. Rock below it reads as submerged. + // 0.0 → dry caves (default) + // 0.5 → half-flooded galleries + // 0.9 → nearly fully submerged (Underwater archetype) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Water", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float WaterLevelRelative = 0.0f; + + // Runtime values — set by StrateManager, NOT editable in the definition. + // These define the strate's Z range in voxel coordinates so the density + // function can seal the top and bottom boundaries. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; + + /** + * Linearly interpolate between two param sets. + * Used at strate boundaries to blend smoothly between adjacent strates. + * + * @param A - Params for the strate above + * @param B - Params for the strate below + * @param Alpha - 0.0 = fully A, 1.0 = fully B + * @return Blended params + */ + static FStrateGenerationParams Lerp( + const FStrateGenerationParams& A, + const FStrateGenerationParams& B, + float Alpha) + { + FStrateGenerationParams Result; + // One assignment per field, expanded from VF_STRATE_PARAM_FIELDS (defined + // above the struct). Bit-identical to the old hand-written list — same + // FMath::Lerp calls, same Alpha-0.5 snap for discrete fields. + VF_STRATE_PARAM_FIELDS(VF_PARAM_LERP, VF_PARAM_SNAP) + return Result; + } +}; + +// Keep the cache-key/memo-key audit mechanically tied to the struct. C++ has no portable +// reflection for UPROPERTY members, so the X-macro can at least prove that its listed byte sum +// and the completed POD layout still agree. The eight bytes below are the current, intentional +// padding between the three bool/enum members and their following four-byte fields on the UE +// toolchains supported by this plugin. A field added to the struct or the list without its +// counterpart changes one side of this equality and fails here instead of silently changing the +// blend/key coverage. +#define VF_STRATE_PARAM_SIZE_FIELD(Name) \ + + sizeof(((FStrateGenerationParams*)nullptr)->Name) +#define VF_STRATE_PARAM_COUNT_FIELD(Name) + 1 +constexpr SIZE_T VF_StrateParamListedBytes = + 0 VF_STRATE_PARAM_FIELDS(VF_STRATE_PARAM_SIZE_FIELD, VF_STRATE_PARAM_SIZE_FIELD); +constexpr int32 VF_StrateParamListedFieldCount = + 0 VF_STRATE_PARAM_FIELDS(VF_STRATE_PARAM_COUNT_FIELD, VF_STRATE_PARAM_COUNT_FIELD); +#undef VF_STRATE_PARAM_SIZE_FIELD +#undef VF_STRATE_PARAM_COUNT_FIELD + +static_assert( + VF_StrateParamListedFieldCount == 82, + "FStrateGenerationParams field count changed; update the struct and VF_STRATE_PARAM_FIELDS together."); +static_assert( + sizeof(FStrateGenerationParams) == VF_StrateParamListedBytes + 9, + "FStrateGenerationParams and VF_STRATE_PARAM_FIELDS disagree; update both places and recheck the expected padding."); + +//============================================================================= +// TERRAIN OPERATION REFERENCE +//============================================================================= + +// Forward declaration — the actual data asset lives in VoxelTerrainOpDefinition.h. +// We only need a soft pointer here, so no #include needed. +class UVoxelTerrainOpDefinition; +class UStaticMesh; + +/** + * FStrateTerrainOpEntry — A reference to a terrain operation with a weight. + * + * Strate definitions hold an array of these. Each entry says "apply this + * terrain operation at this intensity." The weight scales the op's primary + * activation field (density, strength, or spacing). + * + * USAGE IN EDITOR: + * In the strate definition's Details panel, expand TerrainOperations and add + * entries. Pick a DA_Op_* asset and set its weight: + * - Weight 1.0 = use the op as configured in the asset + * - Weight 0.5 = half intensity (fewer/smaller features) + * - Weight 1.5 = more intense than the asset's default + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateTerrainOpEntry +{ + GENERATED_BODY() + + // Reference to a terrain operation data asset (e.g., DA_Op_DeepPit). + // Soft pointer allows async loading and avoids hard dependencies. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Op") + TSoftObjectPtr Operation; + + // Intensity multiplier for this operation in this strate. + // 1.0 = use as configured in the data asset + // 0.5 = half intensity (fewer/smaller features) + // 2.0 = double intensity (use carefully — can overwhelm the cave) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Op", + meta = (ClampMin = "0.0", ClampMax = "3.0")) + float Weight = 1.0f; + + // Probability that any given room rolls this op from the strate's pool. + // Ops are selected by weighted draw per-room during cache build: + // - All probs sum to < 1.0 → some rooms get no terrain op (empty space in the pool) + // - All probs sum to >= 1.0 → every room gets an op (pool is fully claimed) + // - Multi-op strates: each entry competes for each room's random roll + // + // Examples (single op): + // 0.0 = never selected — op is disabled + // 0.5 = ~50% of rooms get this op, ~50% get nothing + // 1.0 = every room gets this op + // + // Examples (two ops, Prob=0.4 each → sum 0.8): + // ~40% rooms get op A, ~40% get op B, ~20% get nothing + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Op", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Probability = 0.5f; +}; + +//============================================================================= +// SLAB GENERATION PARAMS (FlatPlain / CrystalChamber generator types) +//============================================================================= + +/** + * FSlabGenerationParams — Density parameters for slab-based strates. + * + * Used by the FlatPlain and CrystalChamber generator types instead of the + * room-and-corridor FStrateGenerationParams. The entire strate becomes a + * horizontal void between a noisy floor surface and a noisy ceiling surface. + * + * COORDINATE CONVENTION: + * - Floor and ceiling positions are expressed as a fraction of the strate's + * total height (0.0 = strate bottom, 1.0 = strate top). + * - Roughness values are in voxels of surface displacement. + * - StrateTopWorldZ / StrateBottomWorldZ are runtime values (voxel coords), + * set by StrateManager — do NOT edit these manually. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FSlabGenerationParams +{ + GENERATED_BODY() + + // ===== VOID SHAPE ===== + + // Where the floor surface sits, relative to this strate's height. + // 0.0 = at the very bottom of the strate, 1.0 = at the very top. + // Keep this well below CeilingRelativeHeight or you get no open space. + // 0.1-0.2 → floor is near the bottom (lots of headroom) + // 0.25 → floor at 16 m in a 64 m strate (human-scale default) + // 0.4+ → very low ceiling; claustrophobic plains + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Shape", + meta = (ClampMin = "0.0", ClampMax = "0.95")) + float FloorRelativeHeight = 0.25f; + + // Where the ceiling surface sits, relative to this strate's height. + // Must be above FloorRelativeHeight. The difference determines how tall + // the open void is. + // 0.6 → ceiling at 38.4 m in a 64 m strate (22.4 m open span) + // 0.8 → tall open space + // 0.9+ → nearly the full strate height is open (cavernous plains) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Shape", + meta = (ClampMin = "0.05", ClampMax = "1.0")) + float CeilingRelativeHeight = 0.60f; + + // ===== FLOOR ROUGHNESS ===== + // Perlin-based displacement of the floor surface up/down. + // Creates gently rolling ground — hills, shallow valleys, bumps. + // The noise has a very low Z frequency so features extend horizontally. + + // How many voxels the floor surface can shift up or down. + // 0 → perfectly flat floor (artificial, but dramatic) + // 4 → 1 m rolling ground displacement (human-scale default) + // 3-5 → subtle rolling ground + // 8+ → significant hills, deep valleys + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Floor") + float FloorRoughness = 4.0f; + + // Spatial frequency of the floor noise. Lower = broader hills. + // 0.02 → very wide rolling hills (strate-scale) + // 0.04 → moderate hills (good default) + // 0.08 → frequent small bumps + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Floor") + float FloorRoughnessFrequency = 0.04f; + + // ===== CEILING ROUGHNESS ===== + // abs(noise) is used so ceiling features only protrude DOWNWARD. + // This creates stalactite/crystal-like formations hanging from above. + // For FlatPlain: keep this low for a relatively smooth ceiling. + // For CrystalChamber: crank this up to create dense formation forests. + + // How far ceiling formations hang down into the void (in voxels). + // 0 → flat ceiling (no formations) + // 6 → 1.5 m downward formations (FlatPlain/Crystal default) + // 10-15 → significant formations + // 20+ → dramatic columns reaching toward the floor + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Ceiling") + float CeilingRoughness = 6.0f; + + // Spatial frequency of the ceiling formation noise. Lower = fewer, larger formations. + // 0.02 → rare massive formations (icebergs) + // 0.04 → moderate (good default for FlatPlain) + // 0.06 → dense cluster of crystals (good for CrystalChamber) + // 0.1+ → very frequent small stalactites + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Ceiling") + float CeilingRoughnessFrequency = 0.04f; + + // ===== COLUMNS / PILLARS ===== + // Vertical rock cylinders scattered through the open void. + // Unlike TunnelNetwork columns (room-relative), these are placed on a + // world-space hash grid — they can appear anywhere in the void. + // + // Columns are ALWAYS full-height (floor-to-ceiling). Their radius can + // vary but they always span the full void from floor to ceiling. + + // Probability that a hash grid cell contains a column (0 = none, 1 = maximum density). + // 0.0 → disabled (no columns) + // 0.05 → sparse, dramatic lone pillars + // 0.12 → moderate scattering (good default) + // 0.25 → dense forest of pillars + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ColumnDensity = 0.08f; + + // Smallest column radius (in voxels). + // 1-2 → slender needle-like pillars + // 8 → 2 m radius / 4 m diameter (human-scale minimum) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns") + float ColumnMinRadius = 8.0f; + + // Largest column radius (in voxels). + // 8-16 → 2-4 m optional pillars + // 24+ → massive trunks + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns") + float ColumnMaxRadius = 16.0f; + + // Hash grid cell size (higher = columns further apart). + // 30 → dense grid, many candidate slots (most empty due to ColumnDensity) + // 96 → 24 m spacing (human-scale default) + // 100+ → only occasional columns + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns") + float ColumnSpacing = 96.0f; + + // ===== BOUNDARY & DENSITY ===== + + // Solid rock shell at the top and bottom of the strate. + // Prevents the void from touching the strate boundary — guarantees a + // solid ceiling and floor at the geological layer edges. + // 3-4 → thin seal (good default for slab strates) + // 6+ → thick solid boundary + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Boundary") + float BoundarySealThickness = 4.0f; + + // Base solidity strength — used to scale the boundary seal force. + // Match this to the floor/ceiling noise scale so the seal overpowers roughness. + // 6-8 → good default (matches typical roughness values) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Boundary") + float BaseDensity = 8.0f; + + // ===== RUNTIME (set by StrateManager — do not edit) ===== + + // Top Z boundary of this strate in voxel coordinates. Set by StrateManager. + float StrateTopWorldZ = 0.0f; + + // Bottom Z boundary of this strate in voxel coordinates. Set by StrateManager. + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// MAZE GENERATION PARAMS (ECaveGeneratorType::Maze) +//============================================================================= +/** + * FMazeGenerationParams — tight, branching corridors on a deterministic 3D lattice. + * + * Each lattice node sits at a cell center and chooses one parent among the axes that point toward + * the origin. That parent edge is always present, so the lattice is a spanning tree: following + * parents strictly lowers |X|+|Y|+|Z| and ends at (0,0,0). A capped hash roll adds optional + * horizontal/vertical loops for alternate routes; loops are visual detail, never the connectivity + * proof. The evaluator needs only the current cell's one-cell {-1,0} child-node halo. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FMazeGenerationParams +{ + GENERATED_BODY() + + // Lattice cell size in voxels. Smaller = tighter, more claustrophobic maze. + // 32 → compact maze cell · 64 → 16 m fight-space cell (default) · 96+ → roomy + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "8.0")) + float CellSize = 64.0f; + + // Corridor tube radius in voxels. Keep below CellSize/2 to leave a real lattice wall. + // These voxel-valued asset parameters retain their authored counts. At 12.5 cm/voxel + // an unchanged asset is half-scale in metres; double CorridorRadius to retain its old bore. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "1.0")) + float CorridorRadius = 12.5f; + + // Legacy authoring knob for optional horizontal loop edges (0-1). Parent edges are always + // present, so this no longer controls connectivity. The runtime caps it at 0.18 * this value. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float BranchProbability = 0.7f; + + // Legacy authoring knob for optional vertical loop edges (0-1). The spanning tree still reaches + // every Z level; the runtime caps this loop chance at 0.10 * Verticality. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Verticality = 0.3f; + + // Small-scale wall roughness (voxels). 0 = perfectly smooth tubes. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 2.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity before carving (higher = more solid). Matches roughness scale. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// SURFACE-WORLD GENERATION PARAMS (ECaveGeneratorType::SurfaceWorld) +//============================================================================= +/** + * FSurfaceGenerationParams — open-sky terrain inside a strate. + * + * A heightfield (fBM continents + ridged mountains + fine detail) defines the ground; + * everything below it is solid, everything above is open air up to a high solid ceiling + * (the "sky cap"). An optional water table floods valleys to make rivers/lakes/beaches. + * Skylight is simulated by up-facing light actors placed via the content pool. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FSurfaceGenerationParams +{ + GENERATED_BODY() + + // Mean ground height as a fraction of the strate height (0 = bottom, 1 = top). + // The terrain heightfield varies around this baseline by ElevationRange. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.05", ClampMax = "0.9")) + float BaseGroundRelative = 0.25f; + + // Total vertical amplitude of the terrain heightfield in voxels (peak-to-trough-ish). + // 20 → gentle plains · 80 → 20 m hills + valleys (default) · 120+ → tall mountains + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0")) + float ElevationRange = 80.0f; + + // Low-frequency "continent" noise — broad landmasses and basins. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape") + float ContinentFrequency = 0.006f; + + // How strongly sharp ridged mountains mix into the heightfield (0-1). + // 0 → rolling hills only · 0.5 → hills + peaks (default) · 1 → dramatic ranges + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float MountainStrength = 0.5f; + + // Frequency of the ridged mountain noise. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape") + float MountainFrequency = 0.012f; + + // Fine detail frequency — small bumps and rocks on the surface. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape") + float DetailFrequency = 0.04f; + + // Small-scale surface roughness in voxels (rocks, bumps). The default 2 voxels is 0.5 m; + // its proved 0.94 m noise reach remains small beside the 20 m terrain relief. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 2.0f; + + // ----- Macro relief & landforms ----- + + // Domain-warp the heightfield query by this many voxels of XY displacement before + // sampling continents/mountains. Bends straight coastlines and ridgelines into winding, + // organic landforms. 0 = no warp (axis-aligned blobby noise, the old look). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0")) + float HeightWarpStrength = 48.0f; + + // Frequency of the domain-warp noise. Lower = broader, sweeping bends. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro") + float HeightWarpFrequency = 0.008f; + + // Macro "relief" map frequency: a very-low-frequency field that makes some regions flat + // plains and others mountainous highlands — distinct terrain depending on where you + // stand. (The cheap, continuous precursor to a full biome system.) Lower = larger regions. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro") + float ReliefFrequency = 0.0015f; + + // How strongly the relief map modulates terrain (0-1). 0 = uniform everywhere (old + // behaviour); 1 = full plains <-> mountains variation across the world. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ReliefStrength = 0.7f; + + // Contrast of the relief map. >1 sharpens the plains/highland boundary (more distinct + // regions); ~1 keeps it a gradual blend. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.25", ClampMax = "4.0")) + float ReliefContrast = 1.6f; + + // Plateau/mesa terracing strength (0-1), applied in high-relief regions only. 0 = off + // (smooth slopes). Crank up for stepped mesas and layered cliffs in the mountainous areas. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TerraceStrength = 0.0f; + + // Height of each terrace step in voxels (when TerraceStrength > 0). The 8-voxel default is + // a 2 m optional landform step; TerraceStrength remains off by default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "1.0")) + float TerraceHeight = 8.0f; + + // Terrace edge sharpness (0-1). 0 = soft rounded steps; 1 = crisp flat mesas with near- + // vertical risers. Only matters when TerraceStrength > 0. (F20 — the plateau tops flatten + // and the risers steepen as this rises.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TerraceHardness = 0.5f; + + // ----- F20 surface terrain ops (heightfield tier — biome-selected, slope/relief aware) ----- + // These reshape the ground HEIGHT as a function of XY. All default OFF (0) so a strate/biome + // that doesn't set them is byte-identical to before. Cheap: pure per-column height remaps + // (no extra 3D density sampling). Each biome carries its own set; the surface blend lerps the + // final heights between the dominant and neighbour biome for free. + + // Sedimentary "layer lines": fine repeating shelves cut into slopes (exposed rock strata). + // Depth = voxels the surface is nudged toward each band plane; 0 = off. Reads on slopes, + // invisible on flats (a flat area shifts uniformly). Pair with a small Spacing for dense + // banding. Un-gated by relief so the geology reads everywhere. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float LayerLineDepth = 0.0f; + + // Vertical spacing between layer lines in voxels (band period). The 8-voxel default is 2 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "1.0")) + float LayerLineSpacing = 8.0f; + + // Cliff STEEPENING (0-1 master): where the surface is already STEEP (slope > threshold), + // push the height away from the local mean so gentle slopes become sheer walls / canyon + // faces, while gentle ground stays untouched. 0 = off. This is the slope-CONDITIONED op — + // it hugs real steep terrain instead of scattering cliffs at random. Costs 4 extra structural + // samples per column ONLY when > 0 (the priciest phase-1 op, still per-column-cheap). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float CliffStrength = 0.0f; + + // Slope (rise in voxels per voxel of XY) at which cliffs begin. Below this the ground is + // untouched; the effect ramps in above it. ~0.3 = 17°, ~0.5 = 27° (default), ~1.0 = 45°. + // Lower = more of the terrain qualifies as "cliff". + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.05")) + float CliffSlopeThreshold = 0.5f; + + // Extra steepness multiplier at full effect: how far the height is pushed from the local mean. + // 1 = up to ~2× the local relief on the steepest gated slopes; 3 = dramatic vertical walls. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float CliffSharpness = 2.0f; + + // XY distance (voxels) used to measure the slope / local mean for Cliff. Larger = smoother, + // broader cliff faces; smaller = reacts to finer bumps. Keep a few voxels. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.5")) + float CliffSampleDist = 2.0f; + + // ----- F20 phase 2: OVERHANG (volumetric — the first true-3D surface op) ----- + // Real jutting rock shelves: for air voxels just above a steep slope, the heightfield is re-sampled + // UPHILL (toward the cliff) by a height-varying amount and unioned in — so cliff rock extends OUT + // over the void below, self-capping at the cliff's height. This is genuine 3D (per-voxel re-eval on + // steep overhang columns only), costlier than the heightfield ops. 0 = off ⇒ byte-identical. + // Biome-selected: each biome's strength blends across borders. + + // Master overhang strength (0-1). 0 = off. Also the biome selector — a biome with 0 has no overhangs. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float OverhangStrength = 0.0f; + + // Horizontal REACH (voxels): how far the shelf juts out over the void from the cliff, and the scale + // at which the terrain gradient is measured (so a spot over the void can "see" the cliff). Bigger = + // deeper overhangs reaching further out (and a bit more cost). ~8-20. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangReach = 16.0f; + + // Vertical HEIGHT (voxels) of the overhang zone above the local ground — where the shelf sits above + // the ground/void directly under it, AND the band ClassifyTile treats as ambiguous (so it never holes + // a trivially-skipped tile). Larger = taller/higher shelves but more woken air tiles near cliffs. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangHeight = 24.0f; + + // Horizontal frequency of the shelf-shape noise (breaks the reach up so shelves are ragged, not a + // uniform lip). Lower = broader, smoother shelves. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangFrequency = 0.02f; + + // Vertical frequency RATIO of the shelf noise (× the horizontal frequency). Higher = the shelf folds/ + // curls more as it rises (more dramatic undercuts); near 0 = a flatter lip. ~0.4-0.8. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangZScale = 0.5f; + + // Slope (rise per voxel of XY, measured over ~Reach) at which overhangs begin. Below this, none. + // Lowered default so it triggers on merely-steep ground, not only near-vertical walls. ~0.2 = 11°, + // ~0.3 = 17° (default), ~0.6 = 31°. NOTE: a DRAMATIC jutting shelf still needs a near-vertical cliff + // (slope » 1) next to a drop — smooth hills can only get subtle folds; use Cliff to MAKE walls first. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.05")) + float OverhangSlopeThreshold = 0.3f; + + // ----- Water ----- + + // Water table height as a fraction of strate height (0 = no water). Valleys below + // this flood into lakes/rivers; the band just above is flattened into beaches. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Water", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float WaterLevelRelative = 0.27f; + + // Width (voxels) of the flattened beach/shore band around the water line. The default is 3 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Water", meta = (ClampMin = "0.0")) + float BeachWidth = 12.0f; + + // ----- Sky cap & boundary ----- + + // Height of the solid ceiling ("sky cap") as a fraction of strate height (0-1). + // The open-air gap is between the terrain and this cap. Keep high for big skies. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.3", ClampMax = "1.0")) + float CeilingRelative = 0.95f; + + // Downward bumpiness of the sky-cap ceiling (voxels). 0 = flat ceiling; 6 = 1.5 m detail. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingRoughness = 6.0f; + + // Frequency of the fine downward bumpiness above. Higher = smaller, denser bumps. + // (Was hard-coded to 0.04 — exposed so the cap detail scale is tunable.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingRoughnessFrequency = 0.04f; + + // Broad ceiling undulation (voxels): a low-frequency SIGNED swell that raises and lowers + // the WHOLE sky-cap, giving big inverted "hills and valleys" overhead — the ceiling reads + // like terrain instead of a flat lid. Independent of the fine bumps. 0 = level cap height. + // 0 → flat cap (old look) + // 30 → gentle rolling ceiling + // 80+ → dramatic overhead valleys and rises + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingUndulation = 0.0f; + + // Frequency of the broad undulation. Lower = larger, sweeping ceiling valleys. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingUndulationFrequency = 0.004f; + + // Ridged hanging formations (voxels): sharp downward ridges/blades carved into the cap — + // the "valley-like ridges" / inverted-mountain-range look. Adds to the downward hang. + // 0 → none + // 20 → clear ridgelines across the ceiling + // 50+ → dramatic hanging ranges + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingRidgeStrength = 0.0f; + + // Frequency of the ridged ceiling formations. Lower = broader ridges. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingRidgeFrequency = 0.02f; + + // Domain-warp the ceiling ridge/undulation query by this many voxels so ridgelines wind + // organically instead of looking like axis-aligned noise. 0 = no warp (fine bumps are + // unaffected, like the ground heightfield's detail layer). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingWarpStrength = 0.0f; + + // Frequency of the ceiling warp noise. Lower = broader bends. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingWarpFrequency = 0.01f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Boundary", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity before carving. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Boundary") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// VERTICAL-SHAFT GENERATION PARAMS (ECaveGeneratorType::VerticalShafts) +//============================================================================= +/** + * FVerticalShaftParams — a mostly-vertical cave system. + * + * Hash-placed full-height vertical shafts (tapered capsules spanning the strate) carved + * into solid rock, with periodic horizontal ledges inside them and occasional thin + * horizontal connectors linking neighbouring shafts. Emphasises climbing/falling. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVerticalShaftParams +{ + GENERATED_BODY() + + // Hash-grid cell size for shaft XY placement (voxels). Larger = shafts further apart. + // 80 voxels = 20 m, leaving room for a 4-7 m diameter shaft and a fight lane. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "10.0")) + float ShaftSpacing = 80.0f; + + // Probability a grid cell contains a shaft (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ShaftDensity = 0.6f; + + // Shaft radius range (voxels): 4-7 m diameter at the human-scale default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "1.0")) + float ShaftMinRadius = 8.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "1.0")) + float ShaftMaxRadius = 14.0f; + + // Chance an adjacent pair of shafts is joined by a horizontal connector tunnel (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float CrossConnectChance = 0.35f; + + // Radius of horizontal connector tunnels (voxels): 1.5 m radius / 3 m bore by default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "1.0")) + float ConnectorRadius = 6.0f; + + // Vertical spacing of ledges inside shafts (voxels). 32 = 4 m landing interval; + // 0 = no ledges (sheer drops). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float LedgeSpacing = 32.0f; + + // How far ledges intrude into the shaft (voxels of solid added). 4 = 1 m by default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float LedgeDepth = 4.0f; + + // Small-scale wall roughness (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 2.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity before carving. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// FLOATING-ISLAND GENERATION PARAMS (ECaveGeneratorType::FloatingIslands) +//============================================================================= +/** + * FFloatingIslandParams — suspended land masses in a large open void. + * + * The strate interior is open air; hash-placed island blobs (flattened-top ellipsoids, + * roughened underside) float at jittered heights. From below they read as land hanging + * "in the sky". Top/bottom are sealed solid so the void is enclosed. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FFloatingIslandParams +{ + GENERATED_BODY() + + // Hash-grid cell size for island placement (voxels). 112 = 14 m, while the largest default + // island is 24 m across, leaving a 2 m centre-to-edge gap. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "20.0")) + float IslandSpacing = 112.0f; + + // Probability a grid cell contains an island (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float IslandDensity = 0.5f; + + // Island horizontal radius range (voxels): 12-24 m landmass diameters by default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "2.0")) + float IslandMinRadius = 24.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "2.0")) + float IslandMaxRadius = 48.0f; + + // Island vertical thickness as a fraction of its horizontal radius. + // 0.4 → thin plates · 0.6 → substantial landmass (default) · 1.0 → near-spherical + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.1", ClampMax = "1.5")) + float ThicknessRatio = 0.6f; + + // How much islands scatter vertically within the void (0 = all mid-height, 1 = full spread). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float VerticalJitter = 0.6f; + + // Flatten island tops into walkable land (0 = round dome, 1 = flat plateau). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TopFlatten = 0.6f; + + // Surface roughness on island shells (voxels) — craggy undersides, bumpy tops. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 3.0f; + + // SmoothMin blend radius for merging overlapping islands (voxels): 1.5 m by default. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0")) + float SDFBlendRadius = 6.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity inside islands. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// DISTURBANCE PARAMS (the "wow" layer — applies to ANY archetype) +//============================================================================= +/** + * FStrateDisturbanceParams — composable surprises layered on top of whatever + * archetype a strate uses, as a density post-process. Hash-placed and deterministic, + * they never breach the strate seals (they only act in the interior). Set a feature's + * density to 0 to disable it. Designed to make exploration unpredictable: a chasm + * splitting an otherwise tidy maze, a natural bridge over a chamber, blades of rock + * rising from the floor. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateDisturbanceParams +{ + GENERATED_BODY() + + // --- CHASMS: large vertical rifts that carve open air through the interior --- + // Probability a chasm cell is active (0 = disabled). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Chasms", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ChasmDensity = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Chasms", meta = (ClampMin = "20.0")) + // 192 voxels = 24 m between optional chasm sites at 12.5 cm/voxel. + float ChasmSpacing = 192.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Chasms", meta = (ClampMin = "1.0")) + float ChasmRadius = 24.0f; + + // --- BRIDGES: horizontal solid spans across open space --- + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Bridges", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float BridgeDensity = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Bridges", meta = (ClampMin = "20.0")) + // 128 voxels = 32 m between optional bridge sites. + float BridgeSpacing = 128.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Bridges", meta = (ClampMin = "1.0")) + float BridgeRadius = 8.0f; + + // --- RIDGES: thin solid blades rising from the floor --- + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RidgeDensity = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "20.0")) + // 128 voxels = 32 m between optional ridge sites. + float RidgeSpacing = 128.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "0.0")) + float RidgeHeight = 32.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "1.0")) + float RidgeThickness = 8.0f; + + // Rock solidity used to scale carve/fill strength. Match the strate's BaseDensity. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance") + float BaseDensity = 8.0f; + + // Seal thickness — disturbances stay clear of the seal bands. Match the strate. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// INTER-STRATE PASSAGE CONFIG (per-strate tunnel control) +//============================================================================= +/** + * EVoxelPassageStyle — the shape of an auto-carved inter-strate tunnel. + */ +UENUM(BlueprintType) +enum class EVoxelPassageStyle : uint8 +{ + // “Straight” is retained as the authored style name for the base connection, but its runtime + // geometry is a gentle two-leg switchback rather than a vertical shaft. + Straight UMETA(DisplayName = "Straight (walkable sloped tunnel)"), + Worm UMETA(DisplayName = "Worm (organic meander around the axis)"), + Spiral UMETA(DisplayName = "Spiral (corkscrew descent)"), + Cascading UMETA(DisplayName = "Cascading (ledge + drop staircase)") +}; + +/** + * FStratePassageConfig — how THIS strate connects DOWN to the strate below it. + * + * Lives on each UVoxelStrateDefinition: the upper strate of every boundary controls its + * own progression tunnel, so different layers connect differently. Each strate has a finite + * (0,0) landing room; this config controls the passage that opens the next room. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStratePassageConfig +{ + GENERATED_BODY() + + // How many auto-carved tunnels descend from this strate to the one below (0 = none). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage", meta = (ClampMin = "0", ClampMax = "12")) + int32 Connections = 1; + + // Tunnel shape. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage") + EVoxelPassageStyle Style = EVoxelPassageStyle::Straight; + + // ----- WIDTH (tapers along the length) ----- + // Radius at the two mouths (entry/exit) and at the middle. Equal = uniform tube; + // The walkable default is 1.5x the original comfortable profile: 2.25 m diameter at mouths + // and 1.875 m at the waist when the stored radii remain 9/7.5 voxels at 12.5 cm per voxel. + // Double these asset values to retain the previous 4.5/3.75 m dimensions. The §6.5 landing + // room remains wider for turning. + // Mouth > Mid = chambers at the ends with a squeeze between; Mid > Mouth = a bulge. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) + float MouthRadius = 9.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) + float MidRadius = 7.5f; + + // ----- LENGTH ----- + // How far the tunnel reaches INTO each strate (voxels). Auto-capped to the interior; + // the defaults span 8-24 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Length", meta = (ClampMin = "8.0")) + float ReachMin = 32.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Length", meta = (ClampMin = "8.0")) + float ReachMax = 96.0f; + + // ----- PLACEMENT ----- + // Horizontal distance range from the (0,0) spine (voxels) where tunnels may appear; + // the defaults are 16-48 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Placement", meta = (ClampMin = "0.0")) + float DistanceMin = 64.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Placement", meta = (ClampMin = "0.0")) + float DistanceMax = 192.0f; + + // ----- SHAPE detail ----- + // Exotic styles only: max sideways excursion from the axis (voxels). 24 = 6 m; 0 = straight + // even in Worm style. The default walkable switchback does not use this wander value. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "0.0")) + float Wander = 24.0f; + + // Path resolution (control points). Higher = smoother curves; lower = more faceted / + // cheaper. The worm makes several bends, so keep this reasonably high for smoothness. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "1", ClampMax = "48")) + int32 Segments = 20; + + // Vertical wobble (voxels) — dips/rises along the descent for a less uniform fall. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "0.0")) + float VerticalWobble = 0.0f; + + // Spiral style: helix radius (voxels) and number of full turns over the descent. The default + // radius is 6 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Spiral", meta = (ClampMin = "1.0")) + float SpiralRadius = 24.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Spiral", meta = (ClampMin = "0.25")) + float SpiralTurns = 2.0f; + + // Cascading style: number of ledge+drop steps, and how far each ledge runs (voxels). The + // default ledge run is 2 m. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Cascade", meta = (ClampMin = "1", ClampMax = "16")) + int32 CascadeSteps = 4; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Cascade", meta = (ClampMin = "0.0")) + float CascadeLedge = 8.0f; +}; + +//============================================================================= +// CONTENT ENTRY STRUCTS +//============================================================================= + +/** + * ETerrainConditionType — which analytic terrain field an FTerrainCondition tests. + * All are DERIVED PREDICATES (pure functions of XY + seed + strate), evaluated on demand — NOT stored + * terrain annotations (see the F7 design: "conditions, not annotations"). More types (water-edge, + * relief-peak local-max) land in later passes. + */ +UENUM(BlueprintType) +enum class ETerrainConditionType : uint8 +{ + Relief UMETA(DisplayName = "Relief (elevation 0-1)"), // SampleRelief — peaks/mesas/lowlands + Moisture UMETA(DisplayName = "Moisture (0-1)"), // SampleMoisture — wet/dry + BiomeBorder UMETA(DisplayName = "Biome border (0=deep, ~0.5=edge)"), // near a biome boundary +}; + +/** + * FTerrainCondition — one relational "aware placement" predicate (F7). The candidate point's derived + * field (Relief/Moisture/biome-border weight) must fall in [Min,Max] (or OUTSIDE it when bInvert). + * Multiple conditions on one entry are AND-ed. Empty list = no test (zero cost). Deterministic + + * worker-safe (pure query of the analytic fields, no stored state). Also drives the future quest + * FindFeature locator (same predicate, run as a search). + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FTerrainCondition +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition") + ETerrainConditionType Type = ETerrainConditionType::Relief; + + // Inclusive lower/upper bound of the accepted band. All fields read 0..1; BiomeBorder is ~0 deep in a + // biome cell and approaches ~0.5 exactly on a border, so "near a border" ≈ Min 0.35. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Min = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Max = 1.0f; + + // Accept OUTSIDE [Min,Max] instead of inside (avoid peaks, keep off borders, …). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition") + bool bInvert = false; +}; + +/** + * FPlacementProfile — shared placement settings for every scatter primitive + * (FStrateDecoration and FStrateLandmark — the latter also covers set-pieces: ruins/shrines/monuments). + * + * Each primitive keeps only its own DISTRIBUTION fields (HOW candidates are enumerated — + * a per-column grid, a hash lattice, an anchor mode). Everything about "can it go here", + * "what spawns", and "how it looks" lives HERE, once, so all three primitives are authored + * with one identical vocabulary. The awareness layer (FTerrainCondition Conditions[]) lands + * in the Filter section in a later pass. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FPlacementProfile +{ + GENERATED_BODY() + + // ----- Spawn (one of these; ActorClass wins if both set) ----- + + // Real actor — lights, logic, interaction. Costs game-thread time per instance: prefer InstancedMesh + // for pure visual props (dense decoration especially — an actor per groundcover instance is ruinous). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement") + TSubclassOf ActorClass; + + // INSTANCED path: if set, renders as batched instances (decoration) or one StaticMeshComponent + // (landmark/set-piece) instead of spawning ActorClass (which is then ignored). No tick, no per-actor + // overhead, engine-culled. An emissive material still glows at distance without a light. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement") + UStaticMesh* InstancedMesh = nullptr; + + // ----- Filter (can it go here) ----- + + // Which surface type this entry snaps to. (Decoration defaults Any; landmarks default Ceiling — set in + // each primitive's constructor.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + ESurfaceType SurfacePlacement = ESurfaceType::Any; + + // Surface-tilt band (degrees from flat = acos(|normal.Z|); 0 = flat, 90 = vertical). MaxSlopeAngle + // rejects surfaces STEEPER than it (90 = no filter); MinSlopeAngle rejects surfaces FLATTER than it + // (0 = no filter). Pair them to band a prop onto a tilt range (e.g. 30..70 = slopes only). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter", meta = (ClampMin = "0.0", ClampMax = "90.0")) + float MaxSlopeAngle = 90.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter", meta = (ClampMin = "0.0", ClampMax = "90.0")) + float MinSlopeAngle = 0.0f; + + // Wall entries only: exclude downward-facing OVERHANGS. A "wall" (|normal.Z| <= 0.5) still includes + // surfaces leaning slightly DOWNWARD; set this so only normals with Z >= 0 (upright walls) qualify. + // Ignored unless the point resolves as a wall. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + bool bWallExcludeOverhangs = false; + + // Water-relative gate (ignored unless the strate has a water table): place only below (true) / above + // (false) the water line when bRequireWaterRelative is set. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + bool bRequireWaterRelative = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter", + meta = (EditCondition = "bRequireWaterRelative")) + bool bPlaceBelowWater = false; + + // Optional: only place inside this biome (resolved at the candidate XY). Null = any biome in the + // strate. (Decoration entries are already scoped to a biome by being listed under it, so this is + // mostly for the strate-wide landmark / set-piece primitives.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + UVoxelBiomeDefinition* RequiredBiome = nullptr; + + // Relational "aware placement" (F7): derived predicates the candidate must satisfy (relief/moisture/ + // biome-border), all AND-ed and evaluated by pure query — temples on peaks, oasis in wet lowlands, + // markers on biome borders. Empty = no test (zero cost). Deterministic + worker-safe. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + TArray Conditions; + + // ----- Transform ----- + + // Rotate the object so its up-axis follows the surface normal (plants stand up on floors, stalactites + // point down on ceilings). If false, keeps world-up. (Decoration defaults true; landmarks default + // false — set per primitive.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + bool bAlignToSurface = true; + + // Offset along the surface normal (cm). Positive = lift off the surface, negative = sink in. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + float SurfaceOffset = 0.0f; + + // WORLD-space position offset (cm) added after the surface snap (e.g. +Z lifts a sun off the sky-cap). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + FVector LocationOffset = FVector::ZeroVector; + + // Fixed rotation applied on top of the (optional) surface alignment. Use Yaw here to face a prop + // roughly one way (pair with RandomRotation.Yaw for banded variation — replaces the old MinYaw/MaxYaw). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + FRotator RotationOffset = FRotator::ZeroRotator; + + // Per-axis RANDOM rotation range (degrees) — each instance gets a hash-deterministic ±value/2 on each + // axis. Yaw alone = spin variety (360 = full random heading, the decoration default); all three = + // tumbled-debris look. 0 on an axis = no randomisation there. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + FRotator RandomRotation = FRotator::ZeroRotator; + + // Uniform scale range (hash-random per instance). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + float MinScale = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + float MaxScale = 1.0f; + + // ----- Render (InstancedMesh / StaticMeshComponent path) ----- + + // Distance (cm) past which the mesh stops drawing. 0 = never cull (correct for trees / far-visible + // suns). THE lever that makes dense groundcover affordable — grass drawn only near the player. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Render", meta = (ClampMin = "0.0")) + float CullDistance = 0.0f; + + // Whether the instances cast dynamic shadows. Dense instanced shadows are the single biggest cost of + // heavy foliage — turn OFF for grass / small clutter, leave ON for trees and large props. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Render") + bool bCastShadow = true; +}; + +/** + * FDecoSubCompanion — a LEVEL-2 satellite: clutter that spawns ON a level-1 companion (moss on a rock). + * Distinct type (not a self-recursive FDecoCompanion, which UHT can't reflect) so nesting caps at 2 levels. + * Always INHERITS its level-1 parent's snapped surface point (no per-satellite re-solve → nesting stays cheap); + * may still gate on its own Profile.Conditions. Small radii — it sits on its parent. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FDecoSubCompanion +{ + GENERATED_BODY() + + FDecoSubCompanion() + { + Profile.MinScale = 0.8f; + Profile.MaxScale = 1.2f; + Profile.RandomRotation.Yaw = 360.0f; + } + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0.0")) + float RadiusMinVox = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0.0")) + float RadiusMaxVox = 3.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0")) + int32 CountMin = 1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0")) + int32 CountMax = 2; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Probability = 1.0f; +}; + +/** + * FDecoCompanion — a level-1 cluster satellite that spawns near each placed instance of its parent decoration + * (F7 relational placement). Deterministic (pure function of the parent's hash → no search), re-snaps to the + * real surface at its own XY (bSnapToSurface), may gate on its own Conditions, and may itself carry LEVEL-2 + * `SubCompanions` (moss on a rock). E.g. a tree lists rocks + mushrooms; a rock lists moss. Two levels max + * (per-parent budget caps the total); deeper nesting is a later concern. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FDecoCompanion +{ + GENERATED_BODY() + + FDecoCompanion() + { + // Same clutter defaults as a decoration entry (variety scale + full random yaw). + Profile.MinScale = 0.8f; + Profile.MaxScale = 1.2f; + Profile.RandomRotation.Yaw = 360.0f; + } + + // What to spawn + how it looks (mesh/actor, transform, render). Same vocabulary as the parent. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + // Disk (in VOXELS) around the parent that satellites scatter into. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0.0")) + float RadiusMinVox = 2.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0.0")) + float RadiusMaxVox = 6.0f; + + // How many satellites per parent (inclusive range, deterministic per parent). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0")) + int32 CountMin = 1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0")) + int32 CountMax = 3; + + // Chance this companion type fires at all, per parent (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Probability = 1.0f; + + // Snap each satellite to the REAL surface at its own XY (fixes floaters on uneven ground; respects the + // companion's own SurfacePlacement). Cheap on SurfaceWorld (height oracle), a short ray-march in caves. + // OFF = inherit the parent's exact height + normal (cheapest — fine only on flat ground). A satellite + // that finds no surface at its spot is simply skipped (no floater). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion") + bool bSnapToSurface = true; + + // LEVEL-2 clutter that spawns ON each of THIS companion's satellites (e.g. this = rock, sub = moss). + // Inherits the satellite's surface point (no extra surface find). Capped by the per-parent budget. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion") + TArray SubCompanions; +}; + +/** + * FStrateDecoration — One decoration type that can spawn on surfaces. + * + * Placed ON the cave surface (stalactites on ceilings, mushrooms on floors, crystals on walls). + * All the placement/transform/render settings live on the shared `Profile`; this struct adds only + * decoration's own DISTRIBUTION fields (per-column dense grid, spawn density, per-chunk cap). + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateDecoration +{ + GENERATED_BODY() + + FStrateDecoration() + { + // Decoration defaults that differ from FPlacementProfile's neutral defaults: variety scale range + // and a full random yaw (reproduces the legacy bRandomYaw = true, MinYaw/MaxYaw = 0..360 look). + Profile.MinScale = 0.8f; + Profile.MaxScale = 1.2f; + Profile.RandomRotation.Yaw = 360.0f; + } + + // Shared placement/transform/render settings. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + // Which of the two decoration streaming grids this entry uses (§8.5). Far (default) = full radius + + // coarse column grid (cheap for rare/large props visible everywhere); Near = short radius + fine + // column grid (dense groundcover near the player only). The radius/spacing presets live on + // VoxelSettings; this only PICKS a grid. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") + EDecoStreamTier StreamTier = EDecoStreamTier::Far; + + // Chance per valid surface point to spawn this decoration (0-1). + // 0.01 = rare, 0.1 = common, 0.5 = very dense. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float SpawnDensity = 0.05f; + + // Hard cap on how many of THIS decoration spawn per chunk (perf safety). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "1")) + int32 MaxPerChunk = 40; + + // Cluster satellites scattered around each placed instance of THIS decoration (F7 relational placement) — + // e.g. a tree → rocks + mushrooms. Deterministic, one level, inherits this entry's surface point. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Companions") + TArray Companions; +}; + +/** + * ELandmarkAnchor — how a landmark's candidate anchor points are chosen. Feature-conditioning + * (peaks / wet lowlands / biome edges) rides on TOP of either mode via Profile.Conditions. + */ +UENUM(BlueprintType) +enum class ELandmarkAnchor : uint8 +{ + HashLattice UMETA(DisplayName = "Hash Lattice (scattered)"), // scatter on a coarse lattice + PassageMouth UMETA(DisplayName = "Passage Mouth (at descents)"), // at passage endpoints in this strate +}; + +/** + * FStrateLandmark — a RARE, deliberately-placed object: mini-suns, ruins, shrines, monuments (§8.5, F7). + * The one placement primitive for "notable things you navigate by" (set-pieces included). + * + * This is the right primitive for things like the underground "mini-suns" (in-lore light sources): one + * object per ~`SpacingChunks` lattice cell, so the work scales with how MANY landmarks are in range + * (a handful), NOT with the streamed area. That makes a HUGE stream radius (e.g. visible 16 km out so it + * never pops) cheap — unlike the per-chunk decoration grid, which enumerates every chunk in the disk and + * freezes at large radius. Placement is deterministic (pure hash of cell + entry + seed → no pop, same + * landmark in the same place forever), evaluated synchronously on the game thread only when a NEW lattice + * cell enters range (there are so few candidates this never hitches). Strate-wide (listed on the strate + * definition), with an optional per-landmark biome filter. Foliage-style transform tweaks are exposed. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateLandmark +{ + GENERATED_BODY() + + FStrateLandmark() + { + // Landmark defaults that differ from FPlacementProfile's neutral defaults: suns sit on the sky-cap + // ceiling and stay world-upright regardless of the ceiling tilt. + Profile.SurfacePlacement = ESurfaceType::Ceiling; + Profile.bAlignToSurface = false; + } + + // Shared placement/transform/render settings (what to spawn, surface/slope/water gates, transform, + // cull/shadow). A sun that carries its own light/logic goes in Profile.ActorClass; a plain glowing mesh + // in Profile.InstancedMesh. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + // How candidate anchor points are chosen. Feature-conditioning (peaks / wet lowlands / biome edges) + // rides on TOP of either mode via Profile.Conditions. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark") + ELandmarkAnchor AnchorMode = ELandmarkAnchor::HashLattice; + + // ----- Hash lattice (AnchorMode == HashLattice — scattered placement; cheap at any radius) ----- + + // Average spacing between instances, IN CHUNKS = the lattice cell size (one candidate per cell, so cost + // scales with (radius/spacing)²). Large = rare & far apart. 16 → frequent · 64 → sparse · 256+ → km-scale + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "1.0")) + float SpacingChunks = 64.0f; + + // How far within its cell a candidate may wander (0 = dead-centre, 1 = anywhere). Min spacing between two + // ≈ SpacingChunks·(1 − JitterFraction). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0")) + float JitterFraction = 0.5f; + + // Probability that a lattice cell actually contains this instance (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0")) + float SpawnProbability = 1.0f; + + // ----- Passage mouths (AnchorMode == PassageMouth — at the passages threading this strate) ----- + + // Place at the DESCENT mouth — where a passage LEAVES this strate downward (the hole going down; e.g. a + // guardian over the descent). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Passage", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::PassageMouth", EditConditionHides)) + bool bAtDescentMouths = true; + + // Place at the ARRIVAL mouth — where a passage ENTERS this strate from above (where you land; e.g. a + // shrine at the bottom of the climb). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Passage", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::PassageMouth", EditConditionHides)) + bool bAtArrivalMouths = true; + + // Per-mouth chance to place (0-1). Deterministic. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Passage", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::PassageMouth", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0")) + float MouthProbability = 1.0f; + + // ----- Shared ----- + + // How far out (in chunks) instances stream / stay visible. Cheap to make large (lattice candidate count + // scales with (radius/spacing)²; the passage list is finite). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark", meta = (ClampMin = "1")) + int32 StreamRadiusChunks = 256; + + // ----- Exclusion (relational self-awareness — optional) ----- + + // A placed instance suppresses OTHERS whose anchor falls within this radius (chunks) so two never overlap. + // 0 = OFF (the default — pure scatter; mini-suns don't exclude). Conflicts resolve deterministically: + // higher Priority wins, ties by hash. (v1 resolves within the streamed set; a fully position-independent, + // pop-free resolve is a planned follow-up.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Exclusion", meta = (ClampMin = "0.0")) + float ExclusionRadiusChunks = 0.0f; + + // Higher wins an exclusion conflict (a major shrine outranks scattered ruins). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Exclusion") + int32 Priority = 0; + + // ----- Decoration footprint (clear groundcover under the object so it doesn't clip through) ----- + + // Remove decorations (grass etc.) within a radius of this landmark, so foliage doesn't poke through a + // temple floor. Uses the same instance-removal as player digging (cleared on spawn AND when decorations + // stream in near it). A landmark mesh doesn't change density, so this is the only thing that clears under it. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Footprint") + bool bSuppressDecorationsUnder = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Footprint", + meta = (EditCondition = "bSuppressDecorationsUnder", ClampMin = "0.0")) + float SuppressRadiusChunks = 2.0f; + + // (Placement gates, transform tweaks, and cull/shadow render tuning live on the shared `Profile` above — + // see FPlacementProfile. Landmark-specific defaults: Ceiling surface + no surface-align.) + + // ----- MINI-SUN LIGHT ORB (feeds the terrain material's raymarched shadows) ----- + // When set, this landmark is also a LIGHT SOURCE: the terrain material marches the density volume + // toward it for from-the-orb, crisp, dynamic shadows (forward rendering). The visible glowing mesh is + // still the InstancedMesh/ActorClass above — this just declares the lighting. NOT a UE light actor. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb") + bool bIsLightOrb = false; + + // Light colour of the orb. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb")) + FLinearColor OrbColor = FLinearColor(1.0f, 0.95f, 0.85f, 1.0f); + + // Overall brightness multiplier. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "0.0")) + float OrbIntensity = 3.0f; + + // Orb emitter radius in VOXELS (visual/softness reference; falloff origin). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "0.0")) + float OrbRadiusVoxels = 16.0f; + + // Distance in VOXELS over which the orb's light falls to zero. YOU author this (no inverse-square + // blowout) — bigger = lights a wider area. (1 voxel = 12.5 cm.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "1.0")) + float OrbFalloffVoxels = 2000.0f; + + // Max distance in VOXELS along the shadow ray we test for occlusion (bounds the per-pixel march cost; + // past this the point is treated as lit). Keep ≤ the level-0 volume reach for crisp contact shadows. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "1.0")) + float OrbMaxShadowDistanceVoxels = 1000.0f; +}; + +/** + * FStrateAmbientActor — An actor that spawns in open cave space. + * + * Unlike decorations (placed on surfaces), ambient actors float in the void: + * fog volumes, particle emitters, light sources, floating crystals, etc. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateAmbientActor +{ + GENERATED_BODY() + + // The actor class to spawn + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ambient") + TSubclassOf ActorClass; + + // Probability of this actor spawning per chunk (0-1) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ambient", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float SpawnChancePerChunk = 0.1f; +}; + +/** + * FStrateCreature — A creature/enemy type that can spawn in this strate. + * + * Creatures are managed by a future spawning system that respects + * per-strate population limits and weighted random selection. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateCreature +{ + GENERATED_BODY() + + // The creature actor class + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Creature") + TSubclassOf ActorClass; + + // Maximum number of this creature type alive in the entire strate at once + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Creature") + int32 MaxPerStrate = 5; + + // Relative spawn weight (higher = more likely to be chosen when spawning) + // If two creatures have weights 2.0 and 1.0, the first spawns 2x as often + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Creature") + float SpawnWeight = 1.0f; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h b/Standalone/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h new file mode 100644 index 0000000..bf041a7 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h @@ -0,0 +1,350 @@ +// VoxelTerrainOpDefinition.h +// Individual terrain operation data asset — one per operation variant. +// +// DESIGN: +// ======== +// Each terrain operation (pit, arch, terrace, etc.) is its own asset. +// The strate definition holds a weighted array of references to these. +// This allows: +// - Reuse: "DA_Op_DeepPit" can be referenced by multiple strates +// - Composition: mix different ops per strate by drag-and-drop +// - Tuning: tweak one asset, all strates using it update +// +// HOW PARAMS FLOW: +// At generation time, the strate manager builds FStrateGenerationParams by: +// 1. Starting from the strate definition's base params (terrain op fields = 0) +// 2. For each terrain op reference: calling ApplyTo() which copies the op's +// fields into the params, scaled by the entry's Weight. +// 3. The generator reads the final params as usual — no code change needed. +// +// EDITOR UX: +// The Type enum controls which param group is visible via EditCondition. +// When you select "Pit", only pit-related fields appear in the Details panel. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VoxelStrateTypes.h" +#include "VoxelTerrainOpDefinition.generated.h" + +/** + * EVoxelTerrainOpType — Which terrain operation this asset configures. + * Each type maps to a specific density modification in the generation pipeline. + */ +UENUM(BlueprintType) +enum class EVoxelTerrainOpType : uint8 +{ + // Surface treatments (modify cave wall character) + Terrace UMETA(DisplayName = "Terrace / Ledges"), + LayerLines UMETA(DisplayName = "Layer Lines"), + Ribbing UMETA(DisplayName = "Ribbing"), + Cliff UMETA(DisplayName = "Cliff Sharpening"), + Scallop UMETA(DisplayName = "Scallop / Erosion"), + Overhang UMETA(DisplayName = "Overhang / Shelf"), + + // Structural features (place geometry in caves) + Arch UMETA(DisplayName = "Arch / Bridge"), + Column UMETA(DisplayName = "Column / Pillar"), + Pit UMETA(DisplayName = "Pit / Shaft (downward)"), + Chimney UMETA(DisplayName = "Chimney / Shaft (upward)"), + Dome UMETA(DisplayName = "Dome (cathedral ceiling)"), + Pinch UMETA(DisplayName = "Pinch / Bottleneck"), +}; + +/** + * UVoxelTerrainOpDefinition — A single terrain operation, configured as a data asset. + * + * Create these in your Content folder (e.g., Content/TerrainOps/DA_Op_DeepPit). + * Then reference them from your strate definitions with a weight. + * + * USAGE EXAMPLE: + * 1. Create: Right-click Content → Miscellaneous → Data Asset → VoxelTerrainOpDefinition + * 2. Set Type to "Pit", configure depth/radius/taper + * 3. In your strate definition, add this asset to TerrainOperations with Weight=1.0 + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelTerrainOpDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + + //========================================================================= + // OPERATION TYPE + //========================================================================= + + // Which terrain operation this asset configures. + // Changing this shows/hides the relevant parameter group below. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Operation") + EVoxelTerrainOpType Type = EVoxelTerrainOpType::Terrace; + + //========================================================================= + // TERRACE PARAMS (visible when Type == Terrace) + //========================================================================= + + // Vertical spacing between terrace steps (in voxels). 0 = disabled. + // 3-5 → tight steps (staircase feel) + // 8-12 → wide ledges (platforming scale) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrace", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Terrace")) + float TerraceStepHeight = 5.0f; + + // Edge sharpness: 0 = rounded steps, 1 = razor sharp edges. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrace", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Terrace", + ClampMin = "0.0", ClampMax = "1.0")) + float TerraceHardness = 0.5f; + + // Noise displacement on step edges (organic variation). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrace", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Terrace", + ClampMin = "0.0", ClampMax = "2.0")) + float TerraceNoiseDisplacement = 0.5f; + + //========================================================================= + // LAYER LINES PARAMS (visible when Type == LayerLines) + //========================================================================= + + // Spacing between horizontal geological lines (in voxels). 0 = disabled. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Layer Lines", + meta = (EditCondition = "Type == EVoxelTerrainOpType::LayerLines")) + float LayerLineSpacing = 4.0f; + + // Depth of the grooves (how much density is removed). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Layer Lines", + meta = (EditCondition = "Type == EVoxelTerrainOpType::LayerLines", + ClampMin = "0.0", ClampMax = "2.0")) + float LayerLineDepth = 0.3f; + + //========================================================================= + // RIBBING PARAMS (visible when Type == Ribbing) + //========================================================================= + + // Spacing between parallel ribs along Z (in voxels). 0 = disabled. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ribbing", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Ribbing")) + float RibbingSpacing = 3.0f; + + // Depth of the ridges. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ribbing", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Ribbing", + ClampMin = "0.0", ClampMax = "3.0")) + float RibbingDepth = 0.4f; + + //========================================================================= + // CLIFF PARAMS (visible when Type == Cliff) + //========================================================================= + + // How much to amplify vertical gradients near cave surfaces. + // 0 = no effect, 1 = maximum sheer cliff amplification. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cliff", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Cliff", + ClampMin = "0.0", ClampMax = "1.0")) + float CliffStrength = 0.5f; + + //========================================================================= + // SCALLOP PARAMS (visible when Type == Scallop) + //========================================================================= + + // Strength of the erosion bowl patterns (cellular noise subtraction). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scallop", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Scallop", + ClampMin = "0.0", ClampMax = "2.0")) + float ScallopStrength = 0.5f; + + // Frequency of the cellular noise (smaller = larger bowls). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scallop", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Scallop")) + float ScallopFrequency = 0.1f; + + //========================================================================= + // OVERHANG PARAMS (visible when Type == Overhang) + //========================================================================= + + // Probability/strength of overhang generation (0 = none, 1 = max). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Overhang", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Overhang", + ClampMin = "0.0", ClampMax = "1.0")) + float OverhangStrength = 0.5f; + + // How far the overhang protrudes from the wall (in voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Overhang", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Overhang")) + float OverhangDepth = 5.0f; + + // Frequency of the noise that creates overhangs (lower = broader shelves). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Overhang", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Overhang")) + float OverhangFrequency = 0.06f; + + //========================================================================= + // ARCH PARAMS (visible when Type == Arch) + //========================================================================= + + // Probability per grid cell (0 = none, 0.3 = max recommended). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Arch", + ClampMin = "0.0", ClampMax = "0.3")) + float ArchDensity = 0.1f; + + // Minimum arch thickness (radius of the capsule SDF). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Arch", + ClampMin = "1.0")) + float ArchMinRadius = 3.0f; + + // Maximum arch thickness. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Arch", + ClampMin = "1.0")) + float ArchMaxRadius = 6.0f; + + + //========================================================================= + // COLUMN PARAMS (visible when Type == Column) + //========================================================================= + + // Probability per grid cell (0 = none, 1 = every cell). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Column", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Column", + ClampMin = "0.0", ClampMax = "1.0")) + float ColumnDensity = 0.15f; + + // Minimum column radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Column", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Column", + ClampMin = "0.5")) + float ColumnMinRadius = 2.0f; + + // Maximum column radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Column", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Column", + ClampMin = "1.0")) + float ColumnMaxRadius = 5.0f; + + + //========================================================================= + // PIT PARAMS (visible when Type == Pit) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "0.0", ClampMax = "0.5")) + float PitDensity = 0.1f; + + // Minimum pit radius at the top. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "1.0")) + float PitMinRadius = 4.0f; + + // Maximum pit radius at the top. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "2.0")) + float PitMaxRadius = 10.0f; + + // How deep the pit extends downward (in voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "5.0")) + float PitDepth = 25.0f; + + + //========================================================================= + // CHIMNEY PARAMS (visible when Type == Chimney) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "0.0", ClampMax = "1.0")) + float ChimneyDensity = 0.1f; + + // Minimum chimney radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "0.5")) + float ChimneyMinRadius = 2.0f; + + // Maximum chimney radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "1.0")) + float ChimneyMaxRadius = 5.0f; + + // How high the chimney extends upward (in voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "5.0")) + float ChimneyHeight = 20.0f; + + + //========================================================================= + // DOME PARAMS (visible when Type == Dome) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "0.0", ClampMax = "1.0")) + float DomeDensity = 0.15f; + + // Minimum dome radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "3.0")) + float DomeMinRadius = 8.0f; + + // Maximum dome radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "4.0")) + float DomeMaxRadius = 15.0f; + + // Height-to-radius ratio (0.5 = flat, 1.0 = hemisphere, 1.5 = tall/gothic). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "0.3", ClampMax = "2.0")) + float DomeHeightRatio = 0.8f; + + + //========================================================================= + // PINCH PARAMS (visible when Type == Pinch) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pinch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pinch", + ClampMin = "0.0", ClampMax = "1.0")) + float PinchDensity = 0.15f; + + // How much the passage narrows (voxels of added density from each side). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pinch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pinch", + ClampMin = "1.0")) + float PinchStrength = 5.0f; + + // Length of the narrowed section along the passage. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pinch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pinch", + ClampMin = "3.0")) + float PinchLength = 12.0f; + + + //========================================================================= + // APPLY TO GENERATION PARAMS + //========================================================================= + + /** + * Copy this operation's parameters into a FStrateGenerationParams struct. + * Only the fields matching this operation's Type are written. + * Weight scales the primary "strength" or "density" field for intensity control. + * + * @param OutParams - The generation params to write into + * @param Weight - Multiplier for the op's intensity (1.0 = full strength) + */ + void ApplyTo(FStrateGenerationParams& OutParams, float Weight = 1.0f) const; +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelTilePostReach.h b/Standalone/Source/VoxelForge/Public/VoxelTilePostReach.h new file mode 100644 index 0000000..4abfe8e --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelTilePostReach.h @@ -0,0 +1,414 @@ +// Per-tile reach state for density posts. +// +// The marching-cubes worker installs this state once for the tile's lattice plus its one-point +// halo. Density helpers only read the bit that owns them. The inline TLS is intentional: the +// runtime hot path must not cross a DLL boundary for a single boolean load. + +#pragma once + +#include "CoreMinimal.h" +#include "HAL/PlatformTime.h" + +#include + +namespace VoxelGenLOD +{ + enum ETilePostReach : uint8 + { + OriginLandingReachable = 1u << 0, + PassageLandingReachable = 1u << 1, + PassageStructuralReachable= 1u << 2, + TunnelCoreReachable = 1u << 3, + PassageCarvingReachable = 1u << 4, + AllTilePostReach = OriginLandingReachable + | PassageLandingReachable + | PassageStructuralReachable + | TunnelCoreReachable + | PassageCarvingReachable, + AllTileReach = AllTilePostReach, + }; + + enum class ETilePostComparisonKind : uint8 + { + Other = 0, + OriginAir, + OriginFloor, + PassageLandingSDF, + PassageLandingAir, + PassageTunnelAir, + PassageLandingFloor, + PassageStructuralPosts, + PassageNativeFloor, + PassageLandingRoomFloor, + TunnelCore, + PassageCarving, + FinalField, + Count, + }; + + enum class ETileReachCostKind : uint8 + { + TunnelCoreWorld = 0, + TunnelCoreTail, + PassageCarving, + Count, + }; + + constexpr int32 TileReachBlockCells = 8; + constexpr int32 TileReachMaxBlocksPerAxis = 4; + constexpr int32 TileReachMaxBlockCount = + TileReachMaxBlocksPerAxis * TileReachMaxBlocksPerAxis * TileReachMaxBlocksPerAxis; + + FORCEINLINE FBox MakeTileReachBlockBox( + const FIntVector& TileOrigin, int32 Step, int32 CellsPerAxis, + int32 BlockX, int32 BlockY, int32 BlockZ) + { + const int32 SafeStep = FMath::Max(Step, 1); + const int32 MinX = BlockX * TileReachBlockCells; + const int32 MinY = BlockY * TileReachBlockCells; + const int32 MinZ = BlockZ * TileReachBlockCells; + const int32 MaxX = FMath::Min( + CellsPerAxis, MinX + TileReachBlockCells); + const int32 MaxY = FMath::Min( + CellsPerAxis, MinY + TileReachBlockCells); + const int32 MaxZ = FMath::Min( + CellsPerAxis, MinZ + TileReachBlockCells); + return FBox( + FVector( + static_cast(static_cast(TileOrigin.X) + MinX * SafeStep - SafeStep), + static_cast(static_cast(TileOrigin.Y) + MinY * SafeStep - SafeStep), + static_cast(static_cast(TileOrigin.Z) + MinZ * SafeStep - SafeStep)), + FVector( + static_cast(static_cast(TileOrigin.X) + MaxX * SafeStep), + static_cast(static_cast(TileOrigin.Y) + MaxY * SafeStep), + static_cast(static_cast(TileOrigin.Z) + MaxZ * SafeStep))); + } + + // A tile that is not being meshed, or a point query outside the mesher, must retain the + // canonical behaviour. GenerateMesh scopes this to its computed mask. + inline thread_local uint8 TilePostReachFlags = AllTileReach; + inline thread_local bool bTilePostReachDebug = false; + inline thread_local bool bTilePostReachBypass = false; + inline thread_local bool bTileReachDiagnosticTile = false; + inline thread_local bool bTileCoreReachDecisionRecorded = false; + inline thread_local float TileReachScale = 1.0f; + inline thread_local int32 TileReachBlocksPerAxis = 0; + inline thread_local int32 TileReachBlockIndex = INDEX_NONE; + inline thread_local bool bTileBlockReachValid = false; + inline thread_local uint8 TileBlockReachFlags[TileReachMaxBlockCount]{}; + // A false result is installed only after a cache was built with the whole mesher tile as its + // graph window. Chunk-local caches leave this true (the conservative no-skip fallback). + inline thread_local bool bTileCoreReachProofEnabled = false; + + // These are diagnostics only. They never participate in a field decision. + inline std::atomic GTilePostReachDebugEnabled { false }; + inline std::atomic GTilePostReachTileCount { 0 }; + inline std::atomic GOriginLandingReachableTiles { 0 }; + inline std::atomic GPassageLandingReachableTiles { 0 }; + inline std::atomic GPassageStructuralReachableTiles { 0 }; + inline std::atomic GOriginLandingSkippedTiles { 0 }; + inline std::atomic GPassageLandingSkippedTiles { 0 }; + inline std::atomic GPassageStructuralSkippedTiles { 0 }; + inline std::atomic GTunnelCoreReachableTiles { 0 }; + inline std::atomic GTunnelCoreSkippedTiles { 0 }; + inline std::atomic GPassageCarvingReachableTiles { 0 }; + inline std::atomic GPassageCarvingSkippedTiles { 0 }; + inline std::atomic GSkippedPostComparisons { 0 }; + inline std::atomic GSkippedPostDifferences { 0 }; + inline std::atomic GSkippedPostComparisonsByKind[ + static_cast(ETilePostComparisonKind::Count)]{}; + inline std::atomic GSkippedPostDifferencesByKind[ + static_cast(ETilePostComparisonKind::Count)]{}; + inline std::atomic GTileReachCostDiagnosticsEnabled { false }; + inline std::atomic GTileReachCostCalls[ + static_cast(ETileReachCostKind::Count)][2]{}; + inline std::atomic GTileReachCostCycles[ + static_cast(ETileReachCostKind::Count)][2]{}; + inline std::atomic GTileReachBlockCostCalls[ + static_cast(ETileReachCostKind::Count)][2]{}; + inline std::atomic GTileReachBlockCostCycles[ + static_cast(ETileReachCostKind::Count)][2]{}; + + // Automation tests deliberately run the real mesher twice (full reach and a shrunken reach) + // in one process. Keep the reset in the reach owner so a test cannot accidentally omit one + // of the per-kind counters and accept a vacuous proof. + FORCEINLINE void ResetTilePostReachDiagnostics() + { + GTilePostReachDebugEnabled.store(false, std::memory_order_relaxed); + GTilePostReachTileCount.store(0, std::memory_order_relaxed); + GOriginLandingReachableTiles.store(0, std::memory_order_relaxed); + GPassageLandingReachableTiles.store(0, std::memory_order_relaxed); + GPassageStructuralReachableTiles.store(0, std::memory_order_relaxed); + GOriginLandingSkippedTiles.store(0, std::memory_order_relaxed); + GPassageLandingSkippedTiles.store(0, std::memory_order_relaxed); + GPassageStructuralSkippedTiles.store(0, std::memory_order_relaxed); + GTunnelCoreReachableTiles.store(0, std::memory_order_relaxed); + GTunnelCoreSkippedTiles.store(0, std::memory_order_relaxed); + GPassageCarvingReachableTiles.store(0, std::memory_order_relaxed); + GPassageCarvingSkippedTiles.store(0, std::memory_order_relaxed); + GSkippedPostComparisons.store(0, std::memory_order_relaxed); + GSkippedPostDifferences.store(0, std::memory_order_relaxed); + for (uint8 Index = 0; + Index < static_cast(ETilePostComparisonKind::Count); ++Index) + { + GSkippedPostComparisonsByKind[Index].store(0, std::memory_order_relaxed); + GSkippedPostDifferencesByKind[Index].store(0, std::memory_order_relaxed); + } + GTileReachCostDiagnosticsEnabled.store(false, std::memory_order_relaxed); + for (uint8 Kind = 0; Kind < static_cast(ETileReachCostKind::Count); ++Kind) + { + for (uint8 Reach = 0; Reach < 2; ++Reach) + { + GTileReachCostCalls[Kind][Reach].store(0, std::memory_order_relaxed); + GTileReachCostCycles[Kind][Reach].store(0, std::memory_order_relaxed); + GTileReachBlockCostCalls[Kind][Reach].store(0, std::memory_order_relaxed); + GTileReachBlockCostCycles[Kind][Reach].store(0, std::memory_order_relaxed); + } + } + } + + FORCEINLINE bool IsTilePostReachable(uint8 Bit) + { + return (TilePostReachFlags & Bit) != 0; + } + + FORCEINLINE bool IsOriginLandingReachable() + { + return IsTilePostReachable(OriginLandingReachable); + } + + FORCEINLINE bool IsPassageLandingReachable() + { + return IsTilePostReachable(PassageLandingReachable); + } + + FORCEINLINE bool IsPassageStructuralReachable() + { + return IsTilePostReachable(PassageStructuralReachable); + } + + FORCEINLINE bool IsTunnelCoreReachable() + { + // Core's measured far share is negligible; keep its exact tile decision and avoid a + // second, weaker block geometry approximation. Passage carving is the only block-gated + // work because its tight chain produced a material far share in the pre-gate measure. + return IsTilePostReachable(TunnelCoreReachable); + } + + FORCEINLINE bool IsPassageCarvingReachable() + { + if (bTileBlockReachValid + && TileReachBlockIndex >= 0 + && TileReachBlockIndex < TileReachMaxBlockCount) + { + return (TileBlockReachFlags[TileReachBlockIndex] & PassageCarvingReachable) != 0; + } + return IsTilePostReachable(PassageCarvingReachable); + } + + FORCEINLINE bool IsTilePostReachBypassActive() + { + return bTilePostReachBypass; + } + + FORCEINLINE void RecordTileReachMask(uint8 Flags) + { + GTilePostReachTileCount.fetch_add(1, std::memory_order_relaxed); + if ((Flags & OriginLandingReachable) != 0) + { + GOriginLandingReachableTiles.fetch_add(1, std::memory_order_relaxed); + } + else + { + GOriginLandingSkippedTiles.fetch_add(1, std::memory_order_relaxed); + } + if ((Flags & PassageLandingReachable) != 0) + { + GPassageLandingReachableTiles.fetch_add(1, std::memory_order_relaxed); + } + else + { + GPassageLandingSkippedTiles.fetch_add(1, std::memory_order_relaxed); + } + if ((Flags & PassageStructuralReachable) != 0) + { + GPassageStructuralReachableTiles.fetch_add(1, std::memory_order_relaxed); + } + else + { + GPassageStructuralSkippedTiles.fetch_add(1, std::memory_order_relaxed); + } + if ((Flags & PassageCarvingReachable) != 0) + { + GPassageCarvingReachableTiles.fetch_add(1, std::memory_order_relaxed); + } + else + { + GPassageCarvingSkippedTiles.fetch_add(1, std::memory_order_relaxed); + } + } + + FORCEINLINE void RecordTunnelCoreReachDecision(bool bReachable) + { + if (bTileCoreReachDecisionRecorded) + { + return; + } + bTileCoreReachDecisionRecorded = true; + if (bReachable) + { + GTunnelCoreReachableTiles.fetch_add(1, std::memory_order_relaxed); + } + else + { + GTunnelCoreSkippedTiles.fetch_add(1, std::memory_order_relaxed); + } + } + + FORCEINLINE uint8 TileReachCostIndex(ETileReachCostKind Kind) + { + switch (Kind) + { + case ETileReachCostKind::TunnelCoreWorld: + case ETileReachCostKind::TunnelCoreTail: + return IsTilePostReachable(TunnelCoreReachable) ? 1u : 0u; + case ETileReachCostKind::PassageCarving: + return IsTilePostReachable(PassageCarvingReachable) ? 1u : 0u; + default: + return 1u; + } + } + + FORCEINLINE uint8 BlockReachCostIndex(ETileReachCostKind Kind) + { + switch (Kind) + { + case ETileReachCostKind::TunnelCoreWorld: + case ETileReachCostKind::TunnelCoreTail: + return IsTunnelCoreReachable() ? 1u : 0u; + case ETileReachCostKind::PassageCarving: + return IsPassageCarvingReachable() ? 1u : 0u; + default: + return 1u; + } + } + + FORCEINLINE void RecordTileReachBlockCost( + ETileReachCostKind Kind, uint8 ReachIndex, uint64 Cycles) + { + const uint8 KindIndex = static_cast(Kind); + if (KindIndex < static_cast(ETileReachCostKind::Count)) + { + GTileReachBlockCostCalls[KindIndex][ReachIndex].fetch_add( + 1, std::memory_order_relaxed); + GTileReachBlockCostCycles[KindIndex][ReachIndex].fetch_add( + Cycles, std::memory_order_relaxed); + } + } + + /** Inclusive timing split used for the pre-gate near/far measurement only. */ + struct FScopedReachCost + { + ETileReachCostKind Kind; + uint8 TileReachIndex = 1; + uint8 BlockReachIndex = 1; + uint64 StartCycles = 0; + bool bActive = false; + + explicit FScopedReachCost(ETileReachCostKind InKind) + : Kind(InKind) + { + if (!bTileReachDiagnosticTile + || !GTileReachCostDiagnosticsEnabled.load(std::memory_order_relaxed)) + { + return; + } + TileReachIndex = TileReachCostIndex(Kind); + BlockReachIndex = BlockReachCostIndex(Kind); + StartCycles = FPlatformTime::Cycles64(); + bActive = true; + } + + ~FScopedReachCost() + { + if (!bActive) + { + return; + } + const uint8 KindIndex = static_cast(Kind); + if (KindIndex < static_cast(ETileReachCostKind::Count)) + { + const uint64 Cycles = FPlatformTime::Cycles64() - StartCycles; + GTileReachCostCalls[KindIndex][TileReachIndex].fetch_add( + 1, std::memory_order_relaxed); + GTileReachCostCycles[KindIndex][TileReachIndex].fetch_add( + Cycles, + std::memory_order_relaxed); + if (bTileBlockReachValid + && TileReachBlockIndex >= 0 + && TileReachBlockIndex < TileReachMaxBlockCount) + { + RecordTileReachBlockCost(Kind, BlockReachIndex, Cycles); + } + } + } + }; + + FORCEINLINE bool SameFloatBits(float A, float B) + { + uint32 ABits = 0; + uint32 BBits = 0; + FMemory::Memcpy(&ABits, &A, sizeof(float)); + FMemory::Memcpy(&BBits, &B, sizeof(float)); + return ABits == BBits; + } + + FORCEINLINE void RecordSkippedPostComparison( + ETilePostComparisonKind Kind, bool bDifferent) + { + GSkippedPostComparisons.fetch_add(1, std::memory_order_relaxed); + const uint8 KindIndex = static_cast(Kind); + if (KindIndex < static_cast(ETilePostComparisonKind::Count)) + { + GSkippedPostComparisonsByKind[KindIndex].fetch_add( + 1, std::memory_order_relaxed); + } + if (bDifferent) + { + GSkippedPostDifferences.fetch_add(1, std::memory_order_relaxed); + if (KindIndex < static_cast(ETilePostComparisonKind::Count)) + { + GSkippedPostDifferencesByKind[KindIndex].fetch_add( + 1, std::memory_order_relaxed); + } + } + } + + FORCEINLINE void RecordSkippedPostComparison(bool bDifferent) + { + RecordSkippedPostComparison(ETilePostComparisonKind::Other, bDifferent); + } + + /** Run one skipped float post through its canonical body, then restore the fast-path value. */ + template + FORCEINLINE void CompareSkippedPost( + float& Density, CallableType Callable, + ETilePostComparisonKind Kind = ETilePostComparisonKind::Other) + { + if (!bTilePostReachDebug || bTilePostReachBypass) + { + return; + } + + const float Before = Density; + { + const bool PreviousBypass = bTilePostReachBypass; + bTilePostReachBypass = true; + Callable(); + bTilePostReachBypass = PreviousBypass; + } + RecordSkippedPostComparison(Kind, !SameFloatBits(Before, Density)); + Density = Before; + } + +} diff --git a/Standalone/Source/VoxelForge/Public/VoxelTypes.h b/Standalone/Source/VoxelForge/Public/VoxelTypes.h new file mode 100644 index 0000000..2de5030 --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelTypes.h @@ -0,0 +1,401 @@ +// VoxelTypes.h +// Core type definitions, coordinate conversions, and mesh data. +// +// Ce header est le socle du plugin — tout le monde l'inclut. +// Pas de dépendance sur des UClasses ici (on reste léger). + +#pragma once + +#include "CoreMinimal.h" + +//============================================================================= +// EXACT FINITE PREDICATE / PREDICAT FINI EXACT +//============================================================================= +// The CRT-backed FMath::IsFinite is an out-of-line call on the MSVC game path. A finite +// IEEE-754 value is identified entirely by its exponent bits, so this helper has the same +// boolean result for every bit pattern without doing a floating-point comparison. +namespace VoxelMath +{ + // Runtime policy switch. The fast helper below is deliberately independent of this value so + // the equivalence test can compare it directly with FMath::IsFinite. IsFinite() is the A/B + // entry point used by generation code. + VOXELFORGE_API extern int32 GFastIsFinite; + + FORCEINLINE bool IsFiniteFast(float Value) + { + static_assert(sizeof(float) == sizeof(uint32), "VoxelMath::IsFiniteFast requires IEEE float"); + uint32 Bits = 0; + FMemory::Memcpy(&Bits, &Value, sizeof(Bits)); + constexpr uint32 ExponentMask = 0x7F800000u; + return (Bits & ExponentMask) != ExponentMask; + } + + FORCEINLINE bool IsFiniteFast(double Value) + { + static_assert(sizeof(double) == sizeof(uint64), "VoxelMath::IsFiniteFast requires IEEE double"); + uint64 Bits = 0; + FMemory::Memcpy(&Bits, &Value, sizeof(Bits)); + constexpr uint64 ExponentMask = 0x7FF0000000000000ull; + return (Bits & ExponentMask) != ExponentMask; + } + + // Keep the switch at the call site while keeping the exact bit test separate and directly + // testable. In the default-on game path this inlines to the bit load/mask/branch; the 0 path + // is retained for within-round performance and sampler A/B runs. + FORCEINLINE bool IsFinite(float Value) + { + return GFastIsFinite != 0 ? IsFiniteFast(Value) : FMath::IsFinite(Value); + } + + FORCEINLINE bool IsFinite(double Value) + { + return GFastIsFinite != 0 ? IsFiniteFast(Value) : FMath::IsFinite(Value); + } + + // Deterministic trigonometry for world decisions. + // + // The implementation deliberately owns both the range reduction and the polynomial. It + // does not call the CRT, FMath, or a platform vector intrinsic: the same finite float is + // reduced by the same constants and evaluated by the same scalar arithmetic on every host. + // The reduction is done in double so world-coordinate phases do not lose all low bits before + // the period is removed. The polynomial itself is float arithmetic, matching the values + // consumed by the density field and keeping the hot path small. + FORCEINLINE void DetSinCos(float& OutSin, float& OutCos, float Value) + { + if (!IsFiniteFast(Value)) + { + // Non-finite values are outside the world contract. Preserve the input rather than + // entering an undefined integer conversion during range reduction. + OutSin = Value; + OutCos = Value; + return; + } + + constexpr double TwoPi = 6.283185307179586476925286766559; + constexpr double HalfPi = 1.57079632679489661923132169164; + + // Round the number of complete periods toward the nearest integer. All measured world + // phases are many orders of magnitude below INT64_MAX. For a pathological finite input + // whose period count does not fit in int64, use the exact integer significand from the + // float representation and reduce it by fixed binary doubling instead of converting an + // out-of-range quotient. + const double PeriodsReal = static_cast(Value) / TwoPi; + constexpr double MaxSafeInt64 = 9223372036854774784.0; + double Reduced = 0.0; + if (PeriodsReal >= MaxSafeInt64 || PeriodsReal <= -MaxSafeInt64) + { + uint32 ValueBits = 0; + FMemory::Memcpy(&ValueBits, &Value, sizeof(ValueBits)); + const uint32 MagnitudeBits = ValueBits & 0x7FFFFFFFu; + const uint32 ExponentBits = (MagnitudeBits >> 23) & 0xFFu; + const uint32 Significand = (MagnitudeBits & 0x007FFFFFu) | 0x00800000u; + const int32 BinaryExponent = static_cast(ExponentBits) - 127 - 23; + + double ReducedInteger = 0.0; + for (int32 Bit = 23; Bit >= 0; --Bit) + { + ReducedInteger *= 2.0; + ReducedInteger += static_cast((Significand >> Bit) & 1u); + if (ReducedInteger >= TwoPi) + { + ReducedInteger -= TwoPi; + } + } + for (int32 Shift = 0; Shift < BinaryExponent; ++Shift) + { + ReducedInteger *= 2.0; + if (ReducedInteger >= TwoPi) + { + ReducedInteger -= TwoPi; + } + } + Reduced = (ValueBits & 0x80000000u) != 0u ? -ReducedInteger : ReducedInteger; + } + else + { + int64 Periods = 0; + if (PeriodsReal >= 0.0) + { + Periods = static_cast(PeriodsReal + 0.5); + } + else + { + Periods = static_cast(PeriodsReal - 0.5); + } + + Reduced = static_cast(Value) + - static_cast(Periods) * TwoPi; + } + + // Reduce the remaining angle to [-pi/4, pi/4]. Both reduction branches above keep + // Reduced bounded by one period, so this quadrant conversion is always in int32 range. + const double QuadrantReal = Reduced / HalfPi; + int32 Quadrant = 0; + if (QuadrantReal >= 0.0) + { + Quadrant = static_cast(QuadrantReal + 0.5); + } + else + { + Quadrant = static_cast(QuadrantReal - 0.5); + } + Reduced -= static_cast(Quadrant) * HalfPi; + const float Y = static_cast(Reduced); + const float Y2 = Y * Y; + + // Fixed minimax polynomials, the same degree as UE's scalar SinCos implementation. The + // quarter-period reduction gives them a smaller interval than UE's [-pi/2, pi/2] path. + const float SinP = (((((-2.3889859e-08f * Y2 + 2.7525562e-06f) * Y2 + - 0.00019840874f) * Y2 + 0.0083333310f) * Y2 + - 0.16666667f) * Y2 + 1.0f) * Y; + const float CosP = (((((-2.6051615e-07f * Y2 + 2.4760495e-05f) * Y2 + - 0.0013888378f) * Y2 + 0.041666638f) * Y2 + - 0.5f) * Y2 + 1.0f); + + int32 QuadrantMod = Quadrant % 4; + if (QuadrantMod < 0) + { + QuadrantMod += 4; + } + switch (QuadrantMod) + { + case 0: + OutSin = SinP; + OutCos = CosP; + break; + case 1: + OutSin = CosP; + OutCos = -SinP; + break; + case 2: + OutSin = -SinP; + OutCos = -CosP; + break; + default: + OutSin = -CosP; + OutCos = SinP; + break; + } + } + + FORCEINLINE float DetSin(float Value) + { + float SinValue = 0.0f; + float CosValue = 0.0f; + DetSinCos(SinValue, CosValue, Value); + return SinValue; + } + + FORCEINLINE float DetCos(float Value) + { + float SinValue = 0.0f; + float CosValue = 0.0f; + DetSinCos(SinValue, CosValue, Value); + return CosValue; + } +} + +//============================================================================= +// CHUNK CONSTANTS +//============================================================================= +// +// Taille de chunk: 32^3 = 32 768 voxels. +// Pourquoi 32 ? Puissance de 2 → astuces bit à bit + bon alignement GPU. +// VOXEL_SIZE = 12.5 cm/voxel (Unreal travaille en centimètres). 1 chunk = 4 m. +// +// NOTE: 64³ coupe les draw calls (8× moins de chunks) mais rend le streaming trop +// saccadé (briques 8× plus lourdes → applies + spawns de contenu en gros à-coups sur +// le game thread). Le fps se règle côté RENDU (ombres off sur LOD lointain, voir +// ApplyMeshToChunk), pas via la taille de chunk. La taille de chunk reste le levier streaming-vs-draws si besoin. + +constexpr int32 CHUNK_SIZE = 32; + +constexpr float VOXEL_SIZE = 25.0f; + +//============================================================================= +// TRIVIAL-TILE CLASSIFICATION (T1.d) +//============================================================================= +// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ : +// AllSolid / AllAir guarantee every core cell vertex of the mesher (g=0..Cells; +// the ±1 halo is normal-only) is on the same side of the iso ⇒ empty mesh, GenerateMesh is +// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux +// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les +// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface +// échantillonnées au MÊME treillis que le mesher) + gardes conservatives sur +// tout ce qui peut creuser/remplir (spine, passages, disturbances, diff layer). +// +// Vit ici plutôt que dans VoxelGenerator.h pour que VoxelDensityOp.h (le contrat +// de la pile d'opérateurs) puisse s'en servir sans tirer un header UCLASS. +// Lives here rather than in VoxelGenerator.h so VoxelDensityOp.h (the operator-stack +// contract) can use it without pulling in a UCLASS header. +enum class EVoxelTileClass : uint8 +{ + Mixed, // peut contenir une surface → mesher normalement + AllSolid, // chaque échantillon prouvé solide → maillage vide + AllAir, // chaque échantillon prouvé air → maillage vide +}; + +//============================================================================= +// DENSITY → R8 QUANTIZATION (density clipmap / mini-sun shadows) +//============================================================================= +// +// MC convention: NÉGATIF = solide, POSITIF = air, 0 = isosurface. On encode la densité +// dans un R8 où SOLIDE = HAUT, AIR = BAS, iso ≈ 0.5 (128), clampé ±1 autour de la surface +// (loin de la surface ⇒ sature plein solide / plein air). Le trilinear garde la traversée +// iso sub-voxel nette. +// +// SHARED entre deux producteurs qui DOIVENT rester bit-identiques : +// 1. UVoxelDensityVolume (fill worker re-évaluant GetDensityAt), +// 2. UVoxelMarchingCubesMesher (capture-during-meshing : réutilise la grille déjà +// échantillonnée par le mesher au lieu de re-sampler — voir GenerateMesh OutCaptureGrid). +// Même densité d'entrée ⇒ même octet. Ne PAS dupliquer cette formule ailleurs. +FORCEINLINE uint8 VF_QuantizeDensity(float MCDensity) +{ + const float S = FMath::Clamp(0.5f - 0.5f * MCDensity, 0.0f, 1.0f); + return (uint8)FMath::RoundToInt(S * 255.0f); +} + +//============================================================================= +// FACE DIRECTIONS +//============================================================================= +// +// Les 6 faces d'un cube. Gardé ici car le mesher peut encore en avoir besoin +// (ex. normales orientées), et les strates pourraient s'en servir pour des +// décorations "sur le sol" vs "au plafond". + +enum class EVoxelFace : uint8 +{ + PositiveX, // East (+X) + NegativeX, // West (-X) + PositiveY, // North (+Y) + NegativeY, // South (-Y) + PositiveZ, // Up (+Z) + NegativeZ, // Down (-Z) +}; + +//============================================================================= +// COORDINATE CONVERSION +//============================================================================= +// +// Trois espaces: +// WORLD (FVector, cm) → position Unreal +// CHUNK (FIntVector) → quel chunk (32 voxels) +// LOCAL (FIntVector 0-31) → position dans le chunk +// +// Relation: WorldPos = (ChunkCoord * CHUNK_SIZE + LocalPos) * VOXEL_SIZE + +inline FIntVector WorldToChunkCoord(const FVector& WorldPos) +{ + // FloorToInt (pas division int) pour gérer proprement les coords négatives: + // -5 / 32 = 0 (faux — on veut -1) + // floor(-5 / 32) = floor(-0.156) = -1 (correct) + return FIntVector( + FMath::FloorToInt((WorldPos.X / VOXEL_SIZE) / CHUNK_SIZE), + FMath::FloorToInt((WorldPos.Y / VOXEL_SIZE) / CHUNK_SIZE), + FMath::FloorToInt((WorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE) + ); +} + +//============================================================================= +// MATH HELPERS (partagés entre générateur, morphologie, manager) +//============================================================================= + +// Smoothstep classique: 3x² - 2x³. Maps 0→0, 1→1, avec pente nulle aux bornes. +// Utilisé partout pour adoucir les transitions de densité (blend SDF, seal boundary). +// Entrée: x doit être dans [0, 1] (clamp avant si besoin). +inline float SmoothStep01(float x) +{ + return x * x * (3.0f - 2.0f * x); +} + +// Le coeur de bruit (VoxelNoise::Perlin3D, T2.a) renvoie ~[-0.8, 0.8] comme l'ancien +// FMath::PerlinNoise3D — ce facteur remet à ~[-1, 1] pour les formules de densité. +constexpr float VOXEL_NOISE_SCALE = 1.25f; + +//============================================================================= +// CLIPMAP TILE KEY +//============================================================================= +// +// A "tile" generalises a chunk for the chunked-LOD clipmap. A level-L tile spans +// (CHUNK_SIZE << Level) voxels per axis and is meshed at step (1 << Level), so it always +// produces a constant CHUNK_SIZE³-cell mesh (one component, one draw) but covers 8^Level × +// the volume. Level 0 = a full-resolution chunk; each level up doubles linear size. +// Streaming loads concentric shells: level 0 near the player, coarser levels farther out, +// so chunk/draw count stays ~flat regardless of view distance. + +struct FVoxelTileKey +{ + FIntVector Coord = FIntVector::ZeroValue; // in units of (CHUNK_SIZE << Level) voxels + int32 Level = 0; // 0 = full res + + FVoxelTileKey() = default; + FVoxelTileKey(const FIntVector& InCoord, int32 InLevel) : Coord(InCoord), Level(InLevel) {} + + // Cell size (sampling step) in voxels, and tile extent in voxels per axis. + int32 StepVoxels() const { return 1 << Level; } + int32 ExtentVoxels() const { return CHUNK_SIZE << Level; } + + // Min-corner of the tile in VOXEL coords (what the mesher takes). + FIntVector OriginVoxels() const { return Coord * (CHUNK_SIZE << Level); } + + // Tile centre in world cm (for distance sorting / LOD selection). + FVector CenterCm() const + { + const double Ext = (double)(CHUNK_SIZE << Level) * (double)VOXEL_SIZE; + return FVector((Coord.X + 0.5) * Ext, (Coord.Y + 0.5) * Ext, (Coord.Z + 0.5) * Ext); + } + + bool operator==(const FVoxelTileKey& O) const { return Level == O.Level && Coord == O.Coord; } + bool operator!=(const FVoxelTileKey& O) const { return !(*this == O); } +}; + +FORCEINLINE uint32 GetTypeHash(const FVoxelTileKey& K) +{ + return HashCombine(GetTypeHash(K.Coord), ::GetTypeHash(K.Level)); +} + +//============================================================================= +// MESH DATA +//============================================================================= +// +// Sortie du mesher: géométrie prête pour l'upload GPU. +// Struct plain C++ (pas de USTRUCT) — uniquement utilisé entre tâches C++. +// Si lighting est ajouté plus tard, ré-ajouter un champ Colors dédié. + +struct FVoxelMeshData +{ + TArray Vertices; // Positions monde + TArray Triangles; // Indices, 3 par triangle + TArray UVs; // Coords de texture (une par vertex) + TArray Normals; // Normale lissée (gradient de densité) + TArray Colors; // Masques matériau F6 (R=palette biome dominant, G=pente, + // B=poids de fondu de bordure, A=palette biome voisin) + // Number of vertices on the actual density isosurface. LOD seam skirts are appended after + // this prefix and must not be used as witnesses for terrain visibility audits. + int32 NumSurfaceVertices = 0; + + // Diagnostic-only count: skirt triangles are render geometry and are included in the RMC + // sections that currently request collision. This lets collision profiling quantify their + // share without changing the emitted indices. + int32 NumSkirtTriangles = 0; + + // F17 — classe de surface par TRIANGLE, portée par l'ORDRE du buffer d'indices : les + // NumCeilingTriangles DERNIERS triangles de Triangles sont la classe "plafond sky-cap" + // (polygroup 1 → slot matériau 1, sans ombre) ; tout ce qui précède est "sol/roche" + // (polygroup 0). Runs contigus exigés par RMC (une section par polygroup). + int32 NumCeilingTriangles = 0; + + void Clear() + { + Vertices.Empty(); + Triangles.Empty(); + UVs.Empty(); + Normals.Empty(); + Colors.Empty(); + NumSurfaceVertices = 0; + NumSkirtTriangles = 0; + NumCeilingTriangles = 0; + } + + bool IsEmpty() const { return Vertices.Num() == 0; } +}; diff --git a/Standalone/Source/VoxelForge/Public/VoxelWormField.h b/Standalone/Source/VoxelForge/Public/VoxelWormField.h new file mode 100644 index 0000000..68d113e --- /dev/null +++ b/Standalone/Source/VoxelForge/Public/VoxelWormField.h @@ -0,0 +1,26 @@ +// VoxelWormField.h +// Shared deterministic evaluation for the TunnelNetwork worm field. + +#pragma once + +#include "CoreMinimal.h" + +namespace VoxelWormField +{ + /** The scalar inputs used by both the fused and operator-stack evaluators. */ + struct FParameters + { + float Frequency = 0.0f; + float HorizontalBias = 0.0f; + float VerticalScale = 1.0f; + uint32 Seed = 0; + }; + + /** Evaluate the exact two-noise field, retaining the N1 threshold short-circuit. */ + VOXELFORGE_API float Evaluate( + float WorldX, + float WorldY, + float WorldZ, + float Threshold, + const FParameters& Parameters); +} diff --git a/Standalone/Source/WorldMesher.h b/Standalone/Source/WorldMesher.h new file mode 100644 index 0000000..d344f88 --- /dev/null +++ b/Standalone/Source/WorldMesher.h @@ -0,0 +1,179 @@ +#pragma once + +#include "OwnerWorldLoader.h" +#include "VoxelClipmapDesiredTiles.h" +#include "VoxelMarchingCubesMesher.h" + +#include +#include +#include + +struct FStandaloneCpuVertex { float Position[3]; float Normal[3]; }; + +struct FStandaloneCpuLevel +{ + std::vector Vertices; + std::vector Indices; + uint64 VertexCount = 0; + uint64 IndexCount = 0; + uint64 TileCount = 0; + uint64 NonEmptyTiles = 0; + uint64 DensitySamples = 0; + double Seconds = 0.0; +}; + +struct FStandaloneCpuClipmap +{ + std::array Levels; + FIntVector CenterChunk = FIntVector::ZeroValue; + int32 MinimumChunkZ = MIN_int32; + int32 MaximumChunkZ = MAX_int32; + int32 WorkerCount = 6; + double TotalSeconds = 0.0; + bool bCancelled = false; +}; + +namespace StandaloneWorldMesher +{ + using FClock = std::chrono::steady_clock; + + inline FIntVector CameraChunk(const FVector& PositionVoxels) + { + return FIntVector( + FMath::FloorToInt(PositionVoxels.X / CHUNK_SIZE), + FMath::FloorToInt(PositionVoxels.Y / CHUNK_SIZE), + FMath::FloorToInt(PositionVoxels.Z / CHUNK_SIZE)); + } + + inline FStandaloneCpuClipmap Build( + FStandaloneOwnerWorld& World, const FVector& CameraPositionVoxels, int32 WorkerCount = 6, + const std::function& OnLevelReady = {}, + bool bRetainMeshData = true, std::atomic* Cancel = nullptr) + { + FStandaloneCpuClipmap Output; + Output.WorkerCount = FMath::Clamp(WorkerCount,1,32); + Output.CenterChunk = CameraChunk(CameraPositionVoxels); + + bool bHasStrate = false; + int32 TopChunkZ = 0, BottomChunkZ = 0; + if (World.Settings->bClampViewToStrate) + bHasStrate = World.Manager->GetStrateChunkZBounds( + Output.CenterChunk.Z,TopChunkZ,BottomChunkZ); + VoxelClipmapDesiredTiles::ResolveVerticalBounds( + Output.CenterChunk.Z,World.Settings->ViewDistanceUp,World.Settings->ViewDistanceDown, + World.Settings->StrateViewMarginChunks,World.Settings->bClampViewToStrate,bHasStrate, + TopChunkZ,BottomChunkZ,Output.MinimumChunkZ,Output.MaximumChunkZ); + + VoxelClipmapDesiredTiles::FParameters Parameters; + Parameters.ClipRadius = World.ClipRadius; + Parameters.MaxClipLevel = FMath::Clamp(World.MaxClipLevel,0,8); + Parameters.RenderDistanceChunks = World.RenderDistanceChunks; + TArray AllTiles; + VoxelClipmapDesiredTiles::Build( + Output.CenterChunk,Output.MinimumChunkZ,Output.MaximumChunkZ,Parameters,AllTiles); + std::array,9> TilesByLevel; + for (const FVoxelTileKey& Tile : AllTiles) + TilesByLevel[static_cast(Tile.Level)].push_back(Tile); + + UVoxelMarchingCubesMesher::InitializeConsoleSwitches(); + const auto TotalStart = FClock::now(); + for (int32 Level = 0; Level <= Parameters.MaxClipLevel; ++Level) + { + if (Cancel && Cancel->load(std::memory_order_relaxed)) { Output.bCancelled = true; break; } + const auto LevelStart = FClock::now(); + const std::vector& Tiles = TilesByLevel[static_cast(Level)]; + FStandaloneCpuLevel& LevelOutput = Output.Levels[static_cast(Level)]; + LevelOutput.TileCount = Tiles.size(); + struct FTileResult { FVoxelMeshData Mesh; uint64 Samples = 0; }; + std::vector Results(Tiles.size()); + std::atomic Next{0}; + const int32 ThreadCount = FMath::Min(Output.WorkerCount, + FMath::Max(1,static_cast(Tiles.size()))); + std::vector Threads; + Threads.reserve(static_cast(ThreadCount)); + for (int32 Worker = 0; Worker < ThreadCount; ++Worker) + { + Threads.emplace_back([&]() + { + UVoxelMarchingCubesMesher Mesher; + Mesher.SetGenerator(World.Generator.get()); + Mesher.bGenerateSkirts = World.bGenerateSkirts; + Mesher.SkirtCells = static_cast(World.SkirtCells); + Mesher.LODOctaveDrop = World.LODOctaveDrop; + for (;;) + { + if (Cancel && Cancel->load(std::memory_order_relaxed)) break; + const size_t Index = Next.fetch_add(1,std::memory_order_relaxed); + if (Index >= Tiles.size()) break; + const FVoxelTileKey& Tile = Tiles[Index]; + const int32 Extent = Tile.ExtentVoxels(); + const int32 Cells = Level < World.FullResClipLevels + ? CHUNK_SIZE : FMath::Max(1,World.CoarseTileCells); + const int32 Step = FMath::Max(1,Extent / Cells); + int32 BandMin = MIN_int32, BandMax = MAX_int32; + if (World.StrateContentCutMinLevel < 9 + && Level >= World.StrateContentCutMinLevel && bHasStrate) + { + BandMin = BottomChunkZ * CHUNK_SIZE; + BandMax = (TopChunkZ + 1) * CHUNK_SIZE; + } + int64 Samples = 0; + Results[Index].Mesh = Mesher.GenerateMesh( + Tile.OriginVoxels(),Step,Cells,nullptr,BandMin,BandMax,&Samples); + Results[Index].Samples = static_cast(FMath::Max(0,Samples)); + } + }); + } + for (std::thread& Thread : Threads) Thread.join(); + if (Cancel && Cancel->load(std::memory_order_relaxed)) { Output.bCancelled = true; break; } + + size_t VertexCount = 0, IndexCount = 0; + for (const FTileResult& Result : Results) + { + if (!Result.Mesh.Triangles.IsEmpty()) ++LevelOutput.NonEmptyTiles; + VertexCount += static_cast(Result.Mesh.Vertices.Num()); + IndexCount += static_cast(Result.Mesh.Triangles.Num()); + LevelOutput.DensitySamples += Result.Samples; + } + LevelOutput.Vertices.reserve(VertexCount); + LevelOutput.Indices.reserve(IndexCount); + for (FTileResult& Result : Results) + { + const uint32 BaseVertex = static_cast(LevelOutput.Vertices.size()); + for (int32 I = 0; I < Result.Mesh.Vertices.Num(); ++I) + { + const FVector& P = Result.Mesh.Vertices[I]; + const FVector& N = Result.Mesh.Normals[I]; + LevelOutput.Vertices.push_back({ + {static_cast(P.X*0.01),static_cast(P.Y*0.01),static_cast(P.Z*0.01)}, + {static_cast(N.X),static_cast(N.Y),static_cast(N.Z)}}); + } + for (int32 Index : Result.Mesh.Triangles) + LevelOutput.Indices.push_back(BaseVertex + static_cast(Index)); + } + LevelOutput.Seconds = std::chrono::duration(FClock::now()-LevelStart).count(); + LevelOutput.VertexCount = LevelOutput.Vertices.size(); + LevelOutput.IndexCount = LevelOutput.Indices.size(); + std::printf("clip level %d: %.3f s, %llu tiles (%llu nonempty), %llu vertices, %.2f M density samples\n", + Level,LevelOutput.Seconds, + static_cast(LevelOutput.TileCount), + static_cast(LevelOutput.NonEmptyTiles), + static_cast(LevelOutput.VertexCount), + static_cast(LevelOutput.DensitySamples)/1.0e6); + std::fflush(stdout); + if (OnLevelReady) OnLevelReady(Level,std::move(LevelOutput)); + if (!bRetainMeshData) + { + FStandaloneCpuLevel& Metadata = Output.Levels[static_cast(Level)]; + Metadata.Vertices.clear(); + Metadata.Indices.clear(); + Metadata.Vertices.shrink_to_fit(); + Metadata.Indices.shrink_to_fit(); + } + } + Output.TotalSeconds = std::chrono::duration(FClock::now()-TotalStart).count(); + std::printf("clipmap total: %.3f s on %d workers, %zu tiles\n",Output.TotalSeconds, + Output.WorkerCount,static_cast(AllTiles.Num())); + return Output; + } +} diff --git a/Standalone/Source/parity_main.cpp b/Standalone/Source/parity_main.cpp new file mode 100644 index 0000000..2df0a79 --- /dev/null +++ b/Standalone/Source/parity_main.cpp @@ -0,0 +1,56 @@ +#include "OwnerWorldLoader.h" +#include "WorldMesher.h" + +#include +#include +#include + +int main(int argc, char** argv) +{ + const std::string DataPath = argc > 1 ? argv[1] : "Standalone/data/owner_world.json"; + FStandaloneOwnerWorld World; + std::string Error; + if (!StandaloneOwnerLoader::Load(DataPath,World,Error)) + { + std::cerr << "LOAD FAILED: " << Error << '\n'; + return 2; + } + std::cout << "Loaded owner world: seed=" << World.Seed + << " strates=" << World.Manager->GetNumStrates() + << " passages=" << World.Manager->GetPassages().Num() + << " samples=" << World.DensitySamples.size() << '\n'; + + struct FMismatch { size_t Index; float Actual; }; + std::vector Mismatches; + Mismatches.reserve(World.DensitySamples.size()); + std::map SampleKinds; + for (size_t I = 0; I < World.DensitySamples.size(); ++I) + { + const FStandaloneParitySample& Sample = World.DensitySamples[I]; + const float Actual = World.Generator->GetDensityAt( + static_cast(Sample.PositionVoxels.X), + static_cast(Sample.PositionVoxels.Y), + static_cast(Sample.PositionVoxels.Z)); + ++SampleKinds[Sample.Kind]; + if (std::bit_cast(Actual) != std::bit_cast(Sample.PluginDensity)) + Mismatches.push_back({I,Actual}); + } + std::cout << "Parity samples by kind:\n"; + for (const auto& [Kind,Count] : SampleKinds) std::cout << " " << Kind << ": " << Count << '\n'; + for (const FMismatch& M : Mismatches) + { + const FStandaloneParitySample& S = World.DensitySamples[M.Index]; + std::cerr << "MISMATCH index=" << M.Index << " kind=" << S.Kind + << " strate=" << S.StrateIndex << " at_voxels=(" + << S.PositionVoxels.X << ',' << S.PositionVoxels.Y << ',' << S.PositionVoxels.Z + << ") plugin=" << S.PluginDensity << " standalone=" << M.Actual + << " plugin_bits=0x" << std::hex << std::bit_cast(S.PluginDensity) + << " standalone_bits=0x" << std::bit_cast(M.Actual) << std::dec << '\n'; + } + std::cout << "Parity: " << (World.DensitySamples.size()-Mismatches.size()) << '/' + << World.DensitySamples.size() << " bit-identical; mismatches=" << Mismatches.size() << '\n'; + const FStandaloneCpuClipmap Clipmap = StandaloneWorldMesher::Build( + World,World.ViewerStartVoxels,6); + std::cout << "Clipmap build finished in " << Clipmap.TotalSeconds << " seconds.\n"; + return Mismatches.empty() ? 0 : 1; +} diff --git a/Standalone/data/owner_world.json b/Standalone/data/owner_world.json new file mode 100644 index 0000000..271647e --- /dev/null +++ b/Standalone/data/owner_world.json @@ -0,0 +1 @@ +{"schema_version":1,"source":"VoxelForgeExplore standalone base-world export","voxel_size_cm":25,"seed":0,"open_surface_entry":true,"origin_spine_radius":28,"world_radius_voxels":0,"edge_seal_thickness":128,"total_strates":2,"inter_strate_gap_chunks":0,"clip_radius":6,"max_clip_level":6,"full_res_clip_levels":2,"coarse_tile_cells":16,"render_distance_chunks":1536,"generate_skirts":true,"skirt_cells":4,"lod_octave_drop":1,"strate_content_cut_min_level":9,"settings_asset":{"ViewDistanceXY":32,"ViewDistanceUp":10,"ViewDistanceDown":10,"MaxConcurrentTasks":16,"MaxGenerationWorkerCores":0,"NearTaskReserve":4,"MaxMeshAppliesPerFrame":4,"MaxMeshApplyMilliseconds":2,"MaxUnloadsPerFrame":16,"PawnCollisionRadiusChunks":2,"bClampViewToStrate":true,"StrateViewMarginChunks":6,"StrateContentCutMinLevel":9,"ClipRadius":6,"MaxClipLevel":6,"FullResClipLevels":2,"CoarseTileCells":16,"RenderDistanceChunks":1536,"bGenerateSkirts":true,"SkirtCells":4,"LODOctaveDrop":1,"WorldRadiusVoxels":0,"EdgeSealThickness":128,"DecorationRadiusChunks":12,"DecorationNearRadiusChunks":6,"DecorationRegionSizeCells":8,"DecorationSpacingVoxels":8,"DecorationFarSpacingVoxels":8,"DecorationMarchStepVoxels":4,"DecorationMaxCrossingsPerColumn":4,"DecorationColumnDepthVoxels":320,"MaxDecorationCellsPerFrame":2,"MaxConcurrentDecorationTasks":4,"bEnableDensityVolume":false,"DensityVolumeResolution":256,"DensityVolumeLevels":3,"DensityVolumeFillSlabCells":8,"DensityVolumeMarchSteps":64,"bDensityVolumeGPUUpload":false,"bDebugDrawDensityVolume":false,"DensityVolumeDebugRadiusCells":12,"VoxelMaterial":"/Game/VoxelForge/M_Test1_Inst.M_Test1_Inst","Seed":0,"Season":"","CurrentSeason":1,"StratePool":["/Game/VoxelForge/DA_Strate1.DA_Strate1","/Game/VoxelForge/DA_Strate3.DA_Strate3"],"FixedStrates":[],"TotalStrates":2,"InterStrateGapChunks":0,"OriginSpineRadius":28,"bOpenSurfaceEntry":true,"MaxModifications":0,"MaxBrushRadius":30,"MaxTotalVolume":0,"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelSettings"},"strates":[{"index":0,"top_chunk_z":0,"bottom_chunk_z":-15,"height_chunks":16,"asset":{"StrateName":"","StrateDescription":"","StrateHeightInChunks":16,"TransitionType":0,"TransitionBlendChunks":2,"GeneratorType":0,"bEnableWorms":false,"GenerationParams":{"BaseDensity":8,"VerticalScale":0.80000001192092896,"WormFrequency":0.014999999664723873,"WormHorizontalBias":3,"WormThreshold":0.059999998658895493,"WormStrength":10,"WormNetworkRange":64,"RoomSpacing":128,"RoomDensity":0.34999999403953552,"MinRoomRadius":65,"MaxRoomRadius":120,"RoomHeightRatio":0.5,"RoomFloorCutMin":0,"RoomFloorCutMax":0,"FloorReliefStrength":0,"FloorReliefFrequency":1,"RoomMouthRiseStrength":0,"RoomMouthRiseBlendVoxels":12,"RoomShapeVariety":0.5,"OriginRoomRadius":65,"OriginRoomMaxConnections":4,"TunnelMinRadius":8,"TunnelMaxRadius":12,"TunnelDensity":0.40000000596046448,"MaxTunnelLength":360,"TunnelWarpStrength":24,"TunnelHorizontalBias":0.5,"bTunnelsFlowTowardOrigin":true,"TunnelEndpointZOffset":0.25,"bTunnelFloorEnabled":true,"bTunnelFloorTerracingEnabled":true,"TunnelFloorTerraceStepHeight":5,"TunnelFloorMaxLedgeHeight":24,"TunnelFloorGentleSlopeThreshold":88.090850830078125,"TunnelFloorLedgeCountPreference":0,"TunnelFloorMaxLedges":4096,"SDFBlendRadius":25,"CaveWarpStrength":16,"CaveWarpFrequency":0.014999999664723873,"SurfaceRoughness":1,"RoughnessFrequency":0.05000000074505806,"RoughnessNoiseType":2,"DomainWarpStrength":0,"DomainWarpFrequency":0.029999999329447746,"FloorBias":4,"BoundarySealThickness":8,"WaterLevelRelative":0},"SlabParams":{"FloorRelativeHeight":0.25,"CeilingRelativeHeight":0.60000002384185791,"FloorRoughness":4,"FloorRoughnessFrequency":0.039999999105930328,"CeilingRoughness":6,"CeilingRoughnessFrequency":0.039999999105930328,"ColumnDensity":0.079999998211860657,"ColumnMinRadius":8,"ColumnMaxRadius":16,"ColumnSpacing":96,"BoundarySealThickness":4,"BaseDensity":8},"MazeParams":{"CellSize":160,"CorridorRadius":24,"BranchProbability":1,"Verticality":0.20000000298023224,"SurfaceRoughness":2,"BoundarySealThickness":4,"BaseDensity":8},"SurfaceParams":{"BaseGroundRelative":0.25,"ElevationRange":80,"ContinentFrequency":0.0060000000521540642,"MountainStrength":0.5,"MountainFrequency":0.012000000104308128,"DetailFrequency":0.039999999105930328,"SurfaceRoughness":2,"HeightWarpStrength":48,"HeightWarpFrequency":0.0080000003799796104,"ReliefFrequency":0.001500000013038516,"ReliefStrength":0.69999998807907104,"ReliefContrast":1.6000000238418579,"TerraceStrength":0,"TerraceHeight":8,"TerraceHardness":0.5,"LayerLineDepth":0,"LayerLineSpacing":8,"CliffStrength":0,"CliffSlopeThreshold":0.5,"CliffSharpness":2,"CliffSampleDist":2,"OverhangStrength":0,"OverhangReach":16,"OverhangHeight":24,"OverhangFrequency":0.019999999552965164,"OverhangZScale":0.5,"OverhangSlopeThreshold":0.30000001192092896,"WaterLevelRelative":0.27000001072883606,"BeachWidth":12,"CeilingRelative":0.94999998807907104,"CeilingRoughness":6,"CeilingRoughnessFrequency":0.039999999105930328,"CeilingUndulation":0,"CeilingUndulationFrequency":0.0040000001899898052,"CeilingRidgeStrength":0,"CeilingRidgeFrequency":0.019999999552965164,"CeilingWarpStrength":0,"CeilingWarpFrequency":0.0099999997764825821,"BoundarySealThickness":4,"BaseDensity":8},"VerticalShaftParams":{"ShaftSpacing":80,"ShaftDensity":0.60000002384185791,"ShaftMinRadius":8,"ShaftMaxRadius":14,"CrossConnectChance":0.34999999403953552,"ConnectorRadius":6,"LedgeSpacing":32,"LedgeDepth":4,"SurfaceRoughness":2,"BoundarySealThickness":4,"BaseDensity":8},"FloatingIslandParams":{"IslandSpacing":112,"IslandDensity":0.5,"IslandMinRadius":24,"IslandMaxRadius":48,"ThicknessRatio":0.60000002384185791,"VerticalJitter":0.60000002384185791,"TopFlatten":0.60000002384185791,"SurfaceRoughness":3,"SDFBlendRadius":6,"BoundarySealThickness":4,"BaseDensity":8},"Biomes":[],"BiomeMapParams":{"CellSize":800,"WarpStrength":250,"WarpFrequency":0.0017999999690800905,"BorderBlend":120,"ReliefFrequency":0.00030000001424923539,"ReliefContrast":2,"MoistureFrequency":0.00039999998989515007},"Disturbances":{"ChasmDensity":0,"ChasmSpacing":192,"ChasmRadius":24,"BridgeDensity":0,"BridgeSpacing":128,"BridgeRadius":8,"RidgeDensity":0,"RidgeSpacing":128,"RidgeHeight":32,"RidgeThickness":8,"BaseDensity":8,"BoundarySealThickness":4},"PassageConfig":{"Connections":1,"Style":0,"MouthRadius":8,"MidRadius":6,"ReachMin":32,"ReachMax":500,"DistanceMin":64,"DistanceMax":192,"Wander":24,"Segments":20,"VerticalWobble":0,"SpiralRadius":24,"SpiralTurns":2,"CascadeSteps":4,"CascadeLedge":8},"bHasWater":false,"WaterMaterial":null,"WaterColor":{"R":0.019999999552965164,"G":0.079999998211860657,"B":0.11999999731779099,"A":0.80000001192092896},"TerrainOperations":[],"OverrideMaterial":"/Game/VoxelForge/M_Test_Inst.M_Test_Inst","CeilingMaterial":null,"FogColor":{"R":0.05000000074505806,"G":0.05000000074505806,"B":0.10000000149011612,"A":1},"FogDensity":0.30000001192092896,"AmbientLightColor":{"R":0.10000000149011612,"G":0.10000000149011612,"B":0.15000000596046448,"A":1},"AmbientLightIntensity":0.5,"bVolumetricFog":false,"AtmosphereActor":null,"CeilingLayerActor":null,"CeilingLayerZOffset":-200,"CeilingLayerRotation":{"Pitch":0,"Yaw":0,"Roll":0},"FloorLayerActor":null,"FloorLayerZOffset":200,"FloorLayerRotation":{"Pitch":0,"Yaw":0,"Roll":0},"Decorations":[{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":3,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":true},"StreamTier":0,"SpawnDensity":0.0010000000474974513,"MaxPerChunk":40,"Companions":[]}],"Landmarks":[],"AmbientActors":[],"Creatures":[],"AmbientSound":null,"Music":null,"MusicVolume":1,"GameplayTags":{"GameplayTags":[],"ParentTags":[]},"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelStrateDefinition"},"generation_params":{"BaseDensity":8,"VerticalScale":0.80000001192092896,"WormFrequency":0.014999999664723873,"WormHorizontalBias":3,"WormThreshold":0.059999998658895493,"WormStrength":10,"WormNetworkRange":64,"RoomSpacing":128,"RoomDensity":0.34999999403953552,"MinRoomRadius":65,"MaxRoomRadius":120,"RoomHeightRatio":0.5,"RoomFloorCutMin":0,"RoomFloorCutMax":0,"FloorReliefStrength":0,"FloorReliefFrequency":1,"RoomMouthRiseStrength":0,"RoomMouthRiseBlendVoxels":12,"RoomShapeVariety":0.5,"OriginRoomRadius":65,"OriginRoomMaxConnections":4,"TunnelMinRadius":8,"TunnelMaxRadius":12,"TunnelDensity":0.40000000596046448,"MaxTunnelLength":360,"TunnelWarpStrength":24,"TunnelHorizontalBias":0.5,"bTunnelsFlowTowardOrigin":true,"TunnelEndpointZOffset":0.25,"bTunnelFloorEnabled":true,"bTunnelFloorTerracingEnabled":true,"TunnelFloorTerraceStepHeight":5,"TunnelFloorMaxLedgeHeight":24,"TunnelFloorGentleSlopeThreshold":88.090850830078125,"TunnelFloorLedgeCountPreference":0,"TunnelFloorMaxLedges":4096,"SDFBlendRadius":25,"CaveWarpStrength":16,"CaveWarpFrequency":0.014999999664723873,"SurfaceRoughness":1,"RoughnessFrequency":0.05000000074505806,"RoughnessNoiseType":2,"DomainWarpStrength":0,"DomainWarpFrequency":0.029999999329447746,"FloorBias":4,"BoundarySealThickness":8,"WaterLevelRelative":0},"disturbances":{"ChasmDensity":0,"ChasmSpacing":192,"ChasmRadius":24,"BridgeDensity":0,"BridgeSpacing":128,"BridgeRadius":8,"RidgeDensity":0,"RidgeSpacing":128,"RidgeHeight":32,"RidgeThickness":8,"BaseDensity":8,"BoundarySealThickness":4},"passage_config":{"Connections":1,"Style":0,"MouthRadius":8,"MidRadius":6,"ReachMin":32,"ReachMax":500,"DistanceMin":64,"DistanceMax":192,"Wander":24,"Segments":20,"VerticalWobble":0,"SpiralRadius":24,"SpiralTurns":2,"CascadeSteps":4,"CascadeLedge":8},"terrain_operation_assets":[]},{"index":1,"top_chunk_z":-16,"bottom_chunk_z":-79,"height_chunks":64,"asset":{"StrateName":"Crystal Cave","StrateDescription":"","StrateHeightInChunks":64,"TransitionType":0,"TransitionBlendChunks":1,"GeneratorType":4,"bEnableWorms":true,"GenerationParams":{"BaseDensity":5,"VerticalScale":1,"WormFrequency":0.014999999664723873,"WormHorizontalBias":3,"WormThreshold":0.059999998658895493,"WormStrength":10,"WormNetworkRange":64,"RoomSpacing":128,"RoomDensity":0.34999999403953552,"MinRoomRadius":16,"MaxRoomRadius":40,"RoomHeightRatio":0.85000002384185791,"RoomFloorCutMin":0.69999998807907104,"RoomFloorCutMax":0.89999997615814209,"FloorReliefStrength":4,"FloorReliefFrequency":0.014999999664723873,"RoomMouthRiseStrength":0,"RoomMouthRiseBlendVoxels":8,"RoomShapeVariety":0.5,"OriginRoomRadius":48,"OriginRoomMaxConnections":4,"TunnelMinRadius":6,"TunnelMaxRadius":8,"TunnelDensity":0.40000000596046448,"MaxTunnelLength":360,"TunnelWarpStrength":24,"TunnelHorizontalBias":0.5,"bTunnelsFlowTowardOrigin":true,"TunnelEndpointZOffset":0.25,"bTunnelFloorEnabled":true,"bTunnelFloorTerracingEnabled":true,"TunnelFloorTerraceStepHeight":1.7100000381469727,"TunnelFloorMaxLedgeHeight":12,"TunnelFloorGentleSlopeThreshold":89.35406494140625,"TunnelFloorLedgeCountPreference":0,"TunnelFloorMaxLedges":4096,"SDFBlendRadius":4,"CaveWarpStrength":16,"CaveWarpFrequency":0.014999999664723873,"SurfaceRoughness":2,"RoughnessFrequency":0.10000000149011612,"RoughnessNoiseType":0,"DomainWarpStrength":0,"DomainWarpFrequency":0.029999999329447746,"FloorBias":4,"BoundarySealThickness":4,"WaterLevelRelative":0},"SlabParams":{"FloorRelativeHeight":0.25,"CeilingRelativeHeight":0.60000002384185791,"FloorRoughness":4,"FloorRoughnessFrequency":0.039999999105930328,"CeilingRoughness":6,"CeilingRoughnessFrequency":0.039999999105930328,"ColumnDensity":0.079999998211860657,"ColumnMinRadius":8,"ColumnMaxRadius":16,"ColumnSpacing":96,"BoundarySealThickness":4,"BaseDensity":8},"MazeParams":{"CellSize":64,"CorridorRadius":12.5,"BranchProbability":0.69999998807907104,"Verticality":0.30000001192092896,"SurfaceRoughness":2,"BoundarySealThickness":4,"BaseDensity":8},"SurfaceParams":{"BaseGroundRelative":0.20000000298023224,"ElevationRange":200,"ContinentFrequency":0.0010000000474974513,"MountainStrength":1,"MountainFrequency":0.0020000000949949026,"DetailFrequency":0.039999999105930328,"SurfaceRoughness":2,"HeightWarpStrength":48,"HeightWarpFrequency":0.0080000003799796104,"ReliefFrequency":0.001500000013038516,"ReliefStrength":0,"ReliefContrast":2,"TerraceStrength":0,"TerraceHeight":20,"TerraceHardness":0.5,"LayerLineDepth":0,"LayerLineSpacing":8,"CliffStrength":0,"CliffSlopeThreshold":0.5,"CliffSharpness":2,"CliffSampleDist":2,"OverhangStrength":0,"OverhangReach":16,"OverhangHeight":24,"OverhangFrequency":0.019999999552965164,"OverhangZScale":0.5,"OverhangSlopeThreshold":0.30000001192092896,"WaterLevelRelative":0.27000001072883606,"BeachWidth":200,"CeilingRelative":0.94999998807907104,"CeilingRoughness":6,"CeilingRoughnessFrequency":0.039999999105930328,"CeilingUndulation":0,"CeilingUndulationFrequency":0.0040000001899898052,"CeilingRidgeStrength":0,"CeilingRidgeFrequency":0.019999999552965164,"CeilingWarpStrength":0,"CeilingWarpFrequency":0.0099999997764825821,"BoundarySealThickness":50,"BaseDensity":8},"VerticalShaftParams":{"ShaftSpacing":80,"ShaftDensity":0.60000002384185791,"ShaftMinRadius":8,"ShaftMaxRadius":14,"CrossConnectChance":0.34999999403953552,"ConnectorRadius":6,"LedgeSpacing":32,"LedgeDepth":4,"SurfaceRoughness":2,"BoundarySealThickness":4,"BaseDensity":8},"FloatingIslandParams":{"IslandSpacing":112,"IslandDensity":0.5,"IslandMinRadius":24,"IslandMaxRadius":48,"ThicknessRatio":0.60000002384185791,"VerticalJitter":0.60000002384185791,"TopFlatten":0.60000002384185791,"SurfaceRoughness":3,"SDFBlendRadius":6,"BoundarySealThickness":4,"BaseDensity":8},"Biomes":[{"BiomeName":"","BiomeDescription":"","DebugColor":{"R":1,"G":0.77118802070617676,"B":0,"A":1},"ReliefMin":0,"ReliefMax":0.30000001192092896,"MoistureMin":0,"MoistureMax":0.30000001192092896,"bOverrideTerrain":true,"GeneratorType":4,"SurfaceParams":{"BaseGroundRelative":0.25,"ElevationRange":500,"ContinentFrequency":0.0010000000474974513,"MountainStrength":0,"MountainFrequency":0,"DetailFrequency":0.039999999105930328,"SurfaceRoughness":2,"HeightWarpStrength":48,"HeightWarpFrequency":0.0080000003799796104,"ReliefFrequency":9.9999997473787516e-05,"ReliefStrength":0.10000000149011612,"ReliefContrast":1.6000000238418579,"TerraceStrength":0,"TerraceHeight":8,"TerraceHardness":0.5,"LayerLineDepth":0,"LayerLineSpacing":8,"CliffStrength":0,"CliffSlopeThreshold":0.5,"CliffSharpness":2,"CliffSampleDist":2,"OverhangStrength":0,"OverhangReach":16,"OverhangHeight":24,"OverhangFrequency":0.019999999552965164,"OverhangZScale":0.5,"OverhangSlopeThreshold":0.30000001192092896,"WaterLevelRelative":0.27000001072883606,"BeachWidth":12,"CeilingRelative":0.94999998807907104,"CeilingRoughness":10,"CeilingRoughnessFrequency":0.0099999997764825821,"CeilingUndulation":1000,"CeilingUndulationFrequency":0.00019999999494757503,"CeilingRidgeStrength":0,"CeilingRidgeFrequency":0.019999999552965164,"CeilingWarpStrength":0,"CeilingWarpFrequency":0.0099999997764825821,"BoundarySealThickness":4,"BaseDensity":8},"Decorations":[{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":3,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":true},"StreamTier":0,"SpawnDensity":9.9999997473787516e-05,"MaxPerChunk":40,"Companions":[]},{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":3,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":true},"StreamTier":0,"SpawnDensity":9.9999997473787516e-05,"MaxPerChunk":40,"Companions":[]}],"AmbientActors":[],"bOverrideAtmosphere":false,"FogColor":{"R":0.78974199295043945,"G":0.796875,"B":0.56229102611541748,"A":1},"FogDensity":0.004999999888241291,"AmbientLightColor":{"R":0.77083301544189453,"G":0.76037901639938354,"B":0.12847200036048889,"A":1},"AmbientLightIntensity":0.5,"WaterMaterial":null,"MaterialPaletteIndex":1,"GameplayTags":{"GameplayTags":[],"ParentTags":[]},"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelBiomeDefinition"},{"BiomeName":"","BiomeDescription":"","DebugColor":{"R":0.5,"G":0.5,"B":0.5,"A":1},"ReliefMin":0.80000001192092896,"ReliefMax":1,"MoistureMin":0.80000001192092896,"MoistureMax":1,"bOverrideTerrain":true,"GeneratorType":4,"SurfaceParams":{"BaseGroundRelative":0.31000000238418579,"ElevationRange":500,"ContinentFrequency":0.0010000000474974513,"MountainStrength":1,"MountainFrequency":0.00050000002374872565,"DetailFrequency":0.0099999997764825821,"SurfaceRoughness":2,"HeightWarpStrength":48,"HeightWarpFrequency":0.0099999997764825821,"ReliefFrequency":0.001500000013038516,"ReliefStrength":0.69999998807907104,"ReliefContrast":1.6000000238418579,"TerraceStrength":0,"TerraceHeight":1000,"TerraceHardness":0.5,"LayerLineDepth":0,"LayerLineSpacing":8,"CliffStrength":1,"CliffSlopeThreshold":0.30000001192092896,"CliffSharpness":5,"CliffSampleDist":20,"OverhangStrength":1,"OverhangReach":200000,"OverhangHeight":500000,"OverhangFrequency":100000,"OverhangZScale":10000,"OverhangSlopeThreshold":0.30000001192092896,"WaterLevelRelative":0.27000001072883606,"BeachWidth":12,"CeilingRelative":0.94999998807907104,"CeilingRoughness":10,"CeilingRoughnessFrequency":0.0099999997764825821,"CeilingUndulation":900,"CeilingUndulationFrequency":0.00019999999494757503,"CeilingRidgeStrength":0,"CeilingRidgeFrequency":0.019999999552965164,"CeilingWarpStrength":0,"CeilingWarpFrequency":0.0099999997764825821,"BoundarySealThickness":4,"BaseDensity":8},"Decorations":[{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":3,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":true},"StreamTier":0,"SpawnDensity":0.10000000149011612,"MaxPerChunk":40,"Companions":[]}],"AmbientActors":[],"bOverrideAtmosphere":false,"FogColor":{"R":0.05000000074505806,"G":0.05000000074505806,"B":0.10000000149011612,"A":1},"FogDensity":0.30000001192092896,"AmbientLightColor":{"R":0.10000000149011612,"G":0.10000000149011612,"B":0.15000000596046448,"A":1},"AmbientLightIntensity":0.5,"WaterMaterial":null,"MaterialPaletteIndex":0,"GameplayTags":{"GameplayTags":[],"ParentTags":[]},"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelBiomeDefinition"},{"BiomeName":"","BiomeDescription":"","DebugColor":{"R":0.056442998349666595,"G":0.5,"B":0.015007000416517258,"A":1},"ReliefMin":0.10000000149011612,"ReliefMax":0.40000000596046448,"MoistureMin":0.5,"MoistureMax":0.80000001192092896,"bOverrideTerrain":true,"GeneratorType":4,"SurfaceParams":{"BaseGroundRelative":0.25,"ElevationRange":80,"ContinentFrequency":0.0030000000260770321,"MountainStrength":0,"MountainFrequency":0,"DetailFrequency":0.039999999105930328,"SurfaceRoughness":2,"HeightWarpStrength":65,"HeightWarpFrequency":0.0080000003799796104,"ReliefFrequency":0,"ReliefStrength":0,"ReliefContrast":0.25,"TerraceStrength":0,"TerraceHeight":1,"TerraceHardness":0.5,"LayerLineDepth":0,"LayerLineSpacing":8,"CliffStrength":0,"CliffSlopeThreshold":0.5,"CliffSharpness":2,"CliffSampleDist":2,"OverhangStrength":0,"OverhangReach":16,"OverhangHeight":24,"OverhangFrequency":0.019999999552965164,"OverhangZScale":0.5,"OverhangSlopeThreshold":0.30000001192092896,"WaterLevelRelative":0,"BeachWidth":12,"CeilingRelative":0.94999998807907104,"CeilingRoughness":10,"CeilingRoughnessFrequency":0.0099999997764825821,"CeilingUndulation":1000,"CeilingUndulationFrequency":0.00019999999494757503,"CeilingRidgeStrength":0,"CeilingRidgeFrequency":0.019999999552965164,"CeilingWarpStrength":0,"CeilingWarpFrequency":0.0099999997764825821,"BoundarySealThickness":4,"BaseDensity":8},"Decorations":[{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":0,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":40,"Yaw":360,"Roll":40},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":false},"StreamTier":1,"SpawnDensity":0.5,"MaxPerChunk":10000,"Companions":[]},{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":3,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":true},"StreamTier":1,"SpawnDensity":0.30000001192092896,"MaxPerChunk":1000,"Companions":[]},{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":3,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":0.80000001192092896,"MaxScale":1.2000000476837158,"CullDistance":0,"bCastShadow":true},"StreamTier":0,"SpawnDensity":0.00079999997979030013,"MaxPerChunk":40,"Companions":[{"Profile":{"ActorClass":null,"InstancedMesh":null,"SurfacePlacement":0,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":-51,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":360,"Roll":0},"MinScale":3,"MaxScale":5,"CullDistance":0,"bCastShadow":true},"RadiusMinVox":25,"RadiusMaxVox":50,"CountMin":0,"CountMax":2,"Probability":1,"bSnapToSurface":true,"SubCompanions":[]}]}],"AmbientActors":[],"bOverrideAtmosphere":false,"FogColor":{"R":0.45410200953483582,"G":0.78724199533462524,"B":0.9375,"A":1},"FogDensity":0.004999999888241291,"AmbientLightColor":{"R":0.88179701566696167,"G":1,"B":0.74735897779464722,"A":1},"AmbientLightIntensity":0.5,"WaterMaterial":null,"MaterialPaletteIndex":0,"GameplayTags":{"GameplayTags":[],"ParentTags":[]},"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelBiomeDefinition"},{"BiomeName":"","BiomeDescription":"","DebugColor":{"R":0.5,"G":0.5,"B":0.5,"A":1},"ReliefMin":0.99000000953674316,"ReliefMax":1,"MoistureMin":0.80000001192092896,"MoistureMax":1,"bOverrideTerrain":true,"GeneratorType":4,"SurfaceParams":{"BaseGroundRelative":0.5,"ElevationRange":80,"ContinentFrequency":0.0010000000474974513,"MountainStrength":1,"MountainFrequency":0.00050000002374872565,"DetailFrequency":0.0099999997764825821,"SurfaceRoughness":2,"HeightWarpStrength":48,"HeightWarpFrequency":0.0099999997764825821,"ReliefFrequency":0.001500000013038516,"ReliefStrength":0.69999998807907104,"ReliefContrast":1.6000000238418579,"TerraceStrength":1,"TerraceHeight":8,"TerraceHardness":0.5,"LayerLineDepth":0,"LayerLineSpacing":8,"CliffStrength":0,"CliffSlopeThreshold":0.5,"CliffSharpness":2,"CliffSampleDist":2,"OverhangStrength":0,"OverhangReach":16,"OverhangHeight":24,"OverhangFrequency":0.019999999552965164,"OverhangZScale":0.5,"OverhangSlopeThreshold":0.30000001192092896,"WaterLevelRelative":0.27000001072883606,"BeachWidth":12,"CeilingRelative":0.94999998807907104,"CeilingRoughness":10,"CeilingRoughnessFrequency":0.0099999997764825821,"CeilingUndulation":900,"CeilingUndulationFrequency":0.00019999999494757503,"CeilingRidgeStrength":0,"CeilingRidgeFrequency":0.019999999552965164,"CeilingWarpStrength":0,"CeilingWarpFrequency":0.0099999997764825821,"BoundarySealThickness":4,"BaseDensity":8},"Decorations":[],"AmbientActors":[],"bOverrideAtmosphere":false,"FogColor":{"R":0.05000000074505806,"G":0.05000000074505806,"B":0.10000000149011612,"A":1},"FogDensity":0.30000001192092896,"AmbientLightColor":{"R":0.10000000149011612,"G":0.10000000149011612,"B":0.15000000596046448,"A":1},"AmbientLightIntensity":0.5,"WaterMaterial":null,"MaterialPaletteIndex":0,"GameplayTags":{"GameplayTags":[],"ParentTags":[]},"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelBiomeDefinition"}],"BiomeMapParams":{"CellSize":800,"WarpStrength":250,"WarpFrequency":0.0017999999690800905,"BorderBlend":120,"ReliefFrequency":0.00030000001424923539,"ReliefContrast":2,"MoistureFrequency":0.00039999998989515007},"Disturbances":{"ChasmDensity":0,"ChasmSpacing":192,"ChasmRadius":24,"BridgeDensity":0,"BridgeSpacing":128,"BridgeRadius":8,"RidgeDensity":0,"RidgeSpacing":128,"RidgeHeight":32,"RidgeThickness":8,"BaseDensity":8,"BoundarySealThickness":4},"PassageConfig":{"Connections":1,"Style":0,"MouthRadius":10,"MidRadius":8,"ReachMin":32,"ReachMax":5000,"DistanceMin":64,"DistanceMax":300,"Wander":250,"Segments":25,"VerticalWobble":200,"SpiralRadius":1,"SpiralTurns":3,"CascadeSteps":16,"CascadeLedge":8},"bHasWater":true,"WaterMaterial":"/DatasmithContent/Materials/Water/M_Water.M_Water","WaterColor":{"R":0.019999999552965164,"G":0.079999998211860657,"B":0.11999999731779099,"A":0.80000001192092896},"TerrainOperations":[{"Operation":"/Game/VoxelForge/Blocks/NewDataAsset.NewDataAsset","Weight":1,"Probability":1}],"OverrideMaterial":null,"CeilingMaterial":"/Game/VoxelForge/M_Test.M_Test","FogColor":{"R":0.2926819920539856,"G":0.390625,"B":0.37241899967193604,"A":1},"FogDensity":0,"AmbientLightColor":{"R":0.29948800802230835,"G":0.42012500762939453,"B":0.47916701436042786,"A":1},"AmbientLightIntensity":0,"bVolumetricFog":false,"AtmosphereActor":"/Game/VoxelForge/AmbientAtmos/BP_SurfaceLikeAtmos.BP_SurfaceLikeAtmos_C","CeilingLayerActor":null,"CeilingLayerZOffset":-685.83612060546875,"CeilingLayerRotation":{"Pitch":0,"Yaw":0,"Roll":0},"FloorLayerActor":null,"FloorLayerZOffset":200,"FloorLayerRotation":{"Pitch":0,"Yaw":0,"Roll":0},"Decorations":[],"Landmarks":[{"Profile":{"ActorClass":null,"InstancedMesh":"/Game/Darkos/SM_MiniSun.SM_MiniSun","SurfacePlacement":2,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[{"Type":0,"Min":0,"Max":0.25,"bInvert":false},{"Type":1,"Min":0.60000002384185791,"Max":1,"bInvert":false}],"bAlignToSurface":false,"SurfaceOffset":0,"LocationOffset":{"X":0,"Y":0,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":0,"Roll":0},"MinScale":25,"MaxScale":50,"CullDistance":0,"bCastShadow":true},"AnchorMode":0,"SpacingChunks":64,"JitterFraction":0.5,"SpawnProbability":1,"bAtDescentMouths":true,"bAtArrivalMouths":true,"MouthProbability":1,"StreamRadiusChunks":256,"ExclusionRadiusChunks":0,"Priority":0,"bSuppressDecorationsUnder":false,"SuppressRadiusChunks":2,"bIsLightOrb":true,"OrbColor":{"R":1,"G":0.94999998807907104,"B":0.85000002384185791,"A":1},"OrbIntensity":3,"OrbRadiusVoxels":16,"OrbFalloffVoxels":2000,"OrbMaxShadowDistanceVoxels":1000},{"Profile":{"ActorClass":null,"InstancedMesh":"/Game/Magic_Forest/Foliage/Main_Tree/Static_Meshes/SM_Main_Magic_Tree.SM_Main_Magic_Tree","SurfacePlacement":0,"MaxSlopeAngle":90,"MinSlopeAngle":0,"bWallExcludeOverhangs":false,"bRequireWaterRelative":false,"bPlaceBelowWater":false,"RequiredBiome":null,"Conditions":[],"bAlignToSurface":true,"SurfaceOffset":-200,"LocationOffset":{"X":500,"Y":500,"Z":0},"RotationOffset":{"Pitch":0,"Yaw":0,"Roll":0},"RandomRotation":{"Pitch":0,"Yaw":0,"Roll":0},"MinScale":1,"MaxScale":1,"CullDistance":0,"bCastShadow":true},"AnchorMode":1,"SpacingChunks":64,"JitterFraction":0.5,"SpawnProbability":1,"bAtDescentMouths":true,"bAtArrivalMouths":false,"MouthProbability":1,"StreamRadiusChunks":256,"ExclusionRadiusChunks":0,"Priority":0,"bSuppressDecorationsUnder":true,"SuppressRadiusChunks":2,"bIsLightOrb":false,"OrbColor":{"R":1,"G":0.94999998807907104,"B":0.85000002384185791,"A":1},"OrbIntensity":3,"OrbRadiusVoxels":16,"OrbFalloffVoxels":2000,"OrbMaxShadowDistanceVoxels":1000}],"AmbientActors":[],"Creatures":[],"AmbientSound":null,"Music":null,"MusicVolume":1,"GameplayTags":{"GameplayTags":[],"ParentTags":[]},"AssetBundleData":{"Bundles":[]},"NativeClass":"/Script/VoxelForge.VoxelStrateDefinition"},"generation_params":{"BaseDensity":5,"VerticalScale":1,"WormFrequency":0.014999999664723873,"WormHorizontalBias":3,"WormThreshold":0.059999998658895493,"WormStrength":10,"WormNetworkRange":64,"RoomSpacing":128,"RoomDensity":0.34999999403953552,"MinRoomRadius":16,"MaxRoomRadius":40,"RoomHeightRatio":0.85000002384185791,"RoomFloorCutMin":0.69999998807907104,"RoomFloorCutMax":0.89999997615814209,"FloorReliefStrength":4,"FloorReliefFrequency":0.014999999664723873,"RoomMouthRiseStrength":0,"RoomMouthRiseBlendVoxels":8,"RoomShapeVariety":0.5,"OriginRoomRadius":48,"OriginRoomMaxConnections":4,"TunnelMinRadius":6,"TunnelMaxRadius":8,"TunnelDensity":0.40000000596046448,"MaxTunnelLength":360,"TunnelWarpStrength":24,"TunnelHorizontalBias":0.5,"bTunnelsFlowTowardOrigin":true,"TunnelEndpointZOffset":0.25,"bTunnelFloorEnabled":true,"bTunnelFloorTerracingEnabled":true,"TunnelFloorTerraceStepHeight":1.7100000381469727,"TunnelFloorMaxLedgeHeight":12,"TunnelFloorGentleSlopeThreshold":89.35406494140625,"TunnelFloorLedgeCountPreference":0,"TunnelFloorMaxLedges":4096,"SDFBlendRadius":4,"CaveWarpStrength":16,"CaveWarpFrequency":0.014999999664723873,"SurfaceRoughness":2,"RoughnessFrequency":0.10000000149011612,"RoughnessNoiseType":0,"DomainWarpStrength":0,"DomainWarpFrequency":0.029999999329447746,"FloorBias":4,"BoundarySealThickness":4,"WaterLevelRelative":0},"disturbances":{"ChasmDensity":0,"ChasmSpacing":192,"ChasmRadius":24,"BridgeDensity":0,"BridgeSpacing":128,"BridgeRadius":8,"RidgeDensity":0,"RidgeSpacing":128,"RidgeHeight":32,"RidgeThickness":8,"BaseDensity":8,"BoundarySealThickness":4},"passage_config":{"Connections":1,"Style":0,"MouthRadius":10,"MidRadius":8,"ReachMin":32,"ReachMax":5000,"DistanceMin":64,"DistanceMax":300,"Wander":250,"Segments":25,"VerticalWobble":200,"SpiralRadius":1,"SpiralTurns":3,"CascadeSteps":16,"CascadeLedge":8},"terrain_operation_assets":[{"weight":1,"probability":1,"asset_path":"/Game/VoxelForge/Blocks/NewDataAsset.NewDataAsset"}]}],"passages":[{"upper_strate_index":0,"lower_strate_index":1,"upper_point":{"x":-156.75164794921875,"y":25.093801498413086,"z":-114.03872680664062},"lower_point":{"x":-156.75164794921875,"y":25.093801498413086,"z":-823.67364501953125},"requested_upper_point":{"x":-156.75164794921875,"y":25.093801498413086,"z":-114.03872680664062},"requested_lower_point":{"x":-156.75164794921875,"y":25.093801498413086,"z":-823.67364501953125},"radius":8,"passage_type":0,"control_points":[{"x":-148.75058787323334,"y":29.214849624857678,"z":-106.53872680664062},{"x":-131.85946104615306,"y":37.914840114018489,"z":-106.53872680664062},{"x":-126.52542099549612,"y":40.662205531648219,"z":-106.53872680664062},{"x":1050.6943532215714,"y":647.0042775668071,"z":-463.356201171875},{"x":1047.0953044633411,"y":653.99187015186294,"z":-463.356201171875},{"x":-126.54174773727289,"y":40.693675331255868,"z":-816.17364501953125},{"x":-131.85946104615306,"y":37.914840114018489,"z":-816.17364501953125},{"x":-148.75058787323334,"y":29.214849624857678,"z":-816.17364501953125}],"control_radii":[8,8,8,6,6,8,8,8],"upper_landing":{"standing_point":{"x":-156.75164794921875,"y":25.093801498413086,"z":-114.03872680664062},"door_point":{"x":-148.75058787323334,"y":29.214849624857678,"z":-106.53872680664062},"door_direction":{"x":0.88900667510948894,"y":0.45789423627162151,"z":0},"floor_z":-114.53872680664062,"ceiling_z":-96.538726806640625,"half_width":10,"floor_thickness":3,"source_player_fit":false},"lower_landing":{"standing_point":{"x":-156.75164794921875,"y":25.093801498413086,"z":-823.67364501953125},"door_point":{"x":-148.75058787323334,"y":29.214849624857678,"z":-816.17364501953125},"door_direction":{"x":0.88900667510948894,"y":0.45789423627162151,"z":0},"floor_z":-824.17364501953125,"ceiling_z":-806.17364501953125,"half_width":10,"floor_thickness":3,"source_player_fit":false},"walkable_tunnel_contract":true,"native_floor_enabled":true,"native_floor_profile_z":[-114.53872680664062,-114.53872680664062,-114.53872680664062,-469.356201171875,-469.356201171875,-824.17364501953125,-824.17364501953125,-824.17364501953125],"native_floor_relief_scales":[0,0,0,0,0,0,0],"native_floor_relief_strength":0,"native_floor_relief_frequency":1,"native_floor_seed":3757890905,"tunnel_max_gradient_degrees":15,"tunnel_vertical_drop_voxels":709.6348876953125,"tunnel_required_horizontal_run_voxels":2648.3935546875,"tunnel_horizontal_path_length_voxels":2706.27685546875,"tunnel_minimum_clear_width_voxels":12,"tunnel_clear_height_voxels":12,"bound_center":{"x":441.08982568915485,"y":333.66130887811653,"z":-460.35618591308594},"bound_radius":800.099365234375,"bound_radius_sq":640159},{"upper_strate_index":0,"lower_strate_index":0,"upper_point":{"x":0,"y":0,"z":64},"lower_point":{"x":0,"y":0,"z":48},"requested_upper_point":{"x":0,"y":0,"z":0},"requested_lower_point":{"x":0,"y":0,"z":0},"radius":28,"passage_type":1,"control_points":[{"x":0,"y":0,"z":64},{"x":0,"y":0,"z":48}],"control_radii":[28,28],"upper_landing":{"standing_point":{"x":0,"y":0,"z":0},"door_point":{"x":0,"y":0,"z":0},"door_direction":{"x":1,"y":0,"z":0},"floor_z":0,"ceiling_z":0,"half_width":0,"floor_thickness":3,"source_player_fit":false},"lower_landing":{"standing_point":{"x":0,"y":0,"z":0},"door_point":{"x":0,"y":0,"z":0},"door_direction":{"x":1,"y":0,"z":0},"floor_z":0,"ceiling_z":0,"half_width":0,"floor_thickness":3,"source_player_fit":false},"walkable_tunnel_contract":false,"native_floor_enabled":false,"native_floor_profile_z":[],"native_floor_relief_scales":[],"native_floor_relief_strength":0,"native_floor_relief_frequency":0.014999999664723873,"native_floor_seed":0,"tunnel_max_gradient_degrees":0,"tunnel_vertical_drop_voxels":0,"tunnel_required_horizontal_run_voxels":0,"tunnel_horizontal_path_length_voxels":0,"tunnel_minimum_clear_width_voxels":56,"tunnel_clear_height_voxels":56,"bound_center":{"x":0,"y":0,"z":56},"bound_radius":40,"bound_radius_sq":1600}],"density_samples":[{"x":0,"y":0,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":-482,"density":-100.4000244140625,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":-482,"density":-100.471435546875,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":-482,"density":-100.47076416015625,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":-482,"density":-65.533935546875,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":-482,"density":-139.97222900390625,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":-482,"density":-253.8656005859375,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":-482,"density":-28.525909423828125,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":-482,"density":-255.7069091796875,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":-482,"density":-336.91278076171875,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":-482,"density":-100.6134033203125,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":-482,"density":-100.6846923828125,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":-482,"density":-100.54266357421875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":-482,"density":-65.623291015625,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":-482,"density":-140.11248779296875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":-482,"density":-254.0938720703125,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":-482,"density":-28.7703857421875,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":-482,"density":-255.72296142578125,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":-482,"density":-337.09698486328125,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":-482,"density":-100.61566162109375,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":-482,"density":-100.5440673828125,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":30,"density":28,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":-482,"density":-100.6864013671875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":-482,"density":-65.44451904296875,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":-482,"density":-139.8338623046875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":-482,"density":-253.639892578125,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":-482,"density":-28.282745361328125,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":-482,"density":-255.690673828125,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":-482,"density":-336.729248046875,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":-482,"density":-105.41595458984375,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":-482,"density":-105.3905029296875,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":-482,"density":-105.44207763671875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":-482,"density":-68.25933837890625,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":-482,"density":-140.77899169921875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":-482,"density":-261.48785400390625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":-482,"density":-61.25494384765625,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":-482,"density":-252.66650390625,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":-482,"density":-346.35992431640625,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":-482,"density":-100.9278564453125,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":-482,"density":-100.89337158203125,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":-482,"density":-100.961669921875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":-482,"density":-65.8758544921875,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":-482,"density":-136.55438232421875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":-482,"density":-246.763916015625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":-482,"density":-260.72271728515625,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":-482,"density":-329.520751953125,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":-482,"density":-132.43231201171875,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":-482,"density":-132.363525390625,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":-482,"density":-132.49981689453125,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":-482,"density":-104.0762939453125,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":-482,"density":-162.87030029296875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":-482,"density":-28.369781494140625,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":-482,"density":-270.38580322265625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":-482,"density":-161.958740234375,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":-482,"density":-227.6322021484375,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":-482,"density":-383.66748046875,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":-482,"density":-58.24151611328125,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":-482,"density":-58.03668212890625,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":-482,"density":-58.44781494140625,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":-482,"density":-27.68109130859375,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":-482,"density":-88.17047119140625,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":-482,"density":-169.008056640625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":-482,"density":-249.58880615234375,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":-482,"density":-281.74359130859375,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":-482,"density":-352.18707275390625,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":-482,"density":-350.274658203125,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":-482,"density":-76.70086669921875,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":-482,"density":-98.67510986328125,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":-482,"density":-187.3265380859375,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":-482,"density":-381.34075927734375,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":-482,"density":-96.17791748046875,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":-482,"density":-215.371826171875,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":-482,"density":-40.02685546875,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":-482,"density":-410.824462890625,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":-482,"density":-410.82855224609375,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":-482,"density":-410.8182373046875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":-482,"density":-371.4847412109375,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":-482,"density":-488.43096923828125,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":-482,"density":-209.44854736328125,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":-482,"density":-556.447998046875,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":-482,"density":-120.65283203125,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":-482,"density":-656.71044921875,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":30,"density":-7.8856010437011719,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":-482,"density":-517.5760498046875,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":30,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":-481,"density":-101.4000244140625,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":-481,"density":-101.471435546875,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":-481,"density":-101.47076416015625,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":-481,"density":-66.533935546875,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":-481,"density":-140.97222900390625,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":-481,"density":-254.8656005859375,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":-481,"density":-29.525909423828125,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":-481,"density":-256.7069091796875,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":-481,"density":-337.91278076171875,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":-481,"density":-101.6134033203125,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":-481,"density":-101.6846923828125,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":-481,"density":-101.54266357421875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":-481,"density":-66.623291015625,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":-481,"density":-141.11248779296875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":-481,"density":-255.0938720703125,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":-481,"density":-29.7703857421875,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":-481,"density":-256.72296142578125,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":-481,"density":-338.09698486328125,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":-481,"density":-101.61566162109375,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":-481,"density":-101.5440673828125,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":31,"density":28,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":-481,"density":-101.6864013671875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":-481,"density":-66.44451904296875,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":-481,"density":-140.8338623046875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":-481,"density":-254.639892578125,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":-481,"density":-29.282745361328125,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":-481,"density":-256.690673828125,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":-481,"density":-337.729248046875,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":-481,"density":-106.41595458984375,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":-481,"density":-106.3905029296875,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":-481,"density":-106.44207763671875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":-481,"density":-69.25933837890625,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":-481,"density":-141.77899169921875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":-481,"density":-262.48785400390625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":-481,"density":-62.25494384765625,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":-481,"density":-253.66650390625,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":-481,"density":-347.35992431640625,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":-481,"density":-101.9278564453125,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":-481,"density":-101.89337158203125,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":-481,"density":-101.961669921875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":-481,"density":-66.8758544921875,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":-481,"density":-137.55438232421875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":-481,"density":-247.763916015625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":-481,"density":-261.72271728515625,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":-481,"density":-330.520751953125,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":-481,"density":-133.43231201171875,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":-481,"density":-133.363525390625,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":-481,"density":-133.49981689453125,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":-481,"density":-105.0762939453125,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":-481,"density":-163.87030029296875,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":-481,"density":-29.369781494140625,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":-481,"density":-271.38580322265625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":-481,"density":-162.958740234375,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":-481,"density":-228.6322021484375,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":-481,"density":-384.66748046875,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":-481,"density":-59.24151611328125,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":-481,"density":-59.03668212890625,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":-481,"density":-59.44781494140625,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":-481,"density":-28.68109130859375,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":-481,"density":-89.17047119140625,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":-481,"density":-170.008056640625,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":-481,"density":-250.58880615234375,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":-481,"density":-282.74359130859375,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":-481,"density":-353.18707275390625,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":-481,"density":-351.274658203125,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":-481,"density":-77.70086669921875,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":-481,"density":-99.67510986328125,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":-481,"density":-188.3265380859375,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":-481,"density":-382.34075927734375,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":-481,"density":-97.17791748046875,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":-481,"density":-216.371826171875,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":-481,"density":-41.02685546875,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":-481,"density":-411.824462890625,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":-481,"density":-411.82855224609375,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":-481,"density":-411.8182373046875,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":-481,"density":-372.4847412109375,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":-481,"density":-489.43096923828125,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":-481,"density":-210.44854736328125,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":-481,"density":-557.447998046875,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":-481,"density":-121.65283203125,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":-481,"density":-657.71044921875,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":31,"density":-7.9501910209655762,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":-481,"density":-518.5760498046875,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":31,"density":-8,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":32,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":32,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":33,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":33,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":0,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":34,"density":6,"kind":"strate_boundary","strate_index":0},{"x":1,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":0,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-1,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":1,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-128,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":128,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-512,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":512,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-2048,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":2048,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":-8192,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":34,"density":1,"kind":"strate_boundary","strate_index":0},{"x":8192,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":0},{"x":3762,"y":-3868,"z":-376,"density":-8,"kind":"varied_world","strate_index":0},{"x":-92,"y":3095,"z":-168,"density":-7.9726963043212891,"kind":"varied_world","strate_index":0},{"x":1150,"y":320,"z":-261,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3580,"y":-2918,"z":-277,"density":-6.2494964599609375,"kind":"varied_world","strate_index":0},{"x":2607,"y":-2129,"z":-182,"density":-4.8702073097229004,"kind":"varied_world","strate_index":0},{"x":3668,"y":-2481,"z":-3,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2395,"y":391,"z":-21,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1727,"y":831,"z":-331,"density":-5.9491415023803711,"kind":"varied_world","strate_index":0},{"x":480,"y":2770,"z":-181,"density":-7.8216347694396973,"kind":"varied_world","strate_index":0},{"x":702,"y":2130,"z":-139,"density":-6.0904998779296875,"kind":"varied_world","strate_index":0},{"x":-3750,"y":126,"z":-268,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2241,"y":-862,"z":-327,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3472,"y":3137,"z":-422,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":2665,"y":3632,"z":-447,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":360,"y":-3800,"z":-401,"density":-8,"kind":"varied_world","strate_index":0},{"x":1699,"y":3657,"z":-123,"density":-8,"kind":"varied_world","strate_index":0},{"x":1645,"y":-1309,"z":-233,"density":-8,"kind":"varied_world","strate_index":0},{"x":2391,"y":-3876,"z":24,"density":-8,"kind":"varied_world","strate_index":0},{"x":701,"y":541,"z":-397,"density":-8,"kind":"varied_world","strate_index":0},{"x":1868,"y":3493,"z":32,"density":1,"kind":"varied_world","strate_index":0},{"x":3708,"y":1247,"z":-6,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2478,"y":1687,"z":-247,"density":-8,"kind":"varied_world","strate_index":0},{"x":51,"y":4054,"z":-291,"density":3.1409950256347656,"kind":"varied_world","strate_index":0},{"x":3759,"y":583,"z":-92,"density":-7.0350499153137207,"kind":"varied_world","strate_index":0},{"x":-3954,"y":-1212,"z":-298,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2615,"y":-1856,"z":-48,"density":-8,"kind":"varied_world","strate_index":0},{"x":1300,"y":530,"z":9,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3433,"y":1096,"z":-36,"density":-7.2118072509765625,"kind":"varied_world","strate_index":0},{"x":-3692,"y":142,"z":-180,"density":-7.9741635322570801,"kind":"varied_world","strate_index":0},{"x":191,"y":2738,"z":-189,"density":-8,"kind":"varied_world","strate_index":0},{"x":4044,"y":2521,"z":-110,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2124,"y":1639,"z":-348,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2796,"y":893,"z":21,"density":-8,"kind":"varied_world","strate_index":0},{"x":-671,"y":1286,"z":-343,"density":-8,"kind":"varied_world","strate_index":0},{"x":3415,"y":3467,"z":-78,"density":-8,"kind":"varied_world","strate_index":0},{"x":1409,"y":-1185,"z":-128,"density":-8,"kind":"varied_world","strate_index":0},{"x":1615,"y":4039,"z":-339,"density":-8,"kind":"varied_world","strate_index":0},{"x":1702,"y":225,"z":-28,"density":7.4346895217895508,"kind":"varied_world","strate_index":0},{"x":-783,"y":2193,"z":-105,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1783,"y":-518,"z":-469,"density":-5,"kind":"varied_world","strate_index":0},{"x":-2480,"y":-2527,"z":-420,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1907,"y":1703,"z":27,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3219,"y":-991,"z":-438,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":2050,"y":-1193,"z":-372,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3459,"y":1318,"z":-267,"density":-8,"kind":"varied_world","strate_index":0},{"x":3703,"y":2993,"z":-252,"density":-8,"kind":"varied_world","strate_index":0},{"x":3538,"y":1693,"z":-130,"density":5.0777597427368164,"kind":"varied_world","strate_index":0},{"x":-584,"y":-2328,"z":-53,"density":-8,"kind":"varied_world","strate_index":0},{"x":3859,"y":1158,"z":-149,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1779,"y":-3463,"z":-434,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1372,"y":3846,"z":-294,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3233,"y":-2134,"z":-4,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1757,"y":-992,"z":-100,"density":-8,"kind":"varied_world","strate_index":0},{"x":2382,"y":2241,"z":-421,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1676,"y":2573,"z":-480,"density":-5,"kind":"varied_world","strate_index":0},{"x":-720,"y":1560,"z":-212,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1139,"y":2154,"z":-439,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":1570,"y":-2266,"z":-305,"density":-8,"kind":"varied_world","strate_index":0},{"x":2075,"y":-2508,"z":-59,"density":-5.9343070983886719,"kind":"varied_world","strate_index":0},{"x":-543,"y":-1459,"z":-480,"density":-5,"kind":"varied_world","strate_index":0},{"x":-1439,"y":-2641,"z":-57,"density":3.7562675476074219,"kind":"varied_world","strate_index":0},{"x":-1508,"y":3517,"z":-231,"density":-8,"kind":"varied_world","strate_index":0},{"x":3374,"y":1171,"z":-191,"density":-8,"kind":"varied_world","strate_index":0},{"x":2553,"y":-3608,"z":-342,"density":-8,"kind":"varied_world","strate_index":0},{"x":76,"y":-3156,"z":-190,"density":-8,"kind":"varied_world","strate_index":0},{"x":-322,"y":-3820,"z":-369,"density":-8,"kind":"varied_world","strate_index":0},{"x":1843,"y":-1118,"z":-40,"density":-7.5551037788391113,"kind":"varied_world","strate_index":0},{"x":-904,"y":-736,"z":-281,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3734,"y":2817,"z":-468,"density":-5,"kind":"varied_world","strate_index":0},{"x":737,"y":835,"z":-223,"density":-8,"kind":"varied_world","strate_index":0},{"x":10,"y":-1614,"z":4,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2469,"y":2712,"z":-449,"density":-5,"kind":"varied_world","strate_index":0},{"x":3875,"y":-2569,"z":-329,"density":-8,"kind":"varied_world","strate_index":0},{"x":437,"y":-934,"z":-167,"density":-7.3145637512207031,"kind":"varied_world","strate_index":0},{"x":-1019,"y":-2963,"z":-197,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1420,"y":-3745,"z":-386,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3986,"y":-2519,"z":-39,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2093,"y":-547,"z":-90,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3002,"y":2802,"z":-170,"density":-4.9543008804321289,"kind":"varied_world","strate_index":0},{"x":3866,"y":-3911,"z":-292,"density":-8,"kind":"varied_world","strate_index":0},{"x":2589,"y":-1931,"z":-293,"density":6.7064456939697266,"kind":"varied_world","strate_index":0},{"x":-1043,"y":1197,"z":-361,"density":-8,"kind":"varied_world","strate_index":0},{"x":971,"y":-2036,"z":-450,"density":-5,"kind":"varied_world","strate_index":0},{"x":1843,"y":-2799,"z":-428,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1394,"y":-3886,"z":-37,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1902,"y":-2838,"z":-222,"density":-8,"kind":"varied_world","strate_index":0},{"x":1719,"y":2566,"z":-473,"density":-5,"kind":"varied_world","strate_index":0},{"x":3098,"y":2471,"z":25,"density":-8,"kind":"varied_world","strate_index":0},{"x":1131,"y":3964,"z":-441,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-883,"y":-3246,"z":-130,"density":-7.9928956031799316,"kind":"varied_world","strate_index":0},{"x":849,"y":-638,"z":9,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1584,"y":-2439,"z":-385,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1078,"y":2284,"z":-60,"density":-8,"kind":"varied_world","strate_index":0},{"x":3929,"y":-2158,"z":-329,"density":-8,"kind":"varied_world","strate_index":0},{"x":3133,"y":-3834,"z":-117,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2282,"y":488,"z":-442,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":344,"y":574,"z":-141,"density":-8,"kind":"varied_world","strate_index":0},{"x":3886,"y":-1264,"z":-360,"density":-8,"kind":"varied_world","strate_index":0},{"x":873,"y":-375,"z":-216,"density":-7.2852330207824707,"kind":"varied_world","strate_index":0},{"x":2771,"y":1415,"z":-133,"density":-8,"kind":"varied_world","strate_index":0},{"x":-271,"y":2906,"z":-234,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2520,"y":-77,"z":-281,"density":-8,"kind":"varied_world","strate_index":0},{"x":-714,"y":159,"z":-333,"density":-8,"kind":"varied_world","strate_index":0},{"x":2130,"y":-595,"z":-283,"density":-5.9290103912353516,"kind":"varied_world","strate_index":0},{"x":-3788,"y":2175,"z":-247,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1016,"y":538,"z":-332,"density":-14.979887962341309,"kind":"varied_world","strate_index":0},{"x":-1192,"y":197,"z":-1,"density":-8,"kind":"varied_world","strate_index":0},{"x":994,"y":-3263,"z":-434,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":402,"y":1190,"z":-123,"density":-8,"kind":"varied_world","strate_index":0},{"x":512,"y":724,"z":-83,"density":-7.8336653709411621,"kind":"varied_world","strate_index":0},{"x":3820,"y":1082,"z":-296,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1726,"y":-2250,"z":-139,"density":-8,"kind":"varied_world","strate_index":0},{"x":3146,"y":-452,"z":-221,"density":-8,"kind":"varied_world","strate_index":0},{"x":3625,"y":2565,"z":-214,"density":-8,"kind":"varied_world","strate_index":0},{"x":2374,"y":1269,"z":-234,"density":-8,"kind":"varied_world","strate_index":0},{"x":1392,"y":-192,"z":-240,"density":-8,"kind":"varied_world","strate_index":0},{"x":1516,"y":-2127,"z":-480,"density":-5,"kind":"varied_world","strate_index":0},{"x":-3068,"y":273,"z":-6,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1281,"y":-973,"z":-274,"density":-8,"kind":"varied_world","strate_index":0},{"x":3397,"y":2889,"z":-342,"density":-8,"kind":"varied_world","strate_index":0},{"x":3017,"y":3427,"z":-168,"density":7.9183740615844727,"kind":"varied_world","strate_index":0},{"x":-99,"y":-2637,"z":-117,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1874,"y":-994,"z":-184,"density":3.5330917835235596,"kind":"varied_world","strate_index":0},{"x":623,"y":-3309,"z":-58,"density":-0.79146659374237061,"kind":"varied_world","strate_index":0},{"x":733,"y":2656,"z":-164,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3914,"y":-3567,"z":-368,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2847,"y":3593,"z":-183,"density":-8,"kind":"varied_world","strate_index":0},{"x":-4023,"y":-3170,"z":0,"density":-8,"kind":"varied_world","strate_index":0},{"x":183,"y":3453,"z":-65,"density":-8,"kind":"varied_world","strate_index":0},{"x":2979,"y":3401,"z":-342,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2352,"y":707,"z":-45,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3587,"y":3000,"z":-84,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3631,"y":2124,"z":-14,"density":-8,"kind":"varied_world","strate_index":0},{"x":-737,"y":4081,"z":-450,"density":-5,"kind":"varied_world","strate_index":0},{"x":3390,"y":-1398,"z":-227,"density":-8,"kind":"varied_world","strate_index":0},{"x":2953,"y":3922,"z":-87,"density":-5.8188743591308594,"kind":"varied_world","strate_index":0},{"x":2278,"y":-1695,"z":-326,"density":-8,"kind":"varied_world","strate_index":0},{"x":1754,"y":23,"z":-249,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2786,"y":525,"z":-444,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-2283,"y":-3836,"z":-300,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1353,"y":-1161,"z":-339,"density":-8,"kind":"varied_world","strate_index":0},{"x":513,"y":52,"z":-135,"density":6.3260078430175781,"kind":"varied_world","strate_index":0},{"x":873,"y":-1527,"z":-38,"density":-8,"kind":"varied_world","strate_index":0},{"x":-4050,"y":-1709,"z":-281,"density":-8,"kind":"varied_world","strate_index":0},{"x":474,"y":-1307,"z":-73,"density":-8,"kind":"varied_world","strate_index":0},{"x":-652,"y":1170,"z":-358,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1618,"y":-2304,"z":-434,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-446,"y":-1250,"z":-471,"density":-5,"kind":"varied_world","strate_index":0},{"x":-533,"y":-1290,"z":-238,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2809,"y":-1355,"z":-168,"density":-8,"kind":"varied_world","strate_index":0},{"x":1239,"y":-376,"z":-408,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2035,"y":3286,"z":-326,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1596,"y":3212,"z":-320,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2204,"y":-2504,"z":0,"density":-8,"kind":"varied_world","strate_index":0},{"x":-598,"y":-85,"z":-146,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3766,"y":1506,"z":-41,"density":-8,"kind":"varied_world","strate_index":0},{"x":-984,"y":4005,"z":-287,"density":3.281437873840332,"kind":"varied_world","strate_index":0},{"x":2042,"y":3738,"z":-18,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1406,"y":-1046,"z":-431,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-948,"y":-2112,"z":-268,"density":-8,"kind":"varied_world","strate_index":0},{"x":-408,"y":-3789,"z":-266,"density":-8,"kind":"varied_world","strate_index":0},{"x":656,"y":-3700,"z":-254,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1602,"y":3667,"z":-382,"density":-8,"kind":"varied_world","strate_index":0},{"x":226,"y":-3493,"z":4,"density":-8,"kind":"varied_world","strate_index":0},{"x":-901,"y":3795,"z":-385,"density":-8,"kind":"varied_world","strate_index":0},{"x":-537,"y":1339,"z":7,"density":-8,"kind":"varied_world","strate_index":0},{"x":1127,"y":-2522,"z":-333,"density":-8,"kind":"varied_world","strate_index":0},{"x":538,"y":-1252,"z":-80,"density":-7.9307384490966797,"kind":"varied_world","strate_index":0},{"x":2580,"y":459,"z":-24,"density":-8,"kind":"varied_world","strate_index":0},{"x":764,"y":3584,"z":-379,"density":-8,"kind":"varied_world","strate_index":0},{"x":141,"y":679,"z":-203,"density":-7.5472226142883301,"kind":"varied_world","strate_index":0},{"x":1115,"y":-2512,"z":-2,"density":-8,"kind":"varied_world","strate_index":0},{"x":-4051,"y":2808,"z":-448,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":1624,"y":796,"z":-448,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":743,"y":-2702,"z":-214,"density":-8,"kind":"varied_world","strate_index":0},{"x":-681,"y":-1656,"z":-228,"density":-7.2097368240356445,"kind":"varied_world","strate_index":0},{"x":3725,"y":-3844,"z":-60,"density":-8,"kind":"varied_world","strate_index":0},{"x":2305,"y":-2591,"z":-48,"density":-8,"kind":"varied_world","strate_index":0},{"x":1817,"y":2292,"z":-178,"density":-8,"kind":"varied_world","strate_index":0},{"x":3925,"y":-1775,"z":-72,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2885,"y":-1443,"z":-374,"density":-8,"kind":"varied_world","strate_index":0},{"x":-386,"y":1750,"z":-120,"density":5.460418701171875,"kind":"varied_world","strate_index":0},{"x":3651,"y":119,"z":-380,"density":-8,"kind":"varied_world","strate_index":0},{"x":2382,"y":-3164,"z":-73,"density":4.9804935455322266,"kind":"varied_world","strate_index":0},{"x":-515,"y":1249,"z":-392,"density":-8,"kind":"varied_world","strate_index":0},{"x":3447,"y":-944,"z":-197,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1222,"y":-181,"z":-31,"density":-8,"kind":"varied_world","strate_index":0},{"x":-660,"y":44,"z":-84,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3140,"y":-3114,"z":-293,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1946,"y":1546,"z":-270,"density":-7.9965605735778809,"kind":"varied_world","strate_index":0},{"x":-1018,"y":-897,"z":-148,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2214,"y":-3450,"z":-294,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3606,"y":1710,"z":-94,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3096,"y":-3797,"z":-458,"density":-5,"kind":"varied_world","strate_index":0},{"x":2060,"y":186,"z":-423,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-3092,"y":1945,"z":-221,"density":-8,"kind":"varied_world","strate_index":0},{"x":221,"y":362,"z":-74,"density":-8,"kind":"varied_world","strate_index":0},{"x":2085,"y":2129,"z":-264,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3464,"y":-3162,"z":-290,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3168,"y":37,"z":-75,"density":-8,"kind":"varied_world","strate_index":0},{"x":-4065,"y":2151,"z":-153,"density":-2.5160961151123047,"kind":"varied_world","strate_index":0},{"x":-3390,"y":-3325,"z":-326,"density":-8,"kind":"varied_world","strate_index":0},{"x":2141,"y":-2441,"z":-144,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1668,"y":2507,"z":-301,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2621,"y":2306,"z":-157,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2296,"y":44,"z":18,"density":-8,"kind":"varied_world","strate_index":0},{"x":2571,"y":264,"z":-369,"density":-8,"kind":"varied_world","strate_index":0},{"x":-71,"y":3533,"z":-362,"density":-8,"kind":"varied_world","strate_index":0},{"x":3487,"y":141,"z":-466,"density":-5,"kind":"varied_world","strate_index":0},{"x":387,"y":-253,"z":14,"density":-8,"kind":"varied_world","strate_index":0},{"x":1823,"y":3858,"z":-13,"density":-8,"kind":"varied_world","strate_index":0},{"x":978,"y":-2814,"z":-25,"density":-7.9257307052612305,"kind":"varied_world","strate_index":0},{"x":3082,"y":2851,"z":-100,"density":-8,"kind":"varied_world","strate_index":0},{"x":-821,"y":497,"z":-337,"density":-8,"kind":"varied_world","strate_index":0},{"x":-821,"y":756,"z":-218,"density":-8,"kind":"varied_world","strate_index":0},{"x":866,"y":2189,"z":-343,"density":-8,"kind":"varied_world","strate_index":0},{"x":3987,"y":-1074,"z":-204,"density":-8,"kind":"varied_world","strate_index":0},{"x":3303,"y":-278,"z":-269,"density":-8,"kind":"varied_world","strate_index":0},{"x":3543,"y":-1118,"z":-134,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1184,"y":3770,"z":-166,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2776,"y":-2159,"z":14,"density":-8,"kind":"varied_world","strate_index":0},{"x":-323,"y":-1855,"z":-359,"density":-8,"kind":"varied_world","strate_index":0},{"x":980,"y":-3302,"z":-419,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-3282,"y":-1114,"z":0,"density":-8,"kind":"varied_world","strate_index":0},{"x":2621,"y":1207,"z":3,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3808,"y":-2828,"z":-204,"density":-8,"kind":"varied_world","strate_index":0},{"x":271,"y":-3331,"z":-271,"density":-7.7630324363708496,"kind":"varied_world","strate_index":0},{"x":-2809,"y":1303,"z":-60,"density":8,"kind":"varied_world","strate_index":0},{"x":-3995,"y":-2037,"z":-58,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2823,"y":-1678,"z":-91,"density":-8,"kind":"varied_world","strate_index":0},{"x":697,"y":1213,"z":-257,"density":-8,"kind":"varied_world","strate_index":0},{"x":3385,"y":258,"z":13,"density":-8,"kind":"varied_world","strate_index":0},{"x":-537,"y":352,"z":-311,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1535,"y":1488,"z":-373,"density":-8,"kind":"varied_world","strate_index":0},{"x":2756,"y":-1313,"z":-243,"density":-8,"kind":"varied_world","strate_index":0},{"x":453,"y":3676,"z":-139,"density":-3.3761932849884033,"kind":"varied_world","strate_index":0},{"x":-312,"y":-3854,"z":-141,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2666,"y":-3199,"z":-45,"density":-8,"kind":"varied_world","strate_index":0},{"x":252,"y":2270,"z":-310,"density":-8,"kind":"varied_world","strate_index":0},{"x":3023,"y":-105,"z":-400,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3631,"y":1825,"z":-109,"density":-8,"kind":"varied_world","strate_index":0},{"x":2349,"y":134,"z":-275,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3371,"y":-2277,"z":-366,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2971,"y":-2473,"z":-260,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2809,"y":-2049,"z":-366,"density":-8,"kind":"varied_world","strate_index":0},{"x":1489,"y":7,"z":-309,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3722,"y":-1934,"z":-469,"density":-5,"kind":"varied_world","strate_index":0},{"x":3621,"y":-2077,"z":-464,"density":-5,"kind":"varied_world","strate_index":0},{"x":3364,"y":1086,"z":-419,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":584,"y":1141,"z":-454,"density":-5,"kind":"varied_world","strate_index":0},{"x":1399,"y":-300,"z":-370,"density":-8,"kind":"varied_world","strate_index":0},{"x":-720,"y":-1584,"z":12,"density":-8,"kind":"varied_world","strate_index":0},{"x":2938,"y":238,"z":-143,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3775,"y":3742,"z":-331,"density":-8,"kind":"varied_world","strate_index":0},{"x":2003,"y":1167,"z":-32,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2300,"y":-424,"z":-181,"density":-8,"kind":"varied_world","strate_index":0},{"x":3551,"y":-104,"z":16,"density":-8,"kind":"varied_world","strate_index":0},{"x":-727,"y":1334,"z":-190,"density":-8,"kind":"varied_world","strate_index":0},{"x":1951,"y":-2144,"z":-420,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1123,"y":4092,"z":-330,"density":-8,"kind":"varied_world","strate_index":0},{"x":3076,"y":50,"z":21,"density":-8,"kind":"varied_world","strate_index":0},{"x":1802,"y":-4004,"z":-396,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1039,"y":3230,"z":-402,"density":-8,"kind":"varied_world","strate_index":0},{"x":381,"y":970,"z":-349,"density":-8,"kind":"varied_world","strate_index":0},{"x":-567,"y":3681,"z":-430,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-503,"y":3221,"z":-463,"density":-5,"kind":"varied_world","strate_index":0},{"x":-1670,"y":-2366,"z":-100,"density":-8,"kind":"varied_world","strate_index":0},{"x":1536,"y":490,"z":-233,"density":-8,"kind":"varied_world","strate_index":0},{"x":-501,"y":931,"z":6,"density":-8,"kind":"varied_world","strate_index":0},{"x":-765,"y":189,"z":-188,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1183,"y":-1402,"z":-111,"density":-6.5072307586669922,"kind":"varied_world","strate_index":0},{"x":2963,"y":3039,"z":-421,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":1378,"y":-61,"z":-443,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-3151,"y":-1414,"z":-416,"density":-8,"kind":"varied_world","strate_index":0},{"x":1748,"y":2135,"z":-4,"density":-8,"kind":"varied_world","strate_index":0},{"x":1849,"y":4087,"z":-170,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3998,"y":303,"z":-270,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1805,"y":3222,"z":-80,"density":-0.42736142873764038,"kind":"varied_world","strate_index":0},{"x":2253,"y":1251,"z":-150,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2320,"y":-3711,"z":-377,"density":-8,"kind":"varied_world","strate_index":0},{"x":-817,"y":-358,"z":23,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1108,"y":736,"z":-137,"density":-8,"kind":"varied_world","strate_index":0},{"x":1424,"y":-894,"z":-278,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3480,"y":1335,"z":4,"density":-7.6955723762512207,"kind":"varied_world","strate_index":0},{"x":321,"y":-1144,"z":-459,"density":-5,"kind":"varied_world","strate_index":0},{"x":-72,"y":-2304,"z":-15,"density":-8,"kind":"varied_world","strate_index":0},{"x":2409,"y":-2256,"z":-90,"density":-8,"kind":"varied_world","strate_index":0},{"x":-106,"y":-1709,"z":-455,"density":-5,"kind":"varied_world","strate_index":0},{"x":-1330,"y":2335,"z":-133,"density":-8,"kind":"varied_world","strate_index":0},{"x":3191,"y":2496,"z":-3,"density":-8,"kind":"varied_world","strate_index":0},{"x":531,"y":-1372,"z":-219,"density":6.3987531661987305,"kind":"varied_world","strate_index":0},{"x":3226,"y":-1809,"z":-285,"density":-8,"kind":"varied_world","strate_index":0},{"x":1063,"y":-807,"z":-189,"density":-8,"kind":"varied_world","strate_index":0},{"x":2680,"y":-945,"z":-87,"density":-8,"kind":"varied_world","strate_index":0},{"x":103,"y":-4,"z":-386,"density":-8,"kind":"varied_world","strate_index":0},{"x":2184,"y":-3565,"z":-259,"density":-8,"kind":"varied_world","strate_index":0},{"x":421,"y":-364,"z":-254,"density":-6.0395474433898926,"kind":"varied_world","strate_index":0},{"x":1455,"y":-767,"z":-189,"density":7.2390556335449219,"kind":"varied_world","strate_index":0},{"x":-1611,"y":-1404,"z":-475,"density":-5,"kind":"varied_world","strate_index":0},{"x":3192,"y":-516,"z":12,"density":-8,"kind":"varied_world","strate_index":0},{"x":1498,"y":1903,"z":-280,"density":-8,"kind":"varied_world","strate_index":0},{"x":467,"y":-3331,"z":-261,"density":7.8935546875,"kind":"varied_world","strate_index":0},{"x":-1950,"y":1300,"z":-109,"density":-0.73374497890472412,"kind":"varied_world","strate_index":0},{"x":-384,"y":794,"z":-349,"density":-8,"kind":"varied_world","strate_index":0},{"x":2577,"y":767,"z":-430,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":1604,"y":3181,"z":-336,"density":-8,"kind":"varied_world","strate_index":0},{"x":819,"y":900,"z":-140,"density":-4.8331727981567383,"kind":"varied_world","strate_index":0},{"x":4090,"y":72,"z":-152,"density":8,"kind":"varied_world","strate_index":0},{"x":3659,"y":-916,"z":-138,"density":-8,"kind":"varied_world","strate_index":0},{"x":-235,"y":2717,"z":-246,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1325,"y":-582,"z":-340,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3936,"y":2238,"z":-132,"density":-8,"kind":"varied_world","strate_index":0},{"x":3124,"y":-1840,"z":-198,"density":-8,"kind":"varied_world","strate_index":0},{"x":2216,"y":-102,"z":-408,"density":-8,"kind":"varied_world","strate_index":0},{"x":1307,"y":-3632,"z":-46,"density":-8,"kind":"varied_world","strate_index":0},{"x":2067,"y":-4042,"z":-355,"density":-8,"kind":"varied_world","strate_index":0},{"x":140,"y":-1924,"z":-57,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3231,"y":2246,"z":-49,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2690,"y":-3376,"z":-421,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-226,"y":-307,"z":-286,"density":-8,"kind":"varied_world","strate_index":0},{"x":1676,"y":-3949,"z":-85,"density":-8,"kind":"varied_world","strate_index":0},{"x":1276,"y":2528,"z":-166,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1470,"y":396,"z":-437,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1432,"y":-1000,"z":-178,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1857,"y":1660,"z":-215,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2842,"y":1034,"z":-166,"density":-8,"kind":"varied_world","strate_index":0},{"x":3553,"y":1290,"z":-395,"density":-8,"kind":"varied_world","strate_index":0},{"x":-12,"y":-1801,"z":-459,"density":-5,"kind":"varied_world","strate_index":0},{"x":730,"y":3751,"z":-370,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1723,"y":-956,"z":-342,"density":-8,"kind":"varied_world","strate_index":0},{"x":1069,"y":7,"z":-215,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1800,"y":-2150,"z":-71,"density":-7.3073396682739258,"kind":"varied_world","strate_index":0},{"x":2053,"y":2097,"z":-480,"density":-5,"kind":"varied_world","strate_index":0},{"x":-3576,"y":-800,"z":-410,"density":-8,"kind":"varied_world","strate_index":0},{"x":678,"y":-1765,"z":-395,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1646,"y":2368,"z":-472,"density":-5,"kind":"varied_world","strate_index":0},{"x":-1765,"y":1761,"z":-129,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2152,"y":-2707,"z":-386,"density":-8,"kind":"varied_world","strate_index":0},{"x":2952,"y":744,"z":-328,"density":-3.1588506698608398,"kind":"varied_world","strate_index":0},{"x":4049,"y":-3790,"z":-63,"density":-8,"kind":"varied_world","strate_index":0},{"x":3694,"y":-2829,"z":17,"density":-6.8807520866394043,"kind":"varied_world","strate_index":0},{"x":2714,"y":-2918,"z":-419,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-1261,"y":2540,"z":-71,"density":1.7034235000610352,"kind":"varied_world","strate_index":0},{"x":-3184,"y":-2962,"z":-394,"density":-8,"kind":"varied_world","strate_index":0},{"x":-3984,"y":3657,"z":-165,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1459,"y":3986,"z":-474,"density":-5,"kind":"varied_world","strate_index":0},{"x":3726,"y":1377,"z":0,"density":-8,"kind":"varied_world","strate_index":0},{"x":2236,"y":-2668,"z":-435,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-3350,"y":3684,"z":-59,"density":-8,"kind":"varied_world","strate_index":0},{"x":3844,"y":590,"z":-342,"density":-8,"kind":"varied_world","strate_index":0},{"x":1146,"y":195,"z":-93,"density":-8,"kind":"varied_world","strate_index":0},{"x":-679,"y":-2947,"z":-12,"density":-8,"kind":"varied_world","strate_index":0},{"x":-492,"y":707,"z":-62,"density":-8,"kind":"varied_world","strate_index":0},{"x":-469,"y":3174,"z":-88,"density":-8,"kind":"varied_world","strate_index":0},{"x":-638,"y":2909,"z":-180,"density":6.4304094314575195,"kind":"varied_world","strate_index":0},{"x":1267,"y":286,"z":-242,"density":-3.6072564125061035,"kind":"varied_world","strate_index":0},{"x":-1735,"y":2838,"z":-300,"density":-8,"kind":"varied_world","strate_index":0},{"x":1401,"y":1483,"z":-136,"density":-8,"kind":"varied_world","strate_index":0},{"x":-601,"y":-3206,"z":-105,"density":-8,"kind":"varied_world","strate_index":0},{"x":2781,"y":2315,"z":-254,"density":0.49017369747161865,"kind":"varied_world","strate_index":0},{"x":583,"y":2241,"z":-153,"density":-8,"kind":"varied_world","strate_index":0},{"x":670,"y":-244,"z":-340,"density":-8,"kind":"varied_world","strate_index":0},{"x":1305,"y":2735,"z":-354,"density":-8,"kind":"varied_world","strate_index":0},{"x":3276,"y":-183,"z":-355,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2598,"y":-2236,"z":-21,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2778,"y":-1914,"z":-50,"density":-8,"kind":"varied_world","strate_index":0},{"x":-4014,"y":2222,"z":-296,"density":-8,"kind":"varied_world","strate_index":0},{"x":1860,"y":-2616,"z":-410,"density":-8,"kind":"varied_world","strate_index":0},{"x":834,"y":-271,"z":-186,"density":7.1254968643188477,"kind":"varied_world","strate_index":0},{"x":4065,"y":-3061,"z":-315,"density":-8,"kind":"varied_world","strate_index":0},{"x":1769,"y":-1467,"z":0,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2711,"y":-2020,"z":-252,"density":-8,"kind":"varied_world","strate_index":0},{"x":-5,"y":2943,"z":-17,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2205,"y":-882,"z":-248,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1903,"y":489,"z":-435,"density":-6.5,"kind":"varied_world","strate_index":0},{"x":-614,"y":3820,"z":-393,"density":-8,"kind":"varied_world","strate_index":0},{"x":1573,"y":2032,"z":-317,"density":-8,"kind":"varied_world","strate_index":0},{"x":815,"y":-2250,"z":-223,"density":-8,"kind":"varied_world","strate_index":0},{"x":-32,"y":1880,"z":-87,"density":-8,"kind":"varied_world","strate_index":0},{"x":-915,"y":2968,"z":-356,"density":-8,"kind":"varied_world","strate_index":0},{"x":363,"y":-1703,"z":10,"density":-8,"kind":"varied_world","strate_index":0},{"x":3807,"y":2425,"z":-75,"density":-8,"kind":"varied_world","strate_index":0},{"x":-1470,"y":3035,"z":24,"density":-8,"kind":"varied_world","strate_index":0},{"x":-2176,"y":-3172,"z":-334,"density":-8,"kind":"varied_world","strate_index":0},{"x":-46.449775695800781,"y":-472.88494873046875,"z":-79.380828857421875,"density":8,"kind":"room_interior","strate_index":0},{"x":45.865641597132829,"y":-17.565896948430964,"z":-175.68844604492188,"density":8.5513343811035156,"kind":"tunnel_interior","strate_index":0},{"x":57.339517381846868,"y":-25.264677798943772,"z":-160.67250524910469,"density":8.5771579742431641,"kind":"tunnel_interior","strate_index":0},{"x":69.191985751782582,"y":-31.881786773953856,"z":-145.6565644532875,"density":8.0216913223266602,"kind":"tunnel_interior","strate_index":0},{"x":83.732298663477067,"y":-30.819486985415786,"z":-130.640625,"density":8.196864128112793,"kind":"tunnel_interior","strate_index":0},{"x":-32.369951941129266,"y":35.397101593346079,"z":-169.94143676757812,"density":10.847345352172852,"kind":"tunnel_interior","strate_index":0},{"x":-58.364061426509387,"y":62.265496793678246,"z":-164.74788904606658,"density":9.889460563659668,"kind":"tunnel_interior","strate_index":0},{"x":-74.633218068221055,"y":98.02102252379494,"z":-159.55434132455503,"density":9.545994758605957,"kind":"tunnel_interior","strate_index":0},{"x":-107.99742985003323,"y":118.15425857273158,"z":-154.36079406738281,"density":10.207618713378906,"kind":"tunnel_interior","strate_index":0},{"x":-19.691963207007579,"y":44.849679753946333,"z":-174.25631713867188,"density":8.8153648376464844,"kind":"tunnel_interior","strate_index":0},{"x":-25.238782299741921,"y":79.551401801328623,"z":-144.8591070950863,"density":9.4417934417724609,"kind":"tunnel_interior","strate_index":0},{"x":-39.30087804215578,"y":110.64853819742956,"z":-115.46189705150073,"density":10.330310821533203,"kind":"tunnel_interior","strate_index":0},{"x":-59.592546489558885,"y":139.10864264298846,"z":-86.064689636230469,"density":11.014972686767578,"kind":"tunnel_interior","strate_index":0},{"x":37.543675501948208,"y":30.472034123593222,"z":-172.26881408691406,"density":10.072761535644531,"kind":"tunnel_interior","strate_index":0},{"x":66.157746259558891,"y":51.647161423156192,"z":-195.93566598065726,"density":7.7366600036621094,"kind":"tunnel_interior","strate_index":0},{"x":91.931912664831117,"y":76.110886285626492,"z":-219.60251787440046,"density":8.0660820007324219,"kind":"tunnel_interior","strate_index":0},{"x":116.32727568321251,"y":102.17125494505987,"z":-243.26936765216229,"density":6.4813766479492188,"kind":"tunnel_interior","strate_index":0},{"x":153.01855624441575,"y":113.99301135958397,"z":-266.93622166188686,"density":7.4377250671386719,"kind":"tunnel_interior","strate_index":0},{"x":177.05565622262137,"y":140.46823743503424,"z":-290.60306720768591,"density":6.3652338981628418,"kind":"tunnel_interior","strate_index":0},{"x":198.73657876395987,"y":169.67192671706724,"z":-314.26992121741051,"density":8.0105714797973633,"kind":"tunnel_interior","strate_index":0},{"x":446.5372867390177,"y":-602.39866084092375,"z":-192.87490844726562,"density":10.922196388244629,"kind":"tunnel_interior","strate_index":0},{"x":429.06907870323215,"y":-565.04505542364427,"z":-201.37855108860686,"density":8.3199644088745117,"kind":"tunnel_interior","strate_index":0},{"x":414.75563267533772,"y":-525.45024927275949,"z":-209.88219372994809,"density":9.3797311782836914,"kind":"tunnel_interior","strate_index":0},{"x":384.40257597486931,"y":-497.25027642980683,"z":-218.38583637128929,"density":10.83664608001709,"kind":"tunnel_interior","strate_index":0},{"x":352.55728994468183,"y":-470.11041064409272,"z":-226.88947901263052,"density":11.192255020141602,"kind":"tunnel_interior","strate_index":0},{"x":-411.05714201068582,"y":-501.35607268383973,"z":-121.94290161132812,"density":8.6120443344116211,"kind":"tunnel_interior","strate_index":0},{"x":-405.73141719016968,"y":-498.4187291871001,"z":-99.597431788480208,"density":10.024700164794922,"kind":"tunnel_interior","strate_index":0},{"x":-417.85918347769899,"y":-497.57775795361249,"z":-77.25196196563229,"density":9.9202661514282227,"kind":"tunnel_interior","strate_index":0},{"x":-411.87329663815984,"y":-494.56112133712111,"z":-54.906494140625,"density":9.0493144989013672,"kind":"tunnel_interior","strate_index":0},{"x":-360.79756886264676,"y":-568.24025215440281,"z":-249.04958085193601,"density":11.460857391357422,"kind":"tunnel_interior","strate_index":0},{"x":-399.02676076412752,"y":-552.2653398695162,"z":-255.4739233132938,"density":9.906428337097168,"kind":"tunnel_interior","strate_index":0},{"x":-428.13176411263771,"y":-519.77454877621744,"z":-261.89826577465158,"density":8.6741657257080078,"kind":"tunnel_interior","strate_index":0},{"x":-460.3817233835577,"y":-492.97649985597013,"z":-268.32260871466013,"density":7.7398948669433594,"kind":"tunnel_interior","strate_index":0},{"x":-498.73287649373179,"y":-477.22235837523368,"z":-274.74695069736714,"density":8.0947675704956055,"kind":"tunnel_interior","strate_index":0},{"x":-541.05541406113025,"y":-468.65690047072309,"z":-281.17129268007409,"density":8.3440322875976562,"kind":"tunnel_interior","strate_index":0},{"x":-319.8243765680495,"y":-544.81693924215097,"z":-248.57372311016502,"density":9.6692142486572266,"kind":"tunnel_interior","strate_index":0},{"x":-337.87769514690245,"y":-502.44256602340931,"z":-241.64562436387376,"density":8.5652084350585938,"kind":"tunnel_interior","strate_index":0},{"x":-356.65334894551154,"y":-460.36970349160845,"z":-234.71752561758251,"density":6.7160577774047852,"kind":"tunnel_interior","strate_index":0},{"x":-375.33190126220461,"y":-418.25630973038017,"z":-227.78942687129125,"density":6.3487567901611328,"kind":"tunnel_interior","strate_index":0},{"x":-390.79160108937492,"y":-374.79933144117518,"z":-220.861328125,"density":8.4524335861206055,"kind":"tunnel_interior","strate_index":0},{"x":-98.934396369655303,"y":-561.03054354536505,"z":-42.635982513427734,"density":11.224624633789062,"kind":"tunnel_interior","strate_index":0},{"x":-70.368952328359995,"y":-533.39267610930926,"z":-46.142668724060059,"density":10.31546688079834,"kind":"tunnel_interior","strate_index":0},{"x":-33.373512891207916,"y":-519.52817602419725,"z":-49.649354934692383,"density":11.104689598083496,"kind":"tunnel_interior","strate_index":0},{"x":5.2569855727645862,"y":-508.33512088767247,"z":-53.156041145324707,"density":8.1473894119262695,"kind":"tunnel_interior","strate_index":0},{"x":33.412535547622049,"y":-480.02754708916763,"z":-56.662727355957031,"density":8.1890869140625,"kind":"tunnel_interior","strate_index":0},{"x":-44.730002286916779,"y":-439.96536773101576,"z":-96.287193298339844,"density":8.7668170928955078,"kind":"tunnel_interior","strate_index":0},{"x":-55.50373556002328,"y":-479.13153862140433,"z":-81.465515966564112,"density":8.0864372253417969,"kind":"tunnel_interior","strate_index":0},{"x":-57.867220188997699,"y":-519.32745159707827,"z":-66.643838634788381,"density":9.2813472747802734,"kind":"tunnel_interior","strate_index":0},{"x":-59.381292262734704,"y":-559.62736214174265,"z":-51.822162628173828,"density":8.2950153350830078,"kind":"tunnel_interior","strate_index":0},{"x":24.188520390064141,"y":-484.41763078086706,"z":-97.099685668945312,"density":9.6646537780761719,"kind":"tunnel_interior","strate_index":0},{"x":27.762926284743877,"y":-483.08447940713029,"z":-83.210853162740705,"density":10.227140426635742,"kind":"tunnel_interior","strate_index":0},{"x":31.923723318780571,"y":-483.66702956022698,"z":-69.322020656536097,"density":10.970211982727051,"kind":"tunnel_interior","strate_index":0},{"x":35.112437246424605,"y":-481.07384890153116,"z":-55.433189392089844,"density":9.8979072570800781,"kind":"tunnel_interior","strate_index":0},{"x":-384.04683807580039,"y":-401.03459229026066,"z":-55.026767730712891,"density":10.176187515258789,"kind":"tunnel_interior","strate_index":0},{"x":-386.86049162221633,"y":-395.31506415987741,"z":-94.055155627207142,"density":9.8695774078369141,"kind":"tunnel_interior","strate_index":0},{"x":-380.75548550839824,"y":-394.79007132827888,"z":-133.08354352370139,"density":10.199542999267578,"kind":"tunnel_interior","strate_index":0},{"x":-380.32280128221845,"y":-390.961322394989,"z":-172.11193142019562,"density":8.3371458053588867,"kind":"tunnel_interior","strate_index":0},{"x":-376.94790789159657,"y":-388.84621765750211,"z":-211.14031931668987,"density":8.5614986419677734,"kind":"tunnel_interior","strate_index":0},{"x":40.577488896415915,"y":-450.82854418409215,"z":-48.429080963134766,"density":11.583944320678711,"kind":"tunnel_interior","strate_index":0},{"x":35.446116530319898,"y":-453.52301427584956,"z":-48.639141089023951,"density":13.026596069335938,"kind":"tunnel_interior","strate_index":0},{"x":30.933448839765038,"y":-456.97979080730971,"z":-48.849201214913137,"density":10.301076889038086,"kind":"tunnel_interior","strate_index":0},{"x":27.341587633240373,"y":-461.57109281023526,"z":-49.059261322021484,"density":11.241231918334961,"kind":"tunnel_interior","strate_index":0},{"x":139.28390841652595,"y":-466.84000375749446,"z":-57.168312072753906,"density":9.6134166717529297,"kind":"tunnel_interior","strate_index":0},{"x":123.73358399479116,"y":-468.78112855023505,"z":-102.41001360913782,"density":9.5237398147583008,"kind":"tunnel_interior","strate_index":0},{"x":118.47262806004208,"y":-451.17863864668601,"z":-147.65171514552173,"density":8.8176956176757812,"kind":"tunnel_interior","strate_index":0},{"x":105.29490868134337,"y":-448.613240143442,"z":-192.89341668190562,"density":10.505425453186035,"kind":"tunnel_interior","strate_index":0},{"x":93.782680458793948,"y":-442.8844096494953,"z":-238.13511821828953,"density":9.5738992691040039,"kind":"tunnel_interior","strate_index":0},{"x":134.67661233674426,"y":-418.99321090996938,"z":-57.8363037109375,"density":8.9848928451538086,"kind":"tunnel_interior","strate_index":0},{"x":173.66173600418114,"y":-407.3561886488996,"z":-56.622636123090956,"density":8.5603933334350586,"kind":"tunnel_interior","strate_index":0},{"x":200.00513491190702,"y":-373.82831838922266,"z":-55.408968535244412,"density":6.6802783012390137,"kind":"tunnel_interior","strate_index":0},{"x":237.50134759422059,"y":-359.61305342598888,"z":-54.195301055908203,"density":9.59521484375,"kind":"tunnel_interior","strate_index":0},{"x":229.51536807082806,"y":-444.86427394322931,"z":-247.72647082398038,"density":9.9072980880737305,"kind":"tunnel_interior","strate_index":0},{"x":237.22805766020718,"y":-443.26227552760344,"z":-243.34595231888062,"density":10.843462944030762,"kind":"tunnel_interior","strate_index":0},{"x":245.58237101937237,"y":-445.99277723445653,"z":-238.96543381378086,"density":8.264124870300293,"kind":"tunnel_interior","strate_index":0},{"x":252.85332779813137,"y":-441.40802713976012,"z":-234.58491570032999,"density":8.834080696105957,"kind":"tunnel_interior","strate_index":0},{"x":389.22677064661207,"y":-407.41619711956247,"z":-225.90566895463564,"density":11.877470016479492,"kind":"tunnel_interior","strate_index":0},{"x":433.19589434967321,"y":-399.54993227748298,"z":-235.84346974411673,"density":8.1487216949462891,"kind":"tunnel_interior","strate_index":0},{"x":473.70482765407991,"y":-377.34267668651398,"z":-245.78127053359782,"density":10.131977081298828,"kind":"tunnel_interior","strate_index":0},{"x":519.25815333820765,"y":-376.04226000520526,"z":-255.71907043457031,"density":10.112140655517578,"kind":"tunnel_interior","strate_index":0},{"x":-643.04033227359741,"y":-288.22784781815568,"z":-159.93202209472656,"density":11.461188316345215,"kind":"tunnel_interior","strate_index":0},{"x":-601.27633977760752,"y":-289.77169565358184,"z":-175.76050186157227,"density":9.4938735961914062,"kind":"tunnel_interior","strate_index":0},{"x":-561.07933511685405,"y":-303.3010429008886,"z":-191.58898162841797,"density":7.8488368988037109,"kind":"tunnel_interior","strate_index":0},{"x":-520.10493355093774,"y":-310.88427497097047,"z":-207.41746139526367,"density":8.9473180770874023,"kind":"tunnel_interior","strate_index":0},{"x":-477.99806174492903,"y":-309.8055244197563,"z":-223.24594116210938,"density":9.3878316879272461,"kind":"tunnel_interior","strate_index":0},{"x":-356.95829696498333,"y":-332.79305582706962,"z":-215.02636581400574,"density":11.630132675170898,"kind":"tunnel_interior","strate_index":0},{"x":-376.30635585571446,"y":-330.86530953786814,"z":-212.21273703759385,"density":12.310507774353027,"kind":"tunnel_interior","strate_index":0},{"x":-395.609388380268,"y":-328.74986512949266,"z":-209.39910826118199,"density":10.663330078125,"kind":"tunnel_interior","strate_index":0},{"x":-413.15586037522303,"y":-319.311982710672,"z":-206.58547973632812,"density":10.629812240600586,"kind":"tunnel_interior","strate_index":0},{"x":-301.63920171745531,"y":-282.44920984193709,"z":-218.15206623900031,"density":9.9896306991577148,"kind":"tunnel_interior","strate_index":0},{"x":-285.81292762140902,"y":-264.33858860960379,"z":-182.51513009078377,"density":8.1190319061279297,"kind":"tunnel_interior","strate_index":0},{"x":-279.81462265492223,"y":-238.41502626582385,"z":-146.87819394256721,"density":6.7101449966430664,"kind":"tunnel_interior","strate_index":0},{"x":-249.82213893306192,"y":-231.56611512291707,"z":-111.24125513919205,"density":7.0995125770568848,"kind":"tunnel_interior","strate_index":0},{"x":-235.64078243992876,"y":-212.14783594809697,"z":-75.60432164613411,"density":8.54949951171875,"kind":"tunnel_interior","strate_index":0},{"x":-226.88556651701651,"y":-188.41593753224177,"z":-39.967388153076172,"density":9.3723173141479492,"kind":"tunnel_interior","strate_index":0},{"x":232.77883367861585,"y":-274.15073162323034,"z":-49.998882293701172,"density":9.7515897750854492,"kind":"tunnel_interior","strate_index":0},{"x":198.43622019731581,"y":-263.00978303714851,"z":-37.874925887864151,"density":7.8611879348754883,"kind":"tunnel_interior","strate_index":0},{"x":165.16946100276499,"y":-249.54434189079666,"z":-25.750969482027131,"density":10.37913703918457,"kind":"tunnel_interior","strate_index":0},{"x":135.18735809712086,"y":-228.9820745369104,"z":-13.62701416015625,"density":11.724542617797852,"kind":"tunnel_interior","strate_index":0},{"x":-121.09544542983559,"y":-151.90716135941364,"z":-36.161022186279297,"density":11.182351112365723,"kind":"tunnel_interior","strate_index":0},{"x":-97.085737013429494,"y":-169.64676886441455,"z":-27.504666070450668,"density":10.54229736328125,"kind":"tunnel_interior","strate_index":0},{"x":-67.333542224984726,"y":-167.20279415386423,"z":-18.848309954622039,"density":7.3785500526428223,"kind":"tunnel_interior","strate_index":0},{"x":-40.452716933678047,"y":-174.85104537601774,"z":-10.191954612731934,"density":9.3793325424194336,"kind":"tunnel_interior","strate_index":0},{"x":119.07850455946298,"y":-128.26832803065847,"z":-25.269508361816406,"density":8.5812816619873047,"kind":"tunnel_interior","strate_index":0},{"x":117.93055191667507,"y":-139.46108556538698,"z":-60.805924520981762,"density":10.44908332824707,"kind":"tunnel_interior","strate_index":0},{"x":109.46551344912982,"y":-129.95665730446626,"z":-96.342340680147117,"density":10.697076797485352,"kind":"tunnel_interior","strate_index":0},{"x":105.46515291565372,"y":-133.08106263737454,"z":-131.87875366210938,"density":11.950338363647461,"kind":"tunnel_interior","strate_index":0},{"x":315.3601672548009,"y":-77.45224543635139,"z":-306.0322265625,"density":8.07781982421875,"kind":"tunnel_interior","strate_index":0},{"x":314.67860574102542,"y":-79.209163894727581,"z":-303.04849114804767,"density":9.9314594268798828,"kind":"tunnel_interior","strate_index":0},{"x":315.70393905838193,"y":-80.499402354104504,"z":-300.06475573359535,"density":9.9395084381103516,"kind":"tunnel_interior","strate_index":0},{"x":316.55879285249699,"y":-81.83625126809298,"z":-297.08102058590976,"density":10.277414321899414,"kind":"tunnel_interior","strate_index":0},{"x":260.68193736010755,"y":-17.182255977578833,"z":-116.37564086914062,"density":11.91072940826416,"kind":"tunnel_interior","strate_index":0},{"x":304.33612916710729,"y":-7.5721536408148395,"z":-122.92386901025657,"density":10.167007446289062,"kind":"tunnel_interior","strate_index":0},{"x":343.85891578835611,"y":10.308775434959426,"z":-129.47209715137251,"density":9.0876331329345703,"kind":"tunnel_interior","strate_index":0},{"x":377.01802061146361,"y":40.929407197233864,"z":-136.02032470703125,"density":11.333270072937012,"kind":"tunnel_interior","strate_index":0},{"x":283.13271640075141,"y":50.743503617662867,"z":-293.15032365844127,"density":11.262014389038086,"kind":"tunnel_interior","strate_index":0},{"x":268.10172363825092,"y":82.459195943881738,"z":-297.0463379190121,"density":9.3018798828125,"kind":"tunnel_interior","strate_index":0},{"x":269.9170593963479,"y":118.35374511964737,"z":-300.94235217958294,"density":8.2315807342529297,"kind":"tunnel_interior","strate_index":0},{"x":258.2850349771287,"y":150.91257360695744,"z":-304.83836609182299,"density":8.4343328475952148,"kind":"tunnel_interior","strate_index":0},{"x":501.90410921002206,"y":-44.118463477732647,"z":-181.52365112304688,"density":11.917651176452637,"kind":"tunnel_interior","strate_index":0},{"x":496.23110949792772,"y":-30.244492257122893,"z":-165.68161472155543,"density":11.180980682373047,"kind":"tunnel_interior","strate_index":0},{"x":496.90680766528169,"y":-14.839975346134979,"z":-149.83957832006399,"density":9.9385347366333008,"kind":"tunnel_interior","strate_index":0},{"x":491.48623286722136,"y":-0.90515082611798903,"z":-133.99754333496094,"density":9.2329769134521484,"kind":"tunnel_interior","strate_index":0},{"x":-415.94523998910495,"y":63.967854072571853,"z":-141.56683349609375,"density":11.419425010681152,"kind":"tunnel_interior","strate_index":0},{"x":-375.09438057361382,"y":71.772210270966113,"z":-142.22308350587264,"density":9.9274511337280273,"kind":"tunnel_interior","strate_index":0},{"x":-334.20794416207906,"y":79.492173823016969,"z":-142.87933351565152,"density":9.3115949630737305,"kind":"tunnel_interior","strate_index":0},{"x":-295.60018276971834,"y":92.61741926946037,"z":-143.53558357432485,"density":11.364507675170898,"kind":"tunnel_interior","strate_index":0},{"x":-265.33979570642799,"y":125.54356228726786,"z":-144.1918335352093,"density":10.204947471618652,"kind":"tunnel_interior","strate_index":0},{"x":-228.5449668798091,"y":142.96927260669804,"z":-144.84808349609375,"density":11.40432071685791,"kind":"tunnel_interior","strate_index":0},{"x":-138.99378177090753,"y":248.07619814251933,"z":-145.47715759277344,"density":10.436500549316406,"kind":"tunnel_interior","strate_index":0},{"x":-144.44468041422948,"y":266.2337766218082,"z":-180.36887290476054,"density":10.694616317749023,"kind":"tunnel_interior","strate_index":0},{"x":-148.92887482403779,"y":284.18251095593217,"z":-215.26058821674764,"density":8.1937923431396484,"kind":"tunnel_interior","strate_index":0},{"x":-141.74674270338878,"y":299.61088520057103,"z":-250.1523061283701,"density":6.891211986541748,"kind":"tunnel_interior","strate_index":0},{"x":-123.71551037348966,"y":312.69544690521178,"z":-285.04401884072183,"density":9.0326900482177734,"kind":"tunnel_interior","strate_index":0},{"x":-121.47010482568602,"y":329.19033555964359,"z":-319.93573155307365,"density":8.6103906631469727,"kind":"tunnel_interior","strate_index":0},{"x":-35.045277460130414,"y":218.35798228610733,"z":-76.145484924316406,"density":10.988195419311523,"kind":"tunnel_interior","strate_index":0},{"x":5.5055137104996064,"y":237.84063451300804,"z":-63.517892360687256,"density":9.0924758911132812,"kind":"tunnel_interior","strate_index":0},{"x":38.297113998952945,"y":270.96221666830064,"z":-50.890299797058105,"density":7.416844367980957,"kind":"tunnel_interior","strate_index":0},{"x":79.804534845123101,"y":288.76332692642694,"z":-38.262707233428955,"density":8.2829647064208984,"kind":"tunnel_interior","strate_index":0},{"x":120.99166342768893,"y":307.12743966847711,"z":-25.635114669799805,"density":10.310690879821777,"kind":"tunnel_interior","strate_index":0},{"x":215.32527553081437,"y":379.30838925846763,"z":-31.21124267578125,"density":8.6444377899169922,"kind":"tunnel_interior","strate_index":0},{"x":222.23634949768996,"y":404.23616578077224,"z":-44.609939974507597,"density":7.3606796264648438,"kind":"tunnel_interior","strate_index":0},{"x":236.57016703300138,"y":422.8146062660698,"z":-58.008637273233944,"density":9.80755615234375,"kind":"tunnel_interior","strate_index":0},{"x":261.02579352868747,"y":432.73495607832706,"z":-71.407333374023438,"density":11.147562980651855,"kind":"tunnel_interior","strate_index":0},{"x":-6.5290035074002333,"y":441.5669810533272,"z":-318.78586750605496,"density":9.4854230880737305,"kind":"tunnel_interior","strate_index":0},{"x":-7.7477597483958043,"y":432.75436974129281,"z":-307.17533905040636,"density":9.3743686676025391,"kind":"tunnel_interior","strate_index":0},{"x":-10.539818818907891,"y":424.43599846767682,"z":-295.56481059475777,"density":8.3965568542480469,"kind":"tunnel_interior","strate_index":0},{"x":-14.416655455630647,"y":416.45840189389315,"z":-283.95428317717125,"density":8.7653007507324219,"kind":"tunnel_interior","strate_index":0},{"x":168.79744345677287,"y":448.36780926287679,"z":-290.51433786324162,"density":11.104803085327148,"kind":"tunnel_interior","strate_index":0},{"x":210.50543441434067,"y":438.54163184715497,"z":-289.05569230699075,"density":8.7197027206420898,"kind":"tunnel_interior","strate_index":0},{"x":252.11839535488733,"y":425.90691917934515,"z":-287.59704675073982,"density":8.1642551422119141,"kind":"tunnel_interior","strate_index":0},{"x":294.284677858245,"y":429.62517753699223,"z":-286.13840119448889,"density":9.5366926193237305,"kind":"tunnel_interior","strate_index":0},{"x":336.76702145041008,"y":442.68436560878672,"z":-284.67975563823802,"density":11.451995849609375,"kind":"tunnel_interior","strate_index":0},{"x":-3.291713784805367,"y":496.34454740462337,"z":-289.13457977078212,"density":8.2632427215576172,"kind":"tunnel_interior","strate_index":0},{"x":-12.937677878602457,"y":509.48807705218303,"z":-254.05465919072242,"density":7.7623600959777832,"kind":"tunnel_interior","strate_index":0},{"x":-24.799949691581652,"y":519.80897441712875,"z":-218.97473861066271,"density":9.7940549850463867,"kind":"tunnel_interior","strate_index":0},{"x":-40.345531412785022,"y":525.43890111831638,"z":-183.89482116699219,"density":8.5921430587768555,"kind":"tunnel_interior","strate_index":0},{"x":0,"y":0,"z":-482,"density":-100.4000244140625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-482,"density":-100.471435546875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-482,"density":-100.47076416015625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-482,"density":-65.533935546875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-482,"density":-139.97222900390625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-482,"density":-253.8656005859375,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-482,"density":-28.525909423828125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-482,"density":-255.7069091796875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-482,"density":-336.91278076171875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-482,"density":-100.6134033203125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-482,"density":-100.6846923828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-482,"density":-100.54266357421875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-482,"density":-65.623291015625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-482,"density":-140.11248779296875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-482,"density":-254.0938720703125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-482,"density":-28.7703857421875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-482,"density":-255.72296142578125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-482,"density":-337.09698486328125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-482,"density":-100.61566162109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-482,"density":-100.5440673828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-482,"density":-100.6864013671875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-482,"density":-65.44451904296875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-482,"density":-139.8338623046875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-482,"density":-253.639892578125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-482,"density":-28.282745361328125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-482,"density":-255.690673828125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-482,"density":-336.729248046875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-482,"density":-105.41595458984375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-482,"density":-105.3905029296875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-482,"density":-105.44207763671875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-482,"density":-68.25933837890625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-482,"density":-140.77899169921875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-482,"density":-261.48785400390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-482,"density":-61.25494384765625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-482,"density":-252.66650390625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-482,"density":-346.35992431640625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-482,"density":-100.9278564453125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-482,"density":-100.89337158203125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-482,"density":-100.961669921875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-482,"density":-65.8758544921875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-482,"density":-136.55438232421875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-482,"density":-246.763916015625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-482,"density":-260.72271728515625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-482,"density":-329.520751953125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-482,"density":-132.43231201171875,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-482,"density":-132.363525390625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-482,"density":-132.49981689453125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-482,"density":-104.0762939453125,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-482,"density":-162.87030029296875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-482,"density":-28.369781494140625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-482,"density":-270.38580322265625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-482,"density":-161.958740234375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-482,"density":-227.6322021484375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-482,"density":-383.66748046875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-482,"density":-58.24151611328125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-482,"density":-58.03668212890625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-482,"density":-58.44781494140625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-482,"density":-27.68109130859375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-482,"density":-88.17047119140625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-482,"density":-169.008056640625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-482,"density":-249.58880615234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-482,"density":-281.74359130859375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-482,"density":-352.18707275390625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-482,"density":-350.274658203125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-482,"density":-76.70086669921875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-482,"density":-98.67510986328125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-482,"density":-187.3265380859375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-482,"density":-381.34075927734375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-482,"density":-96.17791748046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-482,"density":-215.371826171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-482,"density":-40.02685546875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-482,"density":-410.824462890625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-482,"density":-410.82855224609375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-482,"density":-410.8182373046875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-482,"density":-371.4847412109375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-482,"density":-488.43096923828125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-482,"density":-209.44854736328125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-482,"density":-556.447998046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-482,"density":-120.65283203125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-482,"density":-656.71044921875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-482,"density":-517.5760498046875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-482,"density":-7.9626240730285645,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-2530,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-481,"density":-101.4000244140625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-481,"density":-101.471435546875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-481,"density":-101.47076416015625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-481,"density":-66.533935546875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-481,"density":-140.97222900390625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-481,"density":-254.8656005859375,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-481,"density":-29.525909423828125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-481,"density":-256.7069091796875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-481,"density":-337.91278076171875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-481,"density":-101.6134033203125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-481,"density":-101.6846923828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-481,"density":-101.54266357421875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-481,"density":-66.623291015625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-481,"density":-141.11248779296875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-481,"density":-255.0938720703125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-481,"density":-29.7703857421875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-481,"density":-256.72296142578125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-481,"density":-338.09698486328125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-481,"density":-101.61566162109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-481,"density":-101.5440673828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-481,"density":-101.6864013671875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-481,"density":-66.44451904296875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-481,"density":-140.8338623046875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-481,"density":-254.639892578125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-481,"density":-29.282745361328125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-481,"density":-256.690673828125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-481,"density":-337.729248046875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-481,"density":-106.41595458984375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-481,"density":-106.3905029296875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-481,"density":-106.44207763671875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-481,"density":-69.25933837890625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-481,"density":-141.77899169921875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-481,"density":-262.48785400390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-481,"density":-62.25494384765625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-481,"density":-253.66650390625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-481,"density":-347.35992431640625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-481,"density":-101.9278564453125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-481,"density":-101.89337158203125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-481,"density":-101.961669921875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-481,"density":-66.8758544921875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-481,"density":-137.55438232421875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-481,"density":-247.763916015625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-481,"density":-261.72271728515625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-481,"density":-330.520751953125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-481,"density":-133.43231201171875,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-481,"density":-133.363525390625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-481,"density":-133.49981689453125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-481,"density":-105.0762939453125,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-481,"density":-163.87030029296875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-481,"density":-29.369781494140625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-481,"density":-271.38580322265625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-481,"density":-162.958740234375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-481,"density":-228.6322021484375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-481,"density":-384.66748046875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-481,"density":-59.24151611328125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-481,"density":-59.03668212890625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-481,"density":-59.44781494140625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-481,"density":-28.68109130859375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-481,"density":-89.17047119140625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-481,"density":-170.008056640625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-481,"density":-250.58880615234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-481,"density":-282.74359130859375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-481,"density":-353.18707275390625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-481,"density":-351.274658203125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-481,"density":-77.70086669921875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-481,"density":-99.67510986328125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-481,"density":-188.3265380859375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-481,"density":-382.34075927734375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-481,"density":-97.17791748046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-481,"density":-216.371826171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-481,"density":-41.02685546875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-481,"density":-411.824462890625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-481,"density":-411.82855224609375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-481,"density":-411.8182373046875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-481,"density":-372.4847412109375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-481,"density":-489.43096923828125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-481,"density":-210.44854736328125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-481,"density":-557.447998046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-481,"density":-121.65283203125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-481,"density":-657.71044921875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-481,"density":-518.5760498046875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-481,"density":-7.9905281066894531,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-2529,"density":1,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-2528,"density":-512.2279052734375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-2528,"density":-512.3701171875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-2528,"density":-512.0867919921875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-2528,"density":-510.15234375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-2528,"density":-507.1849365234375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-2528,"density":-765.3807373046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-2528,"density":-522.892578125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-2528,"density":-511.83447265625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-2528,"density":-510.7490234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-2528,"density":-509.982666015625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-2528,"density":-514.4820556640625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-2528,"density":-512.26708984375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-2528,"density":-512.401611328125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-2528,"density":-512.12109375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-2528,"density":-509.9730224609375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-2528,"density":-507.021728515625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-2528,"density":-765.47900390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-2528,"density":-522.995849609375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-2528,"density":-512.107666015625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-2528,"density":-510.5782470703125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-2528,"density":-509.900634765625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-2528,"density":-514.489990234375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-2528,"density":-512.2008056640625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-2528,"density":-512.3404541015625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-2528,"density":-512.0604248046875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-2528,"density":-510.3564453125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-2528,"density":-507.337646484375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-2528,"density":-765.3770751953125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-2528,"density":-522.775146484375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-2528,"density":-511.5499267578125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-2528,"density":-510.96240234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-2528,"density":-510.0516357421875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-2528,"density":-514.4793701171875,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-2528,"density":-524.897216796875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-2528,"density":-524.9652099609375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-2528,"density":-524.815185546875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-2528,"density":-519.339599609375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-2528,"density":-509.368408203125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-2528,"density":-745.00390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-2528,"density":-512.2891845703125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-2528,"density":-511.8489990234375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-2528,"density":-510.6416015625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-2528,"density":-505.659912109375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-2528,"density":-512.5712890625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-2528,"density":-509.9346923828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-2528,"density":-509.8675537109375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-2528,"density":-510.0078125,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-2528,"density":-505.3487548828125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-2528,"density":-513.1708984375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-2528,"density":-872.143798828125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-2528,"density":-518.2874755859375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-2528,"density":-501.866943359375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-2528,"density":-519.867919921875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-2528,"density":-508.3790283203125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-2528,"density":-514.361572265625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-2528,"density":-502.29052734375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-2528,"density":-502.302490234375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-2528,"density":-502.279052734375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-2528,"density":-501.6341552734375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-2528,"density":-502.2464599609375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-2528,"density":-512.3582763671875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-2528,"density":-522.9918212890625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-2528,"density":-518.294189453125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-2528,"density":-480.79736328125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-2528,"density":-512.560302734375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-2528,"density":-511.253662109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-2528,"density":-507.1072998046875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-2528,"density":-507.1072998046875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-2528,"density":-507.1080322265625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-2528,"density":-499.4364013671875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-2528,"density":-512.3631591796875,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-2528,"density":-518.015380859375,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-2528,"density":-517.1123046875,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-2528,"density":-511.5576171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-2528,"density":-522.8221435546875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-2528,"density":-511.654296875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-2528,"density":-512.3802490234375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-2528,"density":-560.2347412109375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-2528,"density":-566.918701171875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-2528,"density":-553.7835693359375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-2528,"density":-968.0560302734375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-2528,"density":-518.732177734375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-2528,"density":-780.2135009765625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-2528,"density":-514.1539306640625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-2528,"density":-965.1802978515625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-2528,"density":-513.947021484375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-2528,"density":-535.6365966796875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-2528,"density":-511.7764892578125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-2528,"density":-682.766357421875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-2528,"density":-680.9273681640625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-2528,"density":-684.76416015625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-2528,"density":-506.9844970703125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-2528,"density":-932.839111328125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-2528,"density":-496.3270263671875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-2528,"density":-819.694091796875,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-2528,"density":-503.389892578125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-2528,"density":-510.7789306640625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-2528,"density":-989.36083984375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-2528,"density":-857.7628173828125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-2528,"density":-514.6248779296875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-2528,"density":-514.641845703125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-2528,"density":-514.595458984375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-2528,"density":-513.9185791015625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-2528,"density":-512.697265625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-2528,"density":-507.45556640625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-2528,"density":-521.087890625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-2528,"density":-519.3856201171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-2528,"density":-877.723876953125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-2528,"density":-513.55029296875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-2528,"density":-834.738037109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-2528,"density":-1018.0711669921875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-2528,"density":-1022.26025390625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-2528,"density":-1013.7410888671875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-2528,"density":-1053.40966796875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-2528,"density":-504.6270751953125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-2528,"density":-733.1939697265625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-2528,"density":-507.7115478515625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-2528,"density":-507.4107666015625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-2528,"density":-525.262939453125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-2528,"density":-517.4447021484375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-480,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-2528,"density":-525.0247802734375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-2527,"density":-511.2279052734375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-2527,"density":-511.3701171875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-2527,"density":-511.0867919921875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-2527,"density":-509.15234375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-2527,"density":-506.1849365234375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-2527,"density":-764.3807373046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-2527,"density":-521.892578125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-2527,"density":-510.83447265625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-2527,"density":-509.7490234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-2527,"density":-508.982666015625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-2527,"density":-513.4820556640625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-2527,"density":-511.26708984375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-2527,"density":-511.401611328125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-2527,"density":-511.12109375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-2527,"density":-508.9730224609375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-2527,"density":-506.021728515625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-2527,"density":-764.47900390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-2527,"density":-521.995849609375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-2527,"density":-511.107666015625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-2527,"density":-509.5782470703125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-2527,"density":-508.900634765625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-2527,"density":-513.489990234375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-2527,"density":-511.2008056640625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-2527,"density":-511.3404541015625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-2527,"density":-511.0604248046875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-2527,"density":-509.3564453125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-2527,"density":-506.337646484375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-2527,"density":-764.3770751953125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-2527,"density":-521.775146484375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-2527,"density":-510.5499267578125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-2527,"density":-509.96240234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-2527,"density":-509.0516357421875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-2527,"density":-513.4793701171875,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-2527,"density":-523.897216796875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-2527,"density":-523.9652099609375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-2527,"density":-523.815185546875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-2527,"density":-518.339599609375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-2527,"density":-508.368408203125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-2527,"density":-744.00390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-2527,"density":-511.2891845703125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-2527,"density":-510.8489990234375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-2527,"density":-509.6416015625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-2527,"density":-504.659912109375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-2527,"density":-511.5712890625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-2527,"density":-508.9346923828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-2527,"density":-508.8675537109375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-2527,"density":-509.0078125,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-2527,"density":-504.3487548828125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-2527,"density":-512.1708984375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-2527,"density":-871.143798828125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-2527,"density":-517.2874755859375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-2527,"density":-500.866943359375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-2527,"density":-518.867919921875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-2527,"density":-507.3790283203125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-2527,"density":-513.361572265625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-2527,"density":-501.29052734375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-2527,"density":-501.302490234375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-2527,"density":-501.279052734375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-2527,"density":-500.6341552734375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-2527,"density":-501.2464599609375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-2527,"density":-511.3582763671875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-2527,"density":-521.9918212890625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-2527,"density":-517.294189453125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-2527,"density":-479.79736328125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-2527,"density":-511.560302734375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-2527,"density":-510.253662109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-2527,"density":-506.1072998046875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-2527,"density":-506.1072998046875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-2527,"density":-506.1080322265625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-2527,"density":-498.4364013671875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-2527,"density":-511.3631591796875,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-2527,"density":-517.015380859375,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-2527,"density":-516.1123046875,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-2527,"density":-510.5576171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-2527,"density":-521.8221435546875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-2527,"density":-510.654296875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-2527,"density":-511.3802490234375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-2527,"density":-559.2347412109375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-2527,"density":-565.918701171875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-2527,"density":-552.7835693359375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-2527,"density":-967.0560302734375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-2527,"density":-517.732177734375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-2527,"density":-779.2135009765625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-2527,"density":-513.1539306640625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-2527,"density":-964.1802978515625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-2527,"density":-512.947021484375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-2527,"density":-534.6365966796875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-2527,"density":-510.7764892578125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-2527,"density":-681.766357421875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-2527,"density":-679.9273681640625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-2527,"density":-683.76416015625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-2527,"density":-505.9844970703125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-2527,"density":-931.839111328125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-2527,"density":-495.3270263671875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-2527,"density":-818.694091796875,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-2527,"density":-502.389892578125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-2527,"density":-509.7789306640625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-2527,"density":-988.36083984375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-2527,"density":-856.7628173828125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-2527,"density":-513.6248779296875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-2527,"density":-513.641845703125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-2527,"density":-513.595458984375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-2527,"density":-512.9185791015625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-2527,"density":-511.697265625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-2527,"density":-506.45556640625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-2527,"density":-520.087890625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-2527,"density":-518.3856201171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-2527,"density":-876.723876953125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-2527,"density":-512.55029296875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-2527,"density":-833.738037109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-2527,"density":-1017.0711669921875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-2527,"density":-1021.26025390625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-2527,"density":-1012.7410888671875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-2527,"density":-1052.40966796875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-2527,"density":-503.6270751953125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-2527,"density":-732.1939697265625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-2527,"density":-506.7115478515625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-2527,"density":-506.4107666015625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-2527,"density":-524.262939453125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-2527,"density":-516.4447021484375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-479,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-2527,"density":-524.0247802734375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":0,"z":-2526,"density":-510.2279052734375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":0,"z":-2526,"density":-510.3701171875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":0,"z":-2526,"density":-510.0867919921875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":0,"z":-2526,"density":-508.15234375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":0,"z":-2526,"density":-505.1849365234375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":0,"z":-2526,"density":-763.3807373046875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":0,"z":-2526,"density":-520.892578125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":0,"z":-2526,"density":-509.83447265625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":0,"z":-2526,"density":-508.7490234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":0,"z":-2526,"density":-507.982666015625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":0,"z":-2526,"density":-512.4820556640625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-1,"z":-2526,"density":-510.26708984375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-1,"z":-2526,"density":-510.401611328125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-1,"z":-2526,"density":-510.12109375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-1,"z":-2526,"density":-507.9730224609375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-1,"z":-2526,"density":-505.021728515625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-1,"z":-2526,"density":-763.47900390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-1,"z":-2526,"density":-520.995849609375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-1,"z":-2526,"density":-510.107666015625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-1,"z":-2526,"density":-508.5782470703125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-1,"z":-2526,"density":-507.900634765625,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-1,"z":-2526,"density":-512.489990234375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":1,"z":-2526,"density":-510.2008056640625,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":1,"z":-2526,"density":-510.3404541015625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":1,"z":-2526,"density":-510.0604248046875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":1,"z":-2526,"density":-508.3564453125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":1,"z":-2526,"density":-505.337646484375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":1,"z":-2526,"density":-763.3770751953125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":1,"z":-2526,"density":-520.775146484375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":1,"z":-2526,"density":-509.5499267578125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":1,"z":-2526,"density":-508.96240234375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":1,"z":-2526,"density":-508.0516357421875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":1,"z":-2526,"density":-512.4793701171875,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-128,"z":-2526,"density":-522.897216796875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-128,"z":-2526,"density":-522.9652099609375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-128,"z":-2526,"density":-522.815185546875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-128,"z":-2526,"density":-517.339599609375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-128,"z":-2526,"density":-507.368408203125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-128,"z":-2526,"density":-743.00390625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-128,"z":-2526,"density":-510.2891845703125,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-128,"z":-2526,"density":-509.8489990234375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-128,"z":-2526,"density":-508.6416015625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-128,"z":-2526,"density":-503.659912109375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-128,"z":-2526,"density":-510.5712890625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":128,"z":-2526,"density":-507.9346923828125,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":128,"z":-2526,"density":-507.8675537109375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":128,"z":-2526,"density":-508.0078125,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":128,"z":-2526,"density":-503.3487548828125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":128,"z":-2526,"density":-511.1708984375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":128,"z":-2526,"density":-870.143798828125,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":128,"z":-2526,"density":-516.2874755859375,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":128,"z":-2526,"density":-499.866943359375,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":128,"z":-2526,"density":-517.867919921875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":128,"z":-2526,"density":-506.3790283203125,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":128,"z":-2526,"density":-512.361572265625,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-512,"z":-2526,"density":-500.29052734375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-512,"z":-2526,"density":-500.302490234375,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-512,"z":-2526,"density":-500.279052734375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-512,"z":-2526,"density":-499.6341552734375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-512,"z":-2526,"density":-500.2464599609375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-512,"z":-2526,"density":-510.3582763671875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-512,"z":-2526,"density":-520.9918212890625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-512,"z":-2526,"density":-516.294189453125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-512,"z":-2526,"density":-478.79736328125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-512,"z":-2526,"density":-510.560302734375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-512,"z":-2526,"density":-509.253662109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":512,"z":-2526,"density":-505.1072998046875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":512,"z":-2526,"density":-505.1072998046875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":512,"z":-2526,"density":-505.1080322265625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":512,"z":-2526,"density":-497.4364013671875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":512,"z":-2526,"density":-510.3631591796875,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":512,"z":-2526,"density":-516.015380859375,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":512,"z":-2526,"density":-515.1123046875,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":512,"z":-2526,"density":-509.5576171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":512,"z":-2526,"density":-520.8221435546875,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":512,"z":-2526,"density":-509.654296875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":512,"z":-2526,"density":-510.3802490234375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-2048,"z":-2526,"density":-558.2347412109375,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-2048,"z":-2526,"density":-564.918701171875,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-2048,"z":-2526,"density":-551.7835693359375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-2048,"z":-2526,"density":-966.0560302734375,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-2048,"z":-2526,"density":-516.732177734375,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-2048,"z":-2526,"density":-778.2135009765625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-2048,"z":-2526,"density":-512.1539306640625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-2048,"z":-2526,"density":-963.1802978515625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-2048,"z":-2526,"density":-511.947021484375,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-2048,"z":-2526,"density":-533.6365966796875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-2048,"z":-2526,"density":-509.7764892578125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":2048,"z":-2526,"density":-680.766357421875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":2048,"z":-2526,"density":-678.9273681640625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":2048,"z":-2526,"density":-682.76416015625,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":2048,"z":-2526,"density":-504.9844970703125,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":2048,"z":-2526,"density":-930.839111328125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":2048,"z":-2526,"density":-494.3270263671875,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":2048,"z":-2526,"density":-817.694091796875,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":2048,"z":-2526,"density":-501.389892578125,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":2048,"z":-2526,"density":-508.7789306640625,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":2048,"z":-2526,"density":-987.36083984375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":2048,"z":-2526,"density":-855.7628173828125,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":-8192,"z":-2526,"density":-512.6248779296875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":-8192,"z":-2526,"density":-512.641845703125,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":-8192,"z":-2526,"density":-512.595458984375,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":-8192,"z":-2526,"density":-511.9185791015625,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":-8192,"z":-2526,"density":-510.697265625,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":-8192,"z":-2526,"density":-505.45556640625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":-8192,"z":-2526,"density":-519.087890625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":-8192,"z":-2526,"density":-517.3856201171875,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":-8192,"z":-2526,"density":-875.723876953125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":-8192,"z":-2526,"density":-511.55029296875,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":-8192,"z":-2526,"density":-832.738037109375,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":0,"y":8192,"z":-2526,"density":-1016.0711669921875,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-1,"y":8192,"z":-2526,"density":-1020.26025390625,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":1,"y":8192,"z":-2526,"density":-1011.7410888671875,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-128,"y":8192,"z":-2526,"density":-1051.40966796875,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":128,"y":8192,"z":-2526,"density":-502.6270751953125,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-512,"y":8192,"z":-2526,"density":-731.1939697265625,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":512,"y":8192,"z":-2526,"density":-505.7115478515625,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-2048,"y":8192,"z":-2526,"density":-505.4107666015625,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":2048,"y":8192,"z":-2526,"density":-523.262939453125,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":-8192,"y":8192,"z":-2526,"density":-515.4447021484375,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-478,"density":-5,"kind":"strate_boundary","strate_index":1},{"x":8192,"y":8192,"z":-2526,"density":-523.0247802734375,"kind":"strate_boundary","strate_index":1},{"x":1513,"y":2552,"z":-2049,"density":-448.415283203125,"kind":"varied_world","strate_index":1},{"x":249,"y":-2145,"z":-1071,"density":616.844970703125,"kind":"varied_world","strate_index":1},{"x":-853,"y":-3945,"z":-519,"density":-196.112548828125,"kind":"varied_world","strate_index":1},{"x":-1627,"y":-1241,"z":-1294,"density":720.0850830078125,"kind":"varied_world","strate_index":1},{"x":1158,"y":3617,"z":-2087,"density":-142.0062255859375,"kind":"varied_world","strate_index":1},{"x":1234,"y":-2223,"z":-816,"density":425.000244140625,"kind":"varied_world","strate_index":1},{"x":1151,"y":-2943,"z":-1527,"density":500.7763671875,"kind":"varied_world","strate_index":1},{"x":-3618,"y":-2458,"z":-933,"density":323.06005859375,"kind":"varied_world","strate_index":1},{"x":2123,"y":1167,"z":-1953,"density":69.1297607421875,"kind":"varied_world","strate_index":1},{"x":3342,"y":2442,"z":-1751,"density":263.3309326171875,"kind":"varied_world","strate_index":1},{"x":-978,"y":1886,"z":-646,"density":351.91909790039062,"kind":"varied_world","strate_index":1},{"x":1725,"y":274,"z":-2334,"density":-314.8770751953125,"kind":"varied_world","strate_index":1},{"x":42,"y":2948,"z":-1895,"density":120.9920654296875,"kind":"varied_world","strate_index":1},{"x":3121,"y":1933,"z":-2342,"density":-314.9151611328125,"kind":"varied_world","strate_index":1},{"x":784,"y":1887,"z":-2324,"density":-678.177978515625,"kind":"varied_world","strate_index":1},{"x":1937,"y":-3158,"z":-1187,"density":819.90966796875,"kind":"varied_world","strate_index":1},{"x":-3901,"y":-964,"z":-1767,"density":246.708984375,"kind":"varied_world","strate_index":1},{"x":2255,"y":1622,"z":-1798,"density":222.4163818359375,"kind":"varied_world","strate_index":1},{"x":-17,"y":-2338,"z":-640,"density":225.46771240234375,"kind":"varied_world","strate_index":1},{"x":-785,"y":-2579,"z":-670,"density":272.87356567382812,"kind":"varied_world","strate_index":1},{"x":419,"y":-2488,"z":-1168,"density":699.7164306640625,"kind":"varied_world","strate_index":1},{"x":2774,"y":3064,"z":-2408,"density":-397.70654296875,"kind":"varied_world","strate_index":1},{"x":-554,"y":4007,"z":-493,"density":-6.6588163375854492,"kind":"varied_world","strate_index":1},{"x":-3559,"y":-2549,"z":-512,"density":-94.3262939453125,"kind":"varied_world","strate_index":1},{"x":-3728,"y":-976,"z":-2187,"density":-174.8775634765625,"kind":"varied_world","strate_index":1},{"x":-2208,"y":2680,"z":-1503,"density":514.533203125,"kind":"varied_world","strate_index":1},{"x":2696,"y":-3753,"z":-2173,"density":-111.60888671875,"kind":"varied_world","strate_index":1},{"x":2226,"y":1031,"z":-2465,"density":-446.10107421875,"kind":"varied_world","strate_index":1},{"x":-2916,"y":-1287,"z":-1988,"density":32.11962890625,"kind":"varied_world","strate_index":1},{"x":2232,"y":2980,"z":-1658,"density":224.8385009765625,"kind":"varied_world","strate_index":1},{"x":1598,"y":106,"z":-1327,"density":686.8172607421875,"kind":"varied_world","strate_index":1},{"x":80,"y":2271,"z":-1637,"density":384.2347412109375,"kind":"varied_world","strate_index":1},{"x":-1530,"y":3581,"z":-1901,"density":125.433349609375,"kind":"varied_world","strate_index":1},{"x":2007,"y":-3747,"z":-590,"density":336.91668701171875,"kind":"varied_world","strate_index":1},{"x":3093,"y":832,"z":-1546,"density":471.5853271484375,"kind":"varied_world","strate_index":1},{"x":-3223,"y":2266,"z":-1203,"density":813.3428955078125,"kind":"varied_world","strate_index":1},{"x":2324,"y":2201,"z":-779,"density":197.495361328125,"kind":"varied_world","strate_index":1},{"x":1302,"y":1501,"z":-643,"density":88.20672607421875,"kind":"varied_world","strate_index":1},{"x":-3407,"y":722,"z":-2459,"density":-673.6195068359375,"kind":"varied_world","strate_index":1},{"x":786,"y":-1888,"z":-484,"density":-7.8545918464660645,"kind":"varied_world","strate_index":1},{"x":1221,"y":1853,"z":-2364,"density":-711.372314453125,"kind":"varied_world","strate_index":1},{"x":1382,"y":3946,"z":-2393,"density":-377.217529296875,"kind":"varied_world","strate_index":1},{"x":-858,"y":3191,"z":-511,"density":-2.5876479148864746,"kind":"varied_world","strate_index":1},{"x":792,"y":1147,"z":-1111,"density":579.6771240234375,"kind":"varied_world","strate_index":1},{"x":-2238,"y":331,"z":-1346,"density":669.8199462890625,"kind":"varied_world","strate_index":1},{"x":-3672,"y":-3308,"z":-734,"density":8.69744873046875,"kind":"varied_world","strate_index":1},{"x":2053,"y":2627,"z":-1167,"density":455.1051025390625,"kind":"varied_world","strate_index":1},{"x":4083,"y":348,"z":-686,"density":155.13458251953125,"kind":"varied_world","strate_index":1},{"x":350,"y":3406,"z":-1430,"density":332.5059814453125,"kind":"varied_world","strate_index":1},{"x":3481,"y":2524,"z":-2292,"density":-285.3154296875,"kind":"varied_world","strate_index":1},{"x":1201,"y":-137,"z":-836,"density":111.67083740234375,"kind":"varied_world","strate_index":1},{"x":-4083,"y":-2292,"z":-1271,"density":572.72503662109375,"kind":"varied_world","strate_index":1},{"x":402,"y":268,"z":-2422,"density":-406.476806640625,"kind":"varied_world","strate_index":1},{"x":-3118,"y":981,"z":-1582,"density":430.591796875,"kind":"varied_world","strate_index":1},{"x":-3865,"y":3734,"z":-1803,"density":217.80078125,"kind":"varied_world","strate_index":1},{"x":-1559,"y":2852,"z":-1091,"density":923.762451171875,"kind":"varied_world","strate_index":1},{"x":-528,"y":3830,"z":-2482,"density":-476.6773681640625,"kind":"varied_world","strate_index":1},{"x":3189,"y":3268,"z":-1363,"density":623.4891357421875,"kind":"varied_world","strate_index":1},{"x":-3204,"y":1668,"z":-1040,"density":867.02618408203125,"kind":"varied_world","strate_index":1},{"x":-3947,"y":3115,"z":-1882,"density":11.275390625,"kind":"varied_world","strate_index":1},{"x":-2930,"y":-1442,"z":-713,"density":168.70770263671875,"kind":"varied_world","strate_index":1},{"x":3025,"y":-593,"z":-1265,"density":281.826171875,"kind":"varied_world","strate_index":1},{"x":853,"y":3142,"z":-817,"density":180.11346435546875,"kind":"varied_world","strate_index":1},{"x":3,"y":-2947,"z":-1322,"density":692.50732421875,"kind":"varied_world","strate_index":1},{"x":3566,"y":-3044,"z":-2171,"density":-405.8978271484375,"kind":"varied_world","strate_index":1},{"x":-329,"y":-273,"z":-2248,"density":-241.748779296875,"kind":"varied_world","strate_index":1},{"x":916,"y":3801,"z":-1458,"density":292.63623046875,"kind":"varied_world","strate_index":1},{"x":-1365,"y":-2978,"z":-1939,"density":-297.053955078125,"kind":"varied_world","strate_index":1},{"x":1233,"y":2296,"z":-1246,"density":423.504150390625,"kind":"varied_world","strate_index":1},{"x":486,"y":-2517,"z":-1177,"density":707.5919189453125,"kind":"varied_world","strate_index":1},{"x":-3068,"y":2718,"z":-589,"density":512.63861083984375,"kind":"varied_world","strate_index":1},{"x":-3564,"y":2767,"z":-803,"density":553.81109619140625,"kind":"varied_world","strate_index":1},{"x":3859,"y":885,"z":-1852,"density":166.0745849609375,"kind":"varied_world","strate_index":1},{"x":-1541,"y":-3107,"z":-1544,"density":161.586669921875,"kind":"varied_world","strate_index":1},{"x":3383,"y":-1570,"z":-1233,"density":428.2020263671875,"kind":"varied_world","strate_index":1},{"x":-1113,"y":3212,"z":-635,"density":400.64251708984375,"kind":"varied_world","strate_index":1},{"x":2802,"y":-1420,"z":-2226,"density":-510.583740234375,"kind":"varied_world","strate_index":1},{"x":-1646,"y":3004,"z":-1306,"density":710.469482421875,"kind":"varied_world","strate_index":1},{"x":-2088,"y":2027,"z":-853,"density":576.7763671875,"kind":"varied_world","strate_index":1},{"x":2917,"y":-3884,"z":-1502,"density":550.670166015625,"kind":"varied_world","strate_index":1},{"x":3154,"y":-465,"z":-1730,"density":-93.1844482421875,"kind":"varied_world","strate_index":1},{"x":-1298,"y":-1108,"z":-1291,"density":728.5052490234375,"kind":"varied_world","strate_index":1},{"x":1733,"y":-2991,"z":-1590,"density":429.15478515625,"kind":"varied_world","strate_index":1},{"x":1518,"y":-3232,"z":-2470,"density":-449.1019287109375,"kind":"varied_world","strate_index":1},{"x":-647,"y":3334,"z":-1541,"density":478.27978515625,"kind":"varied_world","strate_index":1},{"x":2751,"y":-859,"z":-2306,"density":-582.440673828125,"kind":"varied_world","strate_index":1},{"x":-3251,"y":2987,"z":-497,"density":-5.8544635772705078,"kind":"varied_world","strate_index":1},{"x":868,"y":-3314,"z":-2090,"density":-70.4259033203125,"kind":"varied_world","strate_index":1},{"x":-315,"y":-3670,"z":-2302,"density":-282.2552490234375,"kind":"varied_world","strate_index":1},{"x":-1665,"y":-3831,"z":-601,"density":-58.189208984375,"kind":"varied_world","strate_index":1},{"x":-3275,"y":-3202,"z":-1032,"density":444.432373046875,"kind":"varied_world","strate_index":1},{"x":-2910,"y":3274,"z":-1483,"density":531.6319580078125,"kind":"varied_world","strate_index":1},{"x":2256,"y":1059,"z":-2372,"density":-351.8160400390625,"kind":"varied_world","strate_index":1},{"x":236,"y":-1031,"z":-2059,"density":-40.698974609375,"kind":"varied_world","strate_index":1},{"x":-2066,"y":-321,"z":-1533,"density":487.252685546875,"kind":"varied_world","strate_index":1},{"x":-2989,"y":2606,"z":-1963,"density":56.381103515625,"kind":"varied_world","strate_index":1},{"x":3230,"y":-221,"z":-808,"density":188.627685546875,"kind":"varied_world","strate_index":1},{"x":783,"y":1314,"z":-2276,"density":-512.539794921875,"kind":"varied_world","strate_index":1},{"x":-382,"y":-1947,"z":-919,"density":507.54330444335938,"kind":"varied_world","strate_index":1},{"x":1366,"y":-3237,"z":-1993,"density":23.009765625,"kind":"varied_world","strate_index":1},{"x":1326,"y":-3271,"z":-1504,"density":511.80322265625,"kind":"varied_world","strate_index":1},{"x":102,"y":1149,"z":-1070,"density":693.76513671875,"kind":"varied_world","strate_index":1},{"x":4032,"y":2457,"z":-907,"density":297.1265869140625,"kind":"varied_world","strate_index":1},{"x":-2050,"y":2954,"z":-785,"density":679.59033203125,"kind":"varied_world","strate_index":1},{"x":1557,"y":-1695,"z":-1129,"density":626.88165283203125,"kind":"varied_world","strate_index":1},{"x":-1377,"y":2450,"z":-1621,"density":379.025634765625,"kind":"varied_world","strate_index":1},{"x":2070,"y":-1803,"z":-924,"density":384.77813720703125,"kind":"varied_world","strate_index":1},{"x":1974,"y":-2102,"z":-2063,"density":-43.243408203125,"kind":"varied_world","strate_index":1},{"x":2697,"y":-421,"z":-1341,"density":343.3651123046875,"kind":"varied_world","strate_index":1},{"x":-3727,"y":2465,"z":-2366,"density":-609.867431640625,"kind":"varied_world","strate_index":1},{"x":-2384,"y":-1942,"z":-875,"density":398.52377319335938,"kind":"varied_world","strate_index":1},{"x":-1437,"y":1932,"z":-1738,"density":271.173583984375,"kind":"varied_world","strate_index":1},{"x":93,"y":-1940,"z":-2297,"density":-287.9580078125,"kind":"varied_world","strate_index":1},{"x":-252,"y":-1963,"z":-485,"density":-7.7759995460510254,"kind":"varied_world","strate_index":1},{"x":-1214,"y":3890,"z":-765,"density":445.12893676757812,"kind":"varied_world","strate_index":1},{"x":829,"y":3665,"z":-1170,"density":565.794677734375,"kind":"varied_world","strate_index":1},{"x":647,"y":-3858,"z":-530,"density":-30.080322265625,"kind":"varied_world","strate_index":1},{"x":-3716,"y":1640,"z":-1776,"density":21.0576171875,"kind":"varied_world","strate_index":1},{"x":527,"y":1369,"z":-1069,"density":588.6837158203125,"kind":"varied_world","strate_index":1},{"x":613,"y":-2528,"z":-2099,"density":-80.206298828125,"kind":"varied_world","strate_index":1},{"x":3725,"y":826,"z":-2501,"density":-474.5830078125,"kind":"varied_world","strate_index":1},{"x":-2286,"y":-1608,"z":-1458,"density":339.918701171875,"kind":"varied_world","strate_index":1},{"x":-2646,"y":3691,"z":-2070,"density":-55.4365234375,"kind":"varied_world","strate_index":1},{"x":-3356,"y":2602,"z":-1352,"density":435.2056884765625,"kind":"varied_world","strate_index":1},{"x":-1936,"y":2659,"z":-2124,"density":-117.3770751953125,"kind":"varied_world","strate_index":1},{"x":2639,"y":2657,"z":-1401,"density":616.0953369140625,"kind":"varied_world","strate_index":1},{"x":-2576,"y":2218,"z":-933,"density":750.068115234375,"kind":"varied_world","strate_index":1},{"x":-3971,"y":-3800,"z":-1872,"density":138.302734375,"kind":"varied_world","strate_index":1},{"x":-1900,"y":3930,"z":-2196,"density":-189.860107421875,"kind":"varied_world","strate_index":1},{"x":976,"y":2117,"z":-652,"density":48.81732177734375,"kind":"varied_world","strate_index":1},{"x":-1100,"y":-407,"z":-2163,"density":-357.34130859375,"kind":"varied_world","strate_index":1},{"x":-2349,"y":1785,"z":-1809,"density":213.716552734375,"kind":"varied_world","strate_index":1},{"x":1816,"y":217,"z":-2420,"density":-392.364013671875,"kind":"varied_world","strate_index":1},{"x":3898,"y":1169,"z":-1184,"density":798.0911865234375,"kind":"varied_world","strate_index":1},{"x":2217,"y":3830,"z":-2288,"density":-264.8150634765625,"kind":"varied_world","strate_index":1},{"x":-3219,"y":-792,"z":-1790,"density":217.5699462890625,"kind":"varied_world","strate_index":1},{"x":-4054,"y":-2387,"z":-2216,"density":-239.226806640625,"kind":"varied_world","strate_index":1},{"x":-1882,"y":-3402,"z":-1618,"density":86.02685546875,"kind":"varied_world","strate_index":1},{"x":-1284,"y":-865,"z":-2336,"density":-327.632080078125,"kind":"varied_world","strate_index":1},{"x":-1010,"y":61,"z":-1330,"density":321.8759765625,"kind":"varied_world","strate_index":1},{"x":1237,"y":-1574,"z":-1425,"density":580.24951171875,"kind":"varied_world","strate_index":1},{"x":72,"y":3171,"z":-1357,"density":466.3248291015625,"kind":"varied_world","strate_index":1},{"x":-3019,"y":-2731,"z":-1780,"density":230.1571044921875,"kind":"varied_world","strate_index":1},{"x":-3353,"y":-393,"z":-937,"density":695.6124267578125,"kind":"varied_world","strate_index":1},{"x":959,"y":785,"z":-1000,"density":434.87286376953125,"kind":"varied_world","strate_index":1},{"x":-1651,"y":-1050,"z":-1214,"density":801.7474365234375,"kind":"varied_world","strate_index":1},{"x":-1345,"y":857,"z":-1892,"density":-280.2926025390625,"kind":"varied_world","strate_index":1},{"x":-1500,"y":-2365,"z":-2380,"density":-730.5618896484375,"kind":"varied_world","strate_index":1},{"x":3435,"y":-1789,"z":-2190,"density":-564.956787109375,"kind":"varied_world","strate_index":1},{"x":-3219,"y":2204,"z":-1444,"density":572.978759765625,"kind":"varied_world","strate_index":1},{"x":-2639,"y":2962,"z":-1899,"density":114.3634033203125,"kind":"varied_world","strate_index":1},{"x":-4025,"y":49,"z":-2174,"density":-164.6419677734375,"kind":"varied_world","strate_index":1},{"x":3943,"y":-4082,"z":-1149,"density":638.9342041015625,"kind":"varied_world","strate_index":1},{"x":-191,"y":-1857,"z":-702,"density":284.19473266601562,"kind":"varied_world","strate_index":1},{"x":-1456,"y":-2324,"z":-519,"density":-0.99123221635818481,"kind":"varied_world","strate_index":1},{"x":1767,"y":1042,"z":-2500,"density":-479.838623046875,"kind":"varied_world","strate_index":1},{"x":2557,"y":1208,"z":-1053,"density":636.4969482421875,"kind":"varied_world","strate_index":1},{"x":954,"y":-3003,"z":-1940,"density":68.557861328125,"kind":"varied_world","strate_index":1},{"x":-1342,"y":-935,"z":-1632,"density":376.6925048828125,"kind":"varied_world","strate_index":1},{"x":-708,"y":1059,"z":-798,"density":490.75848388671875,"kind":"varied_world","strate_index":1},{"x":3025,"y":654,"z":-762,"density":315.0338134765625,"kind":"varied_world","strate_index":1},{"x":366,"y":3112,"z":-1098,"density":609.5321044921875,"kind":"varied_world","strate_index":1},{"x":-637,"y":-1818,"z":-796,"density":349.69088745117188,"kind":"varied_world","strate_index":1},{"x":16,"y":-1061,"z":-1733,"density":278.490966796875,"kind":"varied_world","strate_index":1},{"x":-2305,"y":209,"z":-1564,"density":444.90771484375,"kind":"varied_world","strate_index":1},{"x":1666,"y":420,"z":-491,"density":-7.0087676048278809,"kind":"varied_world","strate_index":1},{"x":-761,"y":-1176,"z":-1592,"density":423.9010009765625,"kind":"varied_world","strate_index":1},{"x":2171,"y":1153,"z":-1495,"density":526.00537109375,"kind":"varied_world","strate_index":1},{"x":-1231,"y":-2427,"z":-2305,"density":-607.95361328125,"kind":"varied_world","strate_index":1},{"x":1762,"y":251,"z":-1199,"density":724.002197265625,"kind":"varied_world","strate_index":1},{"x":1824,"y":320,"z":-572,"density":129.18173217773438,"kind":"varied_world","strate_index":1},{"x":2812,"y":-1171,"z":-1451,"density":231.7294921875,"kind":"varied_world","strate_index":1},{"x":2086,"y":-180,"z":-886,"density":323.4757080078125,"kind":"varied_world","strate_index":1},{"x":1689,"y":-1247,"z":-1007,"density":360.5230712890625,"kind":"varied_world","strate_index":1},{"x":2958,"y":-1603,"z":-1076,"density":350.77435302734375,"kind":"varied_world","strate_index":1},{"x":110,"y":527,"z":-2130,"density":-118.021484375,"kind":"varied_world","strate_index":1},{"x":-3018,"y":679,"z":-2144,"density":-125.5457763671875,"kind":"varied_world","strate_index":1},{"x":-1092,"y":495,"z":-2412,"density":-652.8385009765625,"kind":"varied_world","strate_index":1},{"x":340,"y":-628,"z":-1872,"density":152.58203125,"kind":"varied_world","strate_index":1},{"x":2641,"y":1825,"z":-762,"density":242.25921630859375,"kind":"varied_world","strate_index":1},{"x":2642,"y":1790,"z":-1931,"density":80.2332763671875,"kind":"varied_world","strate_index":1},{"x":1399,"y":575,"z":-877,"density":370.34658813476562,"kind":"varied_world","strate_index":1},{"x":574,"y":-54,"z":-1397,"density":618.0740966796875,"kind":"varied_world","strate_index":1},{"x":3047,"y":151,"z":-1738,"density":279.4476318359375,"kind":"varied_world","strate_index":1},{"x":2803,"y":-3565,"z":-1726,"density":283.7615966796875,"kind":"varied_world","strate_index":1},{"x":3487,"y":3981,"z":-1921,"density":104.593994140625,"kind":"varied_world","strate_index":1},{"x":-3041,"y":-2523,"z":-1487,"density":537.10791015625,"kind":"varied_world","strate_index":1},{"x":2483,"y":-2614,"z":-722,"density":359.82595825195312,"kind":"varied_world","strate_index":1},{"x":902,"y":-4034,"z":-2065,"density":-48.3623046875,"kind":"varied_world","strate_index":1},{"x":625,"y":3207,"z":-1359,"density":471.2955322265625,"kind":"varied_world","strate_index":1},{"x":3915,"y":3488,"z":-1151,"density":455.9833984375,"kind":"varied_world","strate_index":1},{"x":-1847,"y":-3755,"z":-1984,"density":-238.6363525390625,"kind":"varied_world","strate_index":1},{"x":-2094,"y":-3064,"z":-1469,"density":247.1824951171875,"kind":"varied_world","strate_index":1},{"x":3548,"y":-3046,"z":-838,"density":315.45721435546875,"kind":"varied_world","strate_index":1},{"x":2500,"y":-1696,"z":-2189,"density":-173.835693359375,"kind":"varied_world","strate_index":1},{"x":-138,"y":2604,"z":-2018,"density":3.3583984375,"kind":"varied_world","strate_index":1},{"x":-3886,"y":-2147,"z":-1402,"density":635.6171875,"kind":"varied_world","strate_index":1},{"x":2020,"y":-392,"z":-2222,"density":-162.506591796875,"kind":"varied_world","strate_index":1},{"x":1699,"y":290,"z":-1856,"density":165.370849609375,"kind":"varied_world","strate_index":1},{"x":194,"y":2923,"z":-2094,"density":-80.7783203125,"kind":"varied_world","strate_index":1},{"x":-887,"y":402,"z":-1943,"density":-357.89453125,"kind":"varied_world","strate_index":1},{"x":-1626,"y":-2675,"z":-702,"density":286.85858154296875,"kind":"varied_world","strate_index":1},{"x":312,"y":3259,"z":-1523,"density":135.773193359375,"kind":"varied_world","strate_index":1},{"x":1098,"y":-1440,"z":-2268,"density":-250.98291015625,"kind":"varied_world","strate_index":1},{"x":587,"y":1677,"z":-2007,"density":-293.3790283203125,"kind":"varied_world","strate_index":1},{"x":-409,"y":1148,"z":-1129,"density":802.1207275390625,"kind":"varied_world","strate_index":1},{"x":865,"y":566,"z":-1424,"density":592.50439453125,"kind":"varied_world","strate_index":1},{"x":1009,"y":-3511,"z":-1597,"density":422.4700927734375,"kind":"varied_world","strate_index":1},{"x":1794,"y":-1735,"z":-2124,"density":-111.2435302734375,"kind":"varied_world","strate_index":1},{"x":2117,"y":1406,"z":-616,"density":170.37612915039062,"kind":"varied_world","strate_index":1},{"x":3736,"y":1038,"z":-1226,"density":791.010986328125,"kind":"varied_world","strate_index":1},{"x":235,"y":1359,"z":-1131,"density":612.5927734375,"kind":"varied_world","strate_index":1},{"x":-2835,"y":-3559,"z":-2108,"density":-186.439453125,"kind":"varied_world","strate_index":1},{"x":1530,"y":2514,"z":-1849,"density":-261.981689453125,"kind":"varied_world","strate_index":1},{"x":-2246,"y":-2829,"z":-2105,"density":-445.34619140625,"kind":"varied_world","strate_index":1},{"x":-1636,"y":2603,"z":-630,"density":453.91793823242188,"kind":"varied_world","strate_index":1},{"x":-3506,"y":-3917,"z":-829,"density":82.6357421875,"kind":"varied_world","strate_index":1},{"x":-194,"y":3857,"z":-1178,"density":785.2098388671875,"kind":"varied_world","strate_index":1},{"x":2447,"y":-327,"z":-2429,"density":-417.9105224609375,"kind":"varied_world","strate_index":1},{"x":-3627,"y":-2534,"z":-934,"density":316.233154296875,"kind":"varied_world","strate_index":1},{"x":2714,"y":3985,"z":-1698,"density":315.005615234375,"kind":"varied_world","strate_index":1},{"x":-2761,"y":3458,"z":-576,"density":439.5615234375,"kind":"varied_world","strate_index":1},{"x":2034,"y":-3632,"z":-2169,"density":-150.948974609375,"kind":"varied_world","strate_index":1},{"x":-2336,"y":-1417,"z":-1914,"density":32.307373046875,"kind":"varied_world","strate_index":1},{"x":1432,"y":-875,"z":-1367,"density":607.32354736328125,"kind":"varied_world","strate_index":1},{"x":3212,"y":746,"z":-842,"density":379.33822631835938,"kind":"varied_world","strate_index":1},{"x":2511,"y":2778,"z":-988,"density":233.14385986328125,"kind":"varied_world","strate_index":1},{"x":240,"y":-2672,"z":-769,"density":340.88442993164062,"kind":"varied_world","strate_index":1},{"x":1,"y":-201,"z":-1106,"density":519.9083251953125,"kind":"varied_world","strate_index":1},{"x":-382,"y":-2743,"z":-1573,"density":120.5126953125,"kind":"varied_world","strate_index":1},{"x":-1342,"y":983,"z":-2052,"density":-441.2509765625,"kind":"varied_world","strate_index":1},{"x":2910,"y":-451,"z":-2096,"density":-495.3968505859375,"kind":"varied_world","strate_index":1},{"x":-3823,"y":3585,"z":-947,"density":673.1708984375,"kind":"varied_world","strate_index":1},{"x":1734,"y":286,"z":-1137,"density":661.9482421875,"kind":"varied_world","strate_index":1},{"x":-3716,"y":3698,"z":-1379,"density":637.39013671875,"kind":"varied_world","strate_index":1},{"x":-2750,"y":2461,"z":-2504,"density":-486.1849365234375,"kind":"varied_world","strate_index":1},{"x":146,"y":3320,"z":-702,"density":277.617919921875,"kind":"varied_world","strate_index":1},{"x":-1226,"y":-368,"z":-671,"density":272.13427734375,"kind":"varied_world","strate_index":1},{"x":3542,"y":3284,"z":-2444,"density":-421.7510986328125,"kind":"varied_world","strate_index":1},{"x":-3093,"y":671,"z":-1152,"density":860.7376708984375,"kind":"varied_world","strate_index":1},{"x":2274,"y":1620,"z":-1724,"density":296.540283203125,"kind":"varied_world","strate_index":1},{"x":-3239,"y":290,"z":-2255,"density":-242.6688232421875,"kind":"varied_world","strate_index":1},{"x":1440,"y":-2400,"z":-2043,"density":-16.0240478515625,"kind":"varied_world","strate_index":1},{"x":-1889,"y":692,"z":-2057,"density":-327.5277099609375,"kind":"varied_world","strate_index":1},{"x":-1942,"y":1809,"z":-1778,"density":238.917236328125,"kind":"varied_world","strate_index":1},{"x":-805,"y":3411,"z":-2195,"density":-179.9473876953125,"kind":"varied_world","strate_index":1},{"x":-81,"y":3969,"z":-2500,"density":-692.855224609375,"kind":"varied_world","strate_index":1},{"x":837,"y":-3263,"z":-1154,"density":724.0118408203125,"kind":"varied_world","strate_index":1},{"x":-486,"y":219,"z":-2513,"density":-821.023681640625,"kind":"varied_world","strate_index":1},{"x":-3406,"y":-2119,"z":-1354,"density":684.9981689453125,"kind":"varied_world","strate_index":1},{"x":-2924,"y":3276,"z":-1343,"density":672.057861328125,"kind":"varied_world","strate_index":1},{"x":-3241,"y":-2234,"z":-786,"density":221.015625,"kind":"varied_world","strate_index":1},{"x":1097,"y":2762,"z":-2411,"density":-659.7584228515625,"kind":"varied_world","strate_index":1},{"x":1104,"y":-1769,"z":-1693,"density":326.5491943359375,"kind":"varied_world","strate_index":1},{"x":1181,"y":-66,"z":-2272,"density":-267.5836181640625,"kind":"varied_world","strate_index":1},{"x":189,"y":-2883,"z":-564,"density":158.25753784179688,"kind":"varied_world","strate_index":1},{"x":-2655,"y":-3457,"z":-1897,"density":-207.597412109375,"kind":"varied_world","strate_index":1},{"x":-3472,"y":-1306,"z":-1728,"density":292.6375732421875,"kind":"varied_world","strate_index":1},{"x":2683,"y":-3627,"z":-1602,"density":429.993408203125,"kind":"varied_world","strate_index":1},{"x":-534,"y":3377,"z":-2144,"density":-118.911376953125,"kind":"varied_world","strate_index":1},{"x":-134,"y":1819,"z":-644,"density":264.62158203125,"kind":"varied_world","strate_index":1},{"x":-257,"y":-2087,"z":-2093,"density":-483.19873046875,"kind":"varied_world","strate_index":1},{"x":2605,"y":-1862,"z":-1183,"density":547.73126220703125,"kind":"varied_world","strate_index":1},{"x":-790,"y":3343,"z":-2478,"density":-464.1385498046875,"kind":"varied_world","strate_index":1},{"x":-728,"y":-133,"z":-2492,"density":-722.0360107421875,"kind":"varied_world","strate_index":1},{"x":1427,"y":987,"z":-2136,"density":-118.22412109375,"kind":"varied_world","strate_index":1},{"x":-3166,"y":-184,"z":-973,"density":793.2117919921875,"kind":"varied_world","strate_index":1},{"x":2226,"y":-3797,"z":-1810,"density":196.083251953125,"kind":"varied_world","strate_index":1},{"x":4044,"y":-2715,"z":-855,"density":274.228515625,"kind":"varied_world","strate_index":1},{"x":2570,"y":1790,"z":-1816,"density":189.3143310546875,"kind":"varied_world","strate_index":1},{"x":2194,"y":-1753,"z":-716,"density":137.0125732421875,"kind":"varied_world","strate_index":1},{"x":-936,"y":-1531,"z":-2004,"density":-29.156005859375,"kind":"varied_world","strate_index":1},{"x":-492,"y":1793,"z":-2015,"density":-1.6099853515625,"kind":"varied_world","strate_index":1},{"x":684,"y":3819,"z":-1110,"density":575.58349609375,"kind":"varied_world","strate_index":1},{"x":3333,"y":3719,"z":-1835,"density":199.853271484375,"kind":"varied_world","strate_index":1},{"x":-1639,"y":-967,"z":-2015,"density":0.120361328125,"kind":"varied_world","strate_index":1},{"x":3962,"y":2080,"z":-800,"density":314.82415771484375,"kind":"varied_world","strate_index":1},{"x":-1156,"y":-3519,"z":-1961,"density":-204.945556640625,"kind":"varied_world","strate_index":1},{"x":545,"y":1382,"z":-1088,"density":601.848876953125,"kind":"varied_world","strate_index":1},{"x":-1989,"y":-2637,"z":-1933,"density":-167.6417236328125,"kind":"varied_world","strate_index":1},{"x":3700,"y":1267,"z":-1903,"density":106.45849609375,"kind":"varied_world","strate_index":1},{"x":2063,"y":-1674,"z":-1908,"density":100.9586181640625,"kind":"varied_world","strate_index":1},{"x":681,"y":1535,"z":-872,"density":333.6402587890625,"kind":"varied_world","strate_index":1},{"x":-58,"y":1816,"z":-906,"density":511.27188110351562,"kind":"varied_world","strate_index":1},{"x":-2784,"y":3973,"z":-2342,"density":-346.8370361328125,"kind":"varied_world","strate_index":1},{"x":-3714,"y":-1883,"z":-1850,"density":200.92529296875,"kind":"varied_world","strate_index":1},{"x":-3916,"y":4029,"z":-2154,"density":-143.709228515625,"kind":"varied_world","strate_index":1},{"x":-2791,"y":2273,"z":-1398,"density":611.0711669921875,"kind":"varied_world","strate_index":1},{"x":-20,"y":2451,"z":-687,"density":287.39645385742188,"kind":"varied_world","strate_index":1},{"x":1534,"y":-546,"z":-1712,"density":290.2242431640625,"kind":"varied_world","strate_index":1},{"x":-994,"y":3159,"z":-1815,"density":191.05322265625,"kind":"varied_world","strate_index":1},{"x":-690,"y":2996,"z":-2008,"density":8.4495849609375,"kind":"varied_world","strate_index":1},{"x":-1621,"y":-3005,"z":-787,"density":360.79391479492188,"kind":"varied_world","strate_index":1},{"x":1917,"y":3942,"z":-1348,"density":558.88726806640625,"kind":"varied_world","strate_index":1},{"x":296,"y":1663,"z":-2384,"density":-756.0438232421875,"kind":"varied_world","strate_index":1},{"x":3969,"y":2935,"z":-533,"density":-165.45440673828125,"kind":"varied_world","strate_index":1},{"x":3245,"y":1836,"z":-821,"density":332.89620971679688,"kind":"varied_world","strate_index":1},{"x":3811,"y":3444,"z":-1957,"density":37.4881591796875,"kind":"varied_world","strate_index":1},{"x":-2334,"y":416,"z":-1681,"density":335.44580078125,"kind":"varied_world","strate_index":1},{"x":1763,"y":-2927,"z":-765,"density":607.1455078125,"kind":"varied_world","strate_index":1},{"x":-955,"y":2100,"z":-2026,"density":-20.20947265625,"kind":"varied_world","strate_index":1},{"x":-3742,"y":741,"z":-1783,"density":-52.743896484375,"kind":"varied_world","strate_index":1},{"x":-834,"y":2117,"z":-2069,"density":-62.7442626953125,"kind":"varied_world","strate_index":1},{"x":-333,"y":-3341,"z":-654,"density":183.36801147460938,"kind":"varied_world","strate_index":1},{"x":600,"y":-2613,"z":-2306,"density":-293.078125,"kind":"varied_world","strate_index":1},{"x":3442,"y":2151,"z":-856,"density":337.7528076171875,"kind":"varied_world","strate_index":1},{"x":3805,"y":-380,"z":-1731,"density":287.2645263671875,"kind":"varied_world","strate_index":1},{"x":-1661,"y":-1469,"z":-1432,"density":391.177001953125,"kind":"varied_world","strate_index":1},{"x":1097,"y":3329,"z":-1882,"density":-114.70361328125,"kind":"varied_world","strate_index":1},{"x":-831,"y":-2661,"z":-1352,"density":135.3740234375,"kind":"varied_world","strate_index":1},{"x":152,"y":-490,"z":-507,"density":-142.314697265625,"kind":"varied_world","strate_index":1},{"x":3418,"y":3799,"z":-2006,"density":17.2491455078125,"kind":"varied_world","strate_index":1},{"x":2811,"y":3304,"z":-1581,"density":429.1593017578125,"kind":"varied_world","strate_index":1},{"x":229,"y":-3147,"z":-920,"density":485.05157470703125,"kind":"varied_world","strate_index":1},{"x":-2878,"y":-1675,"z":-2152,"density":-248.78466796875,"kind":"varied_world","strate_index":1},{"x":1580,"y":3798,"z":-802,"density":31.56085205078125,"kind":"varied_world","strate_index":1},{"x":304,"y":-3877,"z":-1451,"density":564.3406982421875,"kind":"varied_world","strate_index":1},{"x":-2044,"y":-757,"z":-1420,"density":605.8040771484375,"kind":"varied_world","strate_index":1},{"x":800,"y":-1123,"z":-1515,"density":512.339599609375,"kind":"varied_world","strate_index":1},{"x":2097,"y":-3305,"z":-1127,"density":881.0078125,"kind":"varied_world","strate_index":1},{"x":-3080,"y":-3304,"z":-1802,"density":217.1644287109375,"kind":"varied_world","strate_index":1},{"x":-216,"y":3361,"z":-707,"density":367.197509765625,"kind":"varied_world","strate_index":1},{"x":-3440,"y":-3615,"z":-978,"density":282.32196044921875,"kind":"varied_world","strate_index":1},{"x":-3192,"y":-2240,"z":-2278,"density":-273.903564453125,"kind":"varied_world","strate_index":1},{"x":2181,"y":299,"z":-1058,"density":618.2608642578125,"kind":"varied_world","strate_index":1},{"x":-95,"y":-850,"z":-1159,"density":539.87396240234375,"kind":"varied_world","strate_index":1},{"x":1917,"y":1367,"z":-625,"density":190.08065795898438,"kind":"varied_world","strate_index":1},{"x":701,"y":-3957,"z":-2167,"density":-147.929931640625,"kind":"varied_world","strate_index":1},{"x":-3331,"y":959,"z":-1735,"density":243.1683349609375,"kind":"varied_world","strate_index":1},{"x":-763,"y":-746,"z":-1576,"density":438.9434814453125,"kind":"varied_world","strate_index":1},{"x":-525,"y":350,"z":-1328,"density":549.9259033203125,"kind":"varied_world","strate_index":1},{"x":-2657,"y":-3369,"z":-877,"density":327.50286865234375,"kind":"varied_world","strate_index":1},{"x":-3633,"y":-3269,"z":-1506,"density":506.772216796875,"kind":"varied_world","strate_index":1},{"x":-3770,"y":-286,"z":-1026,"density":680.7042236328125,"kind":"varied_world","strate_index":1},{"x":-255,"y":-834,"z":-1098,"density":498.45025634765625,"kind":"varied_world","strate_index":1},{"x":-3594,"y":-1184,"z":-1191,"density":705.3482666015625,"kind":"varied_world","strate_index":1},{"x":2284,"y":2751,"z":-2195,"density":-371.989990234375,"kind":"varied_world","strate_index":1},{"x":-933,"y":1700,"z":-719,"density":416.201416015625,"kind":"varied_world","strate_index":1},{"x":-1857,"y":-3365,"z":-2378,"density":-669.0439453125,"kind":"varied_world","strate_index":1},{"x":-1145,"y":-3910,"z":-640,"density":-56.12774658203125,"kind":"varied_world","strate_index":1},{"x":-3960,"y":751,"z":-2384,"density":-601.4302978515625,"kind":"varied_world","strate_index":1},{"x":1292,"y":3041,"z":-734,"density":-11.712158203125,"kind":"varied_world","strate_index":1},{"x":-1893,"y":-3453,"z":-1377,"density":335.290283203125,"kind":"varied_world","strate_index":1},{"x":-773,"y":-125,"z":-828,"density":399.49887084960938,"kind":"varied_world","strate_index":1},{"x":718,"y":-2524,"z":-1347,"density":659.138427734375,"kind":"varied_world","strate_index":1},{"x":73,"y":-2713,"z":-1240,"density":783.4207763671875,"kind":"varied_world","strate_index":1},{"x":-168,"y":-45,"z":-717,"density":175.618408203125,"kind":"varied_world","strate_index":1},{"x":-3310,"y":-3259,"z":-908,"density":303.830078125,"kind":"varied_world","strate_index":1},{"x":3080,"y":1899,"z":-1528,"density":500.23779296875,"kind":"varied_world","strate_index":1},{"x":3238,"y":-344,"z":-1962,"density":6.1339111328125,"kind":"varied_world","strate_index":1},{"x":-444,"y":-331,"z":-1110,"density":621.0452880859375,"kind":"varied_world","strate_index":1},{"x":958,"y":793,"z":-1999,"density":26.382080078125,"kind":"varied_world","strate_index":1},{"x":-1851,"y":-548,"z":-1398,"density":606.212890625,"kind":"varied_world","strate_index":1},{"x":1888,"y":2430,"z":-2373,"density":-788.354248046875,"kind":"varied_world","strate_index":1},{"x":1320,"y":-3209,"z":-2063,"density":-41.157470703125,"kind":"varied_world","strate_index":1},{"x":642,"y":381,"z":-674,"density":-29.21282958984375,"kind":"varied_world","strate_index":1},{"x":-2699,"y":-562,"z":-1780,"density":236.740234375,"kind":"varied_world","strate_index":1},{"x":-1908,"y":-2064,"z":-1460,"density":173.8951416015625,"kind":"varied_world","strate_index":1},{"x":112,"y":-266,"z":-1151,"density":527.53985595703125,"kind":"varied_world","strate_index":1},{"x":-1094,"y":-2331,"z":-1410,"density":256.68603515625,"kind":"varied_world","strate_index":1},{"x":2811,"y":352,"z":-2103,"density":-87.2017822265625,"kind":"varied_world","strate_index":1},{"x":3024,"y":371,"z":-596,"density":90.4150390625,"kind":"varied_world","strate_index":1},{"x":-245,"y":-3100,"z":-2378,"density":-393.3609619140625,"kind":"varied_world","strate_index":1},{"x":-1997,"y":1667,"z":-486,"density":-7.6820478439331055,"kind":"varied_world","strate_index":1},{"x":-1096,"y":2521,"z":-2506,"density":-490.2982177734375,"kind":"varied_world","strate_index":1},{"x":822,"y":3863,"z":-1183,"density":583.0166015625,"kind":"varied_world","strate_index":1},{"x":3970,"y":-1368,"z":-1649,"density":74.067138671875,"kind":"varied_world","strate_index":1},{"x":669,"y":-2877,"z":-950,"density":529.0108642578125,"kind":"varied_world","strate_index":1},{"x":-880,"y":-1178,"z":-1569,"density":438.9840087890625,"kind":"varied_world","strate_index":1},{"x":2462,"y":563,"z":-1620,"density":400.018310546875,"kind":"varied_world","strate_index":1},{"x":-1086,"y":-3760,"z":-1897,"density":-249.62548828125,"kind":"varied_world","strate_index":1},{"x":-4015,"y":-250,"z":-1922,"density":94.6148681640625,"kind":"varied_world","strate_index":1},{"x":-666,"y":3237,"z":-2516,"density":-501.189453125,"kind":"varied_world","strate_index":1},{"x":577,"y":272,"z":-632,"density":-87.86090087890625,"kind":"varied_world","strate_index":1},{"x":818,"y":3362,"z":-2303,"density":-513.455322265625,"kind":"varied_world","strate_index":1},{"x":1787,"y":3088,"z":-2367,"density":-633.0997314453125,"kind":"varied_world","strate_index":1},{"x":3213,"y":2963,"z":-2311,"density":-296.52978515625,"kind":"varied_world","strate_index":1},{"x":2767,"y":-774,"z":-2293,"density":-593.3033447265625,"kind":"varied_world","strate_index":1},{"x":2657,"y":-1221,"z":-842,"density":120.005859375,"kind":"varied_world","strate_index":1},{"x":-3962,"y":-2898,"z":-540,"density":-224.7603759765625,"kind":"varied_world","strate_index":1},{"x":-1867,"y":-3763,"z":-2085,"density":-344.7484130859375,"kind":"varied_world","strate_index":1},{"x":-604,"y":-677,"z":-2121,"density":-113.644775390625,"kind":"varied_world","strate_index":1},{"x":-4087,"y":-2602,"z":-704,"density":-57.7625732421875,"kind":"varied_world","strate_index":1},{"x":1789,"y":-997,"z":-1555,"density":460.422119140625,"kind":"varied_world","strate_index":1},{"x":0,"y":0,"z":-1504,"density":511.7720947265625,"kind":"room_interior","strate_index":1},{"x":439.33547973632812,"y":-306.51235961914062,"z":-1994.2236328125,"density":24.54248046875,"kind":"room_interior","strate_index":1},{"x":-39.307083129882812,"y":-164.59074401855469,"z":-1209.8853759765625,"density":634.15911865234375,"kind":"room_interior","strate_index":1},{"x":178.1148681640625,"y":-236.73075866699219,"z":-968.4700927734375,"density":327.4144287109375,"kind":"room_interior","strate_index":1},{"x":-308.69970703125,"y":-93.156089782714844,"z":-1828.831298828125,"density":178.4454345703125,"kind":"room_interior","strate_index":1},{"x":-69.159515380859375,"y":-94.998428344726562,"z":-1857.5765380859375,"density":159.7603759765625,"kind":"room_interior","strate_index":1},{"x":-421.1346435546875,"y":51.045860290527344,"z":-1526.951904296875,"density":323.062744140625,"kind":"room_interior","strate_index":1},{"x":-196.88992309570312,"y":88.158370971679688,"z":-667.3890380859375,"density":138.70330810546875,"kind":"room_interior","strate_index":1},{"x":-101.37276458740234,"y":67.282157897949219,"z":-1075.8331298828125,"density":518.833251953125,"kind":"room_interior","strate_index":1},{"x":308.57901000976562,"y":55.220149993896484,"z":-1447.2449951171875,"density":565.4754638671875,"kind":"room_interior","strate_index":1},{"x":74.850471496582031,"y":182.37103271484375,"z":-665.7684326171875,"density":63.13885498046875,"kind":"room_interior","strate_index":1},{"x":27.981837138151533,"y":5.007343966216661,"z":-1526.40380859375,"density":490.4888916015625,"kind":"tunnel_interior","strate_index":1},{"x":69.943372663957462,"y":18.637831707390855,"z":-1515.2938025660314,"density":499.249755859375,"kind":"tunnel_interior","strate_index":1},{"x":115.87313505169242,"y":11.679305452988199,"z":-1504.1837965383129,"density":512.90087890625,"kind":"tunnel_interior","strate_index":1},{"x":159.71651019322837,"y":15.545909824442347,"z":-1493.0737915039062,"density":529.0028076171875,"kind":"tunnel_interior","strate_index":1},{"x":201.59036728444593,"y":29.631334911295692,"z":-1481.9637844828758,"density":533.683837890625,"kind":"tunnel_interior","strate_index":1},{"x":241.79609487353841,"y":52.371712082697805,"z":-1470.853781435093,"density":539.1705322265625,"kind":"tunnel_interior","strate_index":1},{"x":285.93147341531414,"y":54.723307825377788,"z":-1459.7437744140625,"density":551.1632080078125,"kind":"tunnel_interior","strate_index":1},{"x":437.17800055814138,"y":-309.60567882129408,"z":-2002.2777099609375,"density":16.202880859375,"kind":"tunnel_interior","strate_index":1},{"x":464.10862511072804,"y":-279.00439216540559,"z":-2004.6083781619072,"density":13.9698486328125,"kind":"tunnel_interior","strate_index":1},{"x":484.91555624241005,"y":-244.42280886646427,"z":-2006.939046362877,"density":11.4942626953125,"kind":"tunnel_interior","strate_index":1},{"x":492.37393631520416,"y":-201.16489855709398,"z":-2009.2697143554688,"density":12.3746337890625,"kind":"tunnel_interior","strate_index":1},{"x":528.5325021973257,"y":-176.56161262294646,"z":-2011.6003827648165,"density":6.5992431640625,"kind":"tunnel_interior","strate_index":1},{"x":551.53527750792853,"y":-143.40730362189535,"z":-2013.9310503406523,"density":0.3258056640625,"kind":"tunnel_interior","strate_index":1},{"x":-29.611163637808644,"y":-175.45019703415261,"z":-1221.3983154296875,"density":642.4898681640625,"kind":"tunnel_interior","strate_index":1},{"x":0.21089149297208709,"y":-180.39822749882612,"z":-1186.6448348721797,"density":601.3843994140625,"kind":"tunnel_interior","strate_index":1},{"x":28.787532797524637,"y":-189.52443919353578,"z":-1151.8913543146718,"density":557.8394775390625,"kind":"tunnel_interior","strate_index":1},{"x":57.113335273941338,"y":-199.49216751639878,"z":-1117.1378773822344,"density":514.07696533203125,"kind":"tunnel_interior","strate_index":1},{"x":86.10118551143627,"y":-207.2388443374127,"z":-1082.3843931996562,"density":469.46990966796875,"kind":"tunnel_interior","strate_index":1},{"x":110.91022333139129,"y":-229.00480371298809,"z":-1047.6309162672187,"density":427.353759765625,"kind":"tunnel_interior","strate_index":1},{"x":141.20025550029496,"y":-232.38282871010284,"z":-1012.8774393347812,"density":383.19244384765625,"kind":"tunnel_interior","strate_index":1},{"x":171.59081612932715,"y":-235.4235954269995,"z":-978.12396240234375,"density":339.2451171875,"kind":"tunnel_interior","strate_index":1},{"x":-48.188778741322999,"y":-149.77527234187079,"z":-1214.2568359375,"density":643.81524658203125,"kind":"tunnel_interior","strate_index":1},{"x":-49.341132225607225,"y":-114.11727279066213,"z":-1192.9708245609399,"density":621.1484375,"kind":"tunnel_interior","strate_index":1},{"x":-63.975692062885223,"y":-81.498781603804218,"z":-1171.6848131843799,"density":605.79632568359375,"kind":"tunnel_interior","strate_index":1},{"x":-70.856569352342092,"y":-47.132257561925591,"z":-1150.3988037109375,"density":587.97088623046875,"kind":"tunnel_interior","strate_index":1},{"x":-76.374121446707335,"y":-12.458370999688265,"z":-1129.1127904312598,"density":568.23602294921875,"kind":"tunnel_interior","strate_index":1},{"x":-92.394679730152163,"y":19.847642863647049,"z":-1107.8267847640527,"density":551.7952880859375,"kind":"tunnel_interior","strate_index":1},{"x":-94.423953169804534,"y":55.307947603338818,"z":-1086.540771484375,"density":527.3082275390625,"kind":"tunnel_interior","strate_index":1},{"x":-556.11715102524215,"y":-34.191562382582298,"z":-1377.7708740234375,"density":347.2315673828125,"kind":"tunnel_interior","strate_index":1},{"x":-531.23622570943166,"y":-18.992029030686542,"z":-1409.217822734221,"density":306.859619140625,"kind":"tunnel_interior","strate_index":1},{"x":-505.86145179458725,"y":-4.5709428091375344,"z":-1440.6647714450046,"density":328.2177734375,"kind":"tunnel_interior","strate_index":1},{"x":-482.8507142205151,"y":13.576547236243309,"z":-1472.1117224987684,"density":327.8785400390625,"kind":"tunnel_interior","strate_index":1},{"x":-458.20383879606123,"y":29.145006336466871,"z":-1503.5586688665717,"density":324.73828125,"kind":"tunnel_interior","strate_index":1},{"x":-433.03560067936894,"y":43.891648198701695,"z":-1535.005615234375,"density":311.5777587890625,"kind":"tunnel_interior","strate_index":1},{"x":-306.4998993677807,"y":-91.62849037890679,"z":-1839.052490234375,"density":168.3753662109375,"kind":"tunnel_interior","strate_index":1},{"x":-261.22992262352454,"y":-87.631800517721942,"z":-1844.0151612067621,"density":165.1883544921875,"kind":"tunnel_interior","strate_index":1},{"x":-215.71621047392921,"y":-78.413633911213481,"z":-1848.9778321791491,"density":160.9283447265625,"kind":"tunnel_interior","strate_index":1},{"x":-171.26445420308286,"y":-91.945528817298523,"z":-1853.940503521284,"density":154.497314453125,"kind":"tunnel_interior","strate_index":1},{"x":-126.89768832105226,"y":-107.29800559793529,"z":-1858.9031741239232,"density":155.11083984375,"kind":"tunnel_interior","strate_index":1},{"x":-81.57294273590459,"y":-102.12794591999925,"z":-1863.8658447265625,"density":154.286376953125,"kind":"tunnel_interior","strate_index":1},{"x":-316.60230654067982,"y":-86.789499307681666,"z":-1835.0538330078125,"density":172.90380859375,"kind":"tunnel_interior","strate_index":1},{"x":-333.16890873078177,"y":-73.436451033810414,"z":-1796.8052825927734,"density":206.625244140625,"kind":"tunnel_interior","strate_index":1},{"x":-350.82527081740852,"y":-60.908773283095833,"z":-1758.5567321777344,"density":213.3721923828125,"kind":"tunnel_interior","strate_index":1},{"x":-358.94951897576715,"y":-41.161591598668416,"z":-1720.3081817626953,"density":236.953369140625,"kind":"tunnel_interior","strate_index":1},{"x":-377.87896407317101,"y":-29.598131006226748,"z":-1682.0596313476562,"density":232.413330078125,"kind":"tunnel_interior","strate_index":1},{"x":-386.18767721904055,"y":-9.9906607974478074,"z":-1643.8110809326172,"density":253.0970458984375,"kind":"tunnel_interior","strate_index":1},{"x":-402.06027058522108,"y":3.8880210842502461,"z":-1605.5625305175781,"density":263.533935546875,"kind":"tunnel_interior","strate_index":1},{"x":-406.72684916225546,"y":26.253998487203507,"z":-1567.3139801025391,"density":291.4359130859375,"kind":"tunnel_interior","strate_index":1},{"x":-416.32870089344937,"y":44.882061696250879,"z":-1529.0654296875,"density":321.115234375,"kind":"tunnel_interior","strate_index":1},{"x":-82.716994155478517,"y":-98.726994120141768,"z":-1862.3597412109375,"density":156.235107421875,"kind":"tunnel_interior","strate_index":1},{"x":-113.12604720034422,"y":-75.148011344649149,"z":-1887.2096481532881,"density":133.1798095703125,"kind":"tunnel_interior","strate_index":1},{"x":-143.1371408463892,"y":-51.002654449371576,"z":-1912.0595550956386,"density":106.4586181640625,"kind":"tunnel_interior","strate_index":1},{"x":-167.87885421001917,"y":-19.357944974792304,"z":-1936.909459445942,"density":80.8193359375,"kind":"tunnel_interior","strate_index":1},{"x":-199.21010763572409,"y":2.9085731240285728,"z":-1961.7593689803398,"density":52.9990234375,"kind":"tunnel_interior","strate_index":1},{"x":-241.45405844230578,"y":9.6441728869293968,"z":-1986.6092733306432,"density":24.282470703125,"kind":"tunnel_interior","strate_index":1},{"x":-174.87933792157716,"y":95.789462473148731,"z":-682.187255859375,"density":148.848876953125,"kind":"tunnel_interior","strate_index":1},{"x":-136.75469221311303,"y":109.16796190893301,"z":-682.42803955795716,"density":137.19110107421875,"kind":"tunnel_interior","strate_index":1},{"x":-96.030726173269954,"y":115.71277725498176,"z":-682.66882325653933,"density":127.78076171875,"kind":"tunnel_interior","strate_index":1},{"x":-63.025369418990394,"y":142.55001690533862,"z":-682.90960693359375,"density":118.7542724609375,"kind":"tunnel_interior","strate_index":1},{"x":-28.036310803410291,"y":164.17207053966905,"z":-683.15039065370365,"density":107.32415771484375,"kind":"tunnel_interior","strate_index":1},{"x":13.150938698249149,"y":169.49887054867636,"z":-683.3911742877026,"density":98.3656005859375,"kind":"tunnel_interior","strate_index":1},{"x":51.629687827556452,"y":181.9464325846175,"z":-683.6319580078125,"density":87.71929931640625,"kind":"tunnel_interior","strate_index":1},{"x":450.31762559026254,"y":323.07480409152288,"z":-1979.82470703125,"density":44.1510009765625,"kind":"tunnel_interior","strate_index":1},{"x":412.16077774752273,"y":333.25424204897399,"z":-1988.2956748943816,"density":30.27880859375,"kind":"tunnel_interior","strate_index":1},{"x":376.76423599123183,"y":350.62079149752475,"z":-1996.7666427575132,"density":21.7894287109375,"kind":"tunnel_interior","strate_index":1},{"x":337.94631161803972,"y":359.07895014368182,"z":-2005.2376098632812,"density":12.8125,"kind":"tunnel_interior","strate_index":1},{"x":299.94839510317678,"y":369.67221329588949,"z":-2013.7085784837764,"density":5.064208984375,"kind":"tunnel_interior","strate_index":1},{"x":-148.75058787323334,"y":29.214849624857678,"z":-106.53872680664062,"density":28,"kind":"inter_strate_passage","strate_index":0},{"x":-131.85946104615306,"y":37.914840114018489,"z":-106.53872680664062,"density":28,"kind":"inter_strate_passage","strate_index":0},{"x":-126.52542099549612,"y":40.662205531648219,"z":-106.53872680664062,"density":28,"kind":"inter_strate_passage","strate_index":0},{"x":1050.6943532215714,"y":647.0042775668071,"z":-463.356201171875,"density":18,"kind":"inter_strate_passage","strate_index":0},{"x":1047.0953044633411,"y":653.99187015186294,"z":-463.356201171875,"density":18,"kind":"inter_strate_passage","strate_index":0},{"x":-126.54174773727289,"y":40.693675331255868,"z":-816.17364501953125,"density":268.84783935546875,"kind":"inter_strate_passage","strate_index":0},{"x":-131.85946104615306,"y":37.914840114018489,"z":-816.17364501953125,"density":270.8858642578125,"kind":"inter_strate_passage","strate_index":0},{"x":-148.75058787323334,"y":29.214849624857678,"z":-816.17364501953125,"density":274.49322509765625,"kind":"inter_strate_passage","strate_index":0},{"x":-156.75164794921875,"y":25.093801498413086,"z":-114.03872680664062,"density":13.357421875,"kind":"passage_landing","strate_index":0},{"x":-156.75164794921875,"y":25.093801498413086,"z":-823.67364501953125,"density":284.04571533203125,"kind":"passage_landing","strate_index":1},{"x":0,"y":0,"z":64,"density":6,"kind":"inter_strate_passage","strate_index":0},{"x":0,"y":0,"z":48,"density":6,"kind":"inter_strate_passage","strate_index":0},{"x":0,"y":0,"z":0,"density":28,"kind":"passage_landing","strate_index":0},{"x":0,"y":0,"z":0,"density":28,"kind":"passage_landing","strate_index":0}],"viewer_start":{"found_open_space":true,"x":-46.449775695800781,"y":-472.88494873046875,"z":-79.380828857421875}} \ No newline at end of file diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 8dd4a08..2e491b6 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism', 'symbolize-stacks', 'region-eval', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game')] + [ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism', 'symbolize-stacks', 'region-eval', 'standalone-export', 'compile-only', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game')] [string]$Scenario = 'canonical', [switch]$Build, [switch]$EnableRender, @@ -634,7 +634,7 @@ function Sync-OwnerAssets { $null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru [void]$rows.Add([ordered]@{ name = $assetName - used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game') + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'standalone-export', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -729,7 +729,7 @@ function Get-AssetEvidence { $target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" [void]$rows.Add([ordered]@{ name = $assetName - used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game') + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'standalone-export', 'step2d', 'step2e', 'step2f', 'step3-scale', 'step2f-capture', 'step3-game') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -1660,6 +1660,7 @@ try { $commandletLedger = $null $gameLedger = $null switch ($Scenario) { + 'compile-only' { } 'canonical' { $baseCvars['voxel.OuterClassifierMode'] = 0 $args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\canonical.log') (Merge-Cvars $baseCvars) @@ -1679,6 +1680,40 @@ try { (Join-Path $RunRoot 'Logs\owner.log') (Join-Path $RunRoot 'Logs\owner.stdout.log') (Join-Path $RunRoot 'Logs\owner.stderr.log') $CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner assets' } + 'standalone-export' { + $exportLog = Join-Path $RunRoot 'Logs\standalone-export.log' + $args = New-CommonArguments 'commandlet' $exportLog (Merge-Cvars $baseCvars) + $args += @( + '-seed=0', '-archetype=TunnelNetwork', '-slot=0', '-modes=export', + '-blockearlyout=0', '-gameconfig=1', '-standaloneworldexport=1', + '-settingsref=/Game/VoxelForge/DA_Settings.DA_Settings', + ('-out="{0}"' -f $commandletOut) + ) + $commandletLedger = Invoke-UnrealLaunch 'standalone-export-commandlet' $args ` + $exportLog (Join-Path $RunRoot 'Logs\standalone-export.stdout.log') ` + (Join-Path $RunRoot 'Logs\standalone-export.stderr.log') + $ownerWorldPath = Join-Path $commandletOut 'owner_world.json' + if ($commandletLedger.status -eq 'passed' -and (Test-Path -LiteralPath $ownerWorldPath -PathType Leaf)) { + $standaloneData = Join-Path $PluginRoot 'Standalone\data' + Ensure-Directory $standaloneData + Copy-Item -LiteralPath $ownerWorldPath -Destination (Join-Path $standaloneData 'owner_world.json') -Force + $exported = Read-JsonFile $ownerWorldPath + $CommandletMetrics = [ordered]@{ + status = 'exported' + output = $ownerWorldPath + standalone_data = Join-Path $standaloneData 'owner_world.json' + seed = Get-JsonValue $exported 'seed' + voxel_size_cm = Get-JsonValue $exported 'voxel_size_cm' + strate_count = @((Get-JsonValue $exported 'strates')).Count + passage_count = @((Get-JsonValue $exported 'passages')).Count + density_sample_count = @((Get-JsonValue $exported 'density_samples')).Count + viewer_start = Get-JsonValue $exported 'viewer_start' + } + } else { + Add-Failure "standalone exporter did not produce $ownerWorldPath" + $CommandletMetrics = [ordered]@{ status = 'missing'; output = $ownerWorldPath } + } + } 'probe' { $args = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\probe.log') (Merge-Cvars $baseCvars) $args += New-CommandletArguments $commandletOut 'tunnelcoreprobe' ($Assets -eq 'owner') $false 128