- Export commandlet writes the live Core adapter's world_assembly + resolved layout pair; the headless owner path imports that pair (manifest/layout equal, layout_chunk_width_q validated). owner_world.json re-exported from the owner's staged assets on the Linux host. - Adapter SW route: Core clearance/radius floors, 0.5 m support search, outward-facing tie-break; passage mouth/mid radius floors match Core (36/24 owner voxels). - FI in Unreal now uses the legacy (compiled analytic) generator unless a plan is imported: the auto native-FI plan failed the passage audit on the owner plan (1843 blocked samples, 945 open-air violations). Owner decides whether native FI returns. - In-engine spawn traces per strate (3 each): SW GenerateTile 42.7 worker-s vs 20.1 headless for equal tile counts; LOD0 p95 45-169 ms in UE. - Fast tier green; all three passages pass on the shared plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
21772 lines
1.0 MiB
Plaintext
21772 lines
1.0 MiB
Plaintext
#include "VoxelForgeExploreCommandlet.h"
|
|
#include "VoxelStackOfflineSymbolizer.h"
|
|
#include "VoxelForgeRegionEvalExperiment.h"
|
|
|
|
#include "CoreMinimal.h"
|
|
#include "EdGraph/EdGraph.h"
|
|
#include "EdGraph/EdGraphNode.h"
|
|
#include "EdGraph/EdGraphPin.h"
|
|
#include "Async/ParallelFor.h"
|
|
#include "Commandlets/Commandlet.h"
|
|
#include "Engine/Blueprint.h"
|
|
#include "Containers/StringConv.h"
|
|
#include "Components/SceneComponent.h"
|
|
#include "Components/HierarchicalInstancedStaticMeshComponent.h"
|
|
#include "Engine/Engine.h"
|
|
#include "Engine/Player.h"
|
|
#include "Engine/StaticMesh.h"
|
|
#include "Engine/GameInstance.h"
|
|
#include "Engine/World.h"
|
|
#include "GameFramework/Character.h"
|
|
#include "GameFramework/CharacterMovementComponent.h"
|
|
#include "GameFramework/Actor.h"
|
|
#include "GameFramework/GameModeBase.h"
|
|
#include "GameFramework/Pawn.h"
|
|
#include "GameFramework/PlayerController.h"
|
|
#include "GameFramework/WorldSettings.h"
|
|
#include "RealtimeMeshComponent.h"
|
|
#include "PhysicsEngine/BodySetup.h"
|
|
#include "HAL/FileManager.h"
|
|
#include "HAL/PlatformProcess.h"
|
|
#include "HAL/PlatformTime.h"
|
|
#include "HAL/PlatformTLS.h"
|
|
#include "IImageWrapper.h"
|
|
#include "IImageWrapperModule.h"
|
|
#include "Misc/CommandLine.h"
|
|
#include "Misc/FileHelper.h"
|
|
#include "Misc/Parse.h"
|
|
#include "Misc/Paths.h"
|
|
#include "Modules/ModuleManager.h"
|
|
#include "ProfilingDebugging/CpuProfilerTrace.h"
|
|
#include "Dom/JsonObject.h"
|
|
#include "Policies/CondensedJsonPrintPolicy.h"
|
|
#include "Serialization/Archive.h"
|
|
#include "Serialization/JsonReader.h"
|
|
#include "Serialization/JsonSerializer.h"
|
|
#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"
|
|
#include "VoxelDensityOpStack.h"
|
|
#include "VoxelDensityAblation.h"
|
|
#include "VoxelDensityProfile.h"
|
|
#include "VoxelGenerator.h"
|
|
#include "VoxelContentManager.h"
|
|
#include "VoxelGeometryHash.h"
|
|
#include "VoxelMarchingCubesMesher.h"
|
|
#include "VoxelRedefinedDensityField.h"
|
|
#include "VoxelNoise.h"
|
|
#include "VoxelPassageGeometry.h"
|
|
#include "VoxelSettings.h"
|
|
#include "VoxelAtmosphereManager.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 "VoxelWorld.h"
|
|
#include "VoxelStartupTrace.h"
|
|
#include "VoxelForgeCoreAssetAdapter.h"
|
|
#include "VoxelForgeCore/VoxelForgeCore.h"
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <map>
|
|
#include <set>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
namespace
|
|
{
|
|
enum class EAssemblerTileKind : uint8
|
|
{
|
|
FlatColumn,
|
|
Relief,
|
|
Mixed,
|
|
Unknown
|
|
};
|
|
|
|
struct FAssemblerTileTimingSample
|
|
{
|
|
int32 LOD = 0;
|
|
EAssemblerTileKind Kind = EAssemblerTileKind::Unknown;
|
|
double ServiceSeconds = 0.0;
|
|
double ModelReadySeconds = 0.0;
|
|
double ClassificationMilliseconds = 0.0;
|
|
double ColumnMilliseconds = 0.0;
|
|
double MeshingMilliseconds = 0.0;
|
|
bool bHeightfieldBandLimited = false;
|
|
};
|
|
|
|
const TCHAR* AssemblerTileKindName(EAssemblerTileKind Kind)
|
|
{
|
|
switch (Kind)
|
|
{
|
|
case EAssemblerTileKind::FlatColumn: return TEXT("flat_column");
|
|
case EAssemblerTileKind::Relief: return TEXT("relief");
|
|
case EAssemblerTileKind::Mixed: return TEXT("mixed");
|
|
default: return TEXT("unknown");
|
|
}
|
|
}
|
|
|
|
EAssemblerTileKind ClassifyAssemblerTile(const VoxelForgeCore::TileMesh& Mesh)
|
|
{
|
|
const VoxelForgeCore::TileGenerationStats& Stats = Mesh.diagnostics;
|
|
if (Stats.featureRefinedResolution)
|
|
{
|
|
return EAssemblerTileKind::Relief;
|
|
}
|
|
|
|
double MinGroundZ = std::numeric_limits<double>::infinity();
|
|
double MaxGroundZ = -std::numeric_limits<double>::infinity();
|
|
const std::size_t GroundIndexCount = std::min<std::size_t>(
|
|
Mesh.indices.size(), static_cast<std::size_t>(Mesh.groups.groundTriangleCount) * 3u);
|
|
const std::size_t ProbeCount = std::min<std::size_t>(64u, GroundIndexCount);
|
|
for (std::size_t Probe = 0; Probe < ProbeCount; ++Probe)
|
|
{
|
|
const std::size_t Index = ProbeCount <= 1u ? 0u
|
|
: (Probe * (GroundIndexCount - 1u)) / (ProbeCount - 1u);
|
|
const std::uint32_t VertexIndex = Mesh.indices[Index];
|
|
if (VertexIndex >= Mesh.vertices.size())
|
|
{
|
|
return EAssemblerTileKind::Unknown;
|
|
}
|
|
const double Z = Mesh.vertices[VertexIndex].position.z;
|
|
if (!std::isfinite(Z))
|
|
{
|
|
return EAssemblerTileKind::Unknown;
|
|
}
|
|
MinGroundZ = std::min(MinGroundZ, Z);
|
|
MaxGroundZ = std::max(MaxGroundZ, Z);
|
|
}
|
|
const int32 LOD = FMath::Clamp(static_cast<int32>(Mesh.key.lod), 0, 20);
|
|
const double ReliefThresholdCm = 4.0 * 25.0 * static_cast<double>(1u << LOD);
|
|
if (std::isfinite(MinGroundZ) && MaxGroundZ - MinGroundZ > ReliefThresholdCm)
|
|
{
|
|
return EAssemblerTileKind::Relief;
|
|
}
|
|
if (Stats.meshingSkipped || Stats.tileClass == VoxelForgeCore::TileClass::allAir
|
|
|| Stats.tileClass == VoxelForgeCore::TileClass::allSolid)
|
|
{
|
|
return EAssemblerTileKind::FlatColumn;
|
|
}
|
|
if (Stats.tileClass == VoxelForgeCore::TileClass::mixed || !Mesh.indices.empty())
|
|
{
|
|
return EAssemblerTileKind::Mixed;
|
|
}
|
|
if (Stats.unknownBoxes > 0 || Stats.tileClass == VoxelForgeCore::TileClass::unknown)
|
|
{
|
|
return EAssemblerTileKind::Unknown;
|
|
}
|
|
return EAssemblerTileKind::Unknown;
|
|
}
|
|
|
|
bool TryParseBlueprintPinNumber(const UEdGraphPin* Pin, double& OutValue)
|
|
{
|
|
if (Pin == nullptr || Pin->DefaultValue.IsEmpty())
|
|
{
|
|
return false;
|
|
}
|
|
return LexTryParseString(OutValue, *Pin->DefaultValue) && std::isfinite(OutValue);
|
|
}
|
|
|
|
bool ReadStagedPlayerSpeeds(float& OutWalkCmPerSecond, float& OutSprintCmPerSecond,
|
|
FString& OutError)
|
|
{
|
|
const UClass* CharacterClass = LoadClass<ACharacter>(nullptr,
|
|
TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter.BP_FirstPersonCharacter_C"));
|
|
const UClass* ControllerClass = LoadClass<APlayerController>(nullptr,
|
|
TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonPlayerController.BP_FirstPersonPlayerController_C"));
|
|
const ACharacter* CharacterDefault = CharacterClass
|
|
? Cast<ACharacter>(CharacterClass->GetDefaultObject()) : nullptr;
|
|
if (CharacterDefault == nullptr || CharacterDefault->GetCharacterMovement() == nullptr)
|
|
{
|
|
OutError = TEXT("BuildHost staged BP_FirstPersonCharacter CDO or CharacterMovement is missing.");
|
|
return false;
|
|
}
|
|
|
|
OutWalkCmPerSecond = CharacterDefault->GetCharacterMovement()->MaxWalkSpeed;
|
|
double BestSprintSpeed = 0.0;
|
|
FString BestSprintSource;
|
|
const UBlueprint* CharacterBlueprint = LoadObject<UBlueprint>(nullptr,
|
|
TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonCharacter.BP_FirstPersonCharacter"));
|
|
const UBlueprint* ControllerBlueprint = LoadObject<UBlueprint>(nullptr,
|
|
TEXT("/Game/FirstPerson/Blueprints/BP_FirstPersonPlayerController.BP_FirstPersonPlayerController"));
|
|
const auto ScanBlueprintGraphs = [&](const UBlueprint* Blueprint, const TCHAR* BlueprintName)
|
|
{
|
|
if (Blueprint == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
const auto ScanGraph = [&](const UEdGraph* Graph)
|
|
{
|
|
if (Graph == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
for (const UEdGraphNode* Node : Graph->Nodes)
|
|
{
|
|
if (Node == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
for (const UEdGraphPin* Pin : Node->Pins)
|
|
{
|
|
if (Pin == nullptr || Pin->Direction != EGPD_Input
|
|
|| !Pin->PinName.ToString().Contains(TEXT("MaxWalkSpeed"), ESearchCase::IgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
double Value = 0.0;
|
|
bool bFoundValue = TryParseBlueprintPinNumber(Pin, Value);
|
|
if (!bFoundValue)
|
|
{
|
|
for (const UEdGraphPin* LinkedPin : Pin->LinkedTo)
|
|
{
|
|
if (TryParseBlueprintPinNumber(LinkedPin, Value))
|
|
{
|
|
bFoundValue = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (bFoundValue && Value > static_cast<double>(OutWalkCmPerSecond)
|
|
&& Value > BestSprintSpeed)
|
|
{
|
|
BestSprintSpeed = Value;
|
|
BestSprintSource = FString::Printf(TEXT("%s.%s"), BlueprintName,
|
|
*Node->GetName());
|
|
}
|
|
}
|
|
}
|
|
};
|
|
for (const auto& GraphReference : Blueprint->UbergraphPages)
|
|
{
|
|
ScanGraph(GraphReference);
|
|
}
|
|
for (const auto& GraphReference : Blueprint->FunctionGraphs)
|
|
{
|
|
ScanGraph(GraphReference);
|
|
}
|
|
};
|
|
ScanBlueprintGraphs(CharacterBlueprint, TEXT("BP_FirstPersonCharacter"));
|
|
ScanBlueprintGraphs(ControllerBlueprint, TEXT("BP_FirstPersonPlayerController"));
|
|
|
|
if (!std::isfinite(OutWalkCmPerSecond) || OutWalkCmPerSecond <= 0.0f
|
|
|| !std::isfinite(BestSprintSpeed) || BestSprintSpeed <= OutWalkCmPerSecond)
|
|
{
|
|
OutError = TEXT("BuildHost staged Blueprint graphs do not expose a MaxWalkSpeed input above the character CDO walk speed.");
|
|
return false;
|
|
}
|
|
OutSprintCmPerSecond = static_cast<float>(BestSprintSpeed);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgePawnSpeedFixture] source=staged_blueprints walk_cm_s=%.3f sprint_cm_s=%.3f sprint_graph=%s"),
|
|
OutWalkCmPerSecond, OutSprintCmPerSecond, *BestSprintSource);
|
|
return true;
|
|
}
|
|
|
|
struct FAssemblerGeometryAudit
|
|
{
|
|
bool bValid = false;
|
|
int32 LOD = 0;
|
|
FIntVector Tile = FIntVector::ZeroValue;
|
|
double ExpectedTileSpanCm = 0.0;
|
|
double CellWorldCm = 0.0;
|
|
double SpanXcm = 0.0;
|
|
double SpanYcm = 0.0;
|
|
double MaxTriangleEdgeCm = 0.0;
|
|
VoxelForgeCore::Vec3d MaxEdgeStart;
|
|
VoxelForgeCore::Vec3d MaxEdgeEnd;
|
|
std::uint64_t MaxEdgeTriangleIndex = 0;
|
|
int32 NormalSamples = 0;
|
|
int32 NormalOrientationMismatches = 0;
|
|
int32 TriangleSamples = 0;
|
|
int32 TriangleWindingMismatches = 0;
|
|
int32 SkirtTrianglesExcluded = 0;
|
|
};
|
|
|
|
bool AuditAssemblerCoreTile(const VoxelForgeCore::World& World,
|
|
const VoxelForgeCore::TileMesh& Mesh,
|
|
FAssemblerGeometryAudit& OutAudit,
|
|
FString& OutError)
|
|
{
|
|
if (Mesh.indices.empty() || Mesh.indices.size() % 3u != 0u || Mesh.vertices.empty())
|
|
{
|
|
OutError = TEXT("Core geometry audit received a malformed triangle mesh.");
|
|
return false;
|
|
}
|
|
const std::size_t SurfaceVertexCount = std::min<std::size_t>(
|
|
Mesh.vertices.size(), Mesh.groups.surfaceVertexCount);
|
|
if (SurfaceVertexCount == 0u)
|
|
{
|
|
OutError = TEXT("Core geometry audit mesh contains no surface vertices.");
|
|
return false;
|
|
}
|
|
|
|
double MinX = std::numeric_limits<double>::infinity();
|
|
double MaxX = -std::numeric_limits<double>::infinity();
|
|
double MinY = std::numeric_limits<double>::infinity();
|
|
double MaxY = -std::numeric_limits<double>::infinity();
|
|
for (std::size_t Index = 0; Index < SurfaceVertexCount; ++Index)
|
|
{
|
|
const VoxelForgeCore::MeshVertex& Vertex = Mesh.vertices[Index];
|
|
if (!std::isfinite(Vertex.position.x) || !std::isfinite(Vertex.position.y)
|
|
|| !std::isfinite(Vertex.position.z))
|
|
{
|
|
OutError = TEXT("Core geometry audit found a non-finite surface vertex.");
|
|
return false;
|
|
}
|
|
MinX = std::min(MinX, Vertex.position.x);
|
|
MaxX = std::max(MaxX, Vertex.position.x);
|
|
MinY = std::min(MinY, Vertex.position.y);
|
|
MaxY = std::max(MaxY, Vertex.position.y);
|
|
}
|
|
|
|
const int32 LOD = static_cast<int32>(Mesh.key.lod);
|
|
const VoxelForgeCore::WorldConfiguration& Configuration = World.configuration();
|
|
const double CoreVoxelSizeCm = static_cast<double>(Configuration.voxelSizeMicrons) / 10000.0;
|
|
const int32 ExtentVoxels = CHUNK_SIZE << FMath::Clamp(LOD, 0, 20);
|
|
const int32 Cells = LOD < Configuration.fullResolutionLodCount
|
|
? CHUNK_SIZE : FMath::Max(1, Configuration.coarseTileCells);
|
|
const double Step = static_cast<double>(FMath::Max(1, ExtentVoxels / Cells));
|
|
const int32 StepVoxels = FMath::Max(1, FMath::RoundToInt(Step));
|
|
const double CellWorldCm = CoreVoxelSizeCm * Step;
|
|
// Les coutures lointaines peuvent depasser deux cellules en plan sans former une pointe verticale.
|
|
// Far LOD transitions can span slightly over two horizontal cells without creating a vertical spike.
|
|
const double MaxEdgeCellMultiple = LOD >= 7 ? 4.0 : 2.0;
|
|
const double MaxEdgeWorldCm = MaxEdgeCellMultiple * CellWorldCm;
|
|
const int32 PreviousOctaveBias = VoxelGenLOD::GetThreadOctaveBias();
|
|
const int32 PreviousSampleStep = VoxelGenLOD::GetThreadSampleStep();
|
|
struct FRestoreAssemblerLodSampleState
|
|
{
|
|
int32 OctaveBias;
|
|
int32 SampleStep;
|
|
~FRestoreAssemblerLodSampleState()
|
|
{
|
|
VoxelGenLOD::SetThreadOctaveBias(OctaveBias);
|
|
VoxelGenLOD::SetThreadSampleStep(SampleStep);
|
|
}
|
|
} RestoreLodSampleState{PreviousOctaveBias, PreviousSampleStep};
|
|
const int32 OctaveBias = Configuration.lodOctaveDrop > 0 && StepVoxels > 1
|
|
? Configuration.lodOctaveDrop * static_cast<int32>(FMath::FloorLog2(
|
|
static_cast<uint32>(StepVoxels))) : 0;
|
|
VoxelGenLOD::SetThreadOctaveBias(OctaveBias);
|
|
VoxelGenLOD::SetThreadSampleStep(StepVoxels);
|
|
for (std::size_t Index = 0; Index < Mesh.indices.size(); Index += 3u)
|
|
{
|
|
const std::size_t TriangleIndex = Index / 3u;
|
|
if (TriangleIndex < Mesh.skirtFaceMasks.size()
|
|
&& Mesh.skirtFaceMasks[TriangleIndex] != 0u)
|
|
{
|
|
++OutAudit.SkirtTrianglesExcluded;
|
|
continue;
|
|
}
|
|
const std::uint32_t IA = Mesh.indices[Index];
|
|
const std::uint32_t IB = Mesh.indices[Index + 1u];
|
|
const std::uint32_t IC = Mesh.indices[Index + 2u];
|
|
if (IA >= Mesh.vertices.size() || IB >= Mesh.vertices.size() || IC >= Mesh.vertices.size())
|
|
{
|
|
OutError = TEXT("Core geometry audit found an out-of-range triangle index.");
|
|
return false;
|
|
}
|
|
const auto& A = Mesh.vertices[IA].position;
|
|
const auto& B = Mesh.vertices[IB].position;
|
|
const auto& C = Mesh.vertices[IC].position;
|
|
const auto Distance = [](const auto& P, const auto& Q)
|
|
{
|
|
const double DX = P.x - Q.x;
|
|
const double DY = P.y - Q.y;
|
|
const double DZ = P.z - Q.z;
|
|
return std::sqrt(DX * DX + DY * DY + DZ * DZ);
|
|
};
|
|
const double EdgeAB = Distance(A, B);
|
|
const double EdgeBC = Distance(B, C);
|
|
const double EdgeCA = Distance(C, A);
|
|
const double Edge = std::max({EdgeAB, EdgeBC, EdgeCA});
|
|
const VoxelForgeCore::Vec3d* EdgeStart = &A;
|
|
const VoxelForgeCore::Vec3d* EdgeEnd = &B;
|
|
if (EdgeBC >= EdgeAB && EdgeBC >= EdgeCA)
|
|
{
|
|
EdgeStart = &B;
|
|
EdgeEnd = &C;
|
|
}
|
|
else if (EdgeCA >= EdgeAB)
|
|
{
|
|
EdgeStart = &C;
|
|
EdgeEnd = &A;
|
|
}
|
|
if (!std::isfinite(Edge) || Edge > MaxEdgeWorldCm + 0.1)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core geometry audit found an oversized edge %.3f cm (bound %.3f cm; delta=(%.3f,%.3f,%.3f), start=(%.3f,%.3f,%.3f), end=(%.3f,%.3f,%.3f)) in tile (%d,%d,%d) lod=%d triangle=%llu."),
|
|
Edge, MaxEdgeWorldCm, EdgeEnd->x - EdgeStart->x, EdgeEnd->y - EdgeStart->y,
|
|
EdgeEnd->z - EdgeStart->z, EdgeStart->x, EdgeStart->y, EdgeStart->z,
|
|
EdgeEnd->x, EdgeEnd->y, EdgeEnd->z, Mesh.key.coordinate.x,
|
|
Mesh.key.coordinate.y, Mesh.key.coordinate.z, LOD,
|
|
static_cast<unsigned long long>(TriangleIndex));
|
|
return false;
|
|
}
|
|
if (Edge > OutAudit.MaxTriangleEdgeCm)
|
|
{
|
|
OutAudit.MaxTriangleEdgeCm = Edge;
|
|
OutAudit.MaxEdgeTriangleIndex = static_cast<std::uint64_t>(TriangleIndex);
|
|
if (EdgeAB >= EdgeBC && EdgeAB >= EdgeCA)
|
|
{
|
|
OutAudit.MaxEdgeStart = A;
|
|
OutAudit.MaxEdgeEnd = B;
|
|
}
|
|
else if (EdgeBC >= EdgeCA)
|
|
{
|
|
OutAudit.MaxEdgeStart = B;
|
|
OutAudit.MaxEdgeEnd = C;
|
|
}
|
|
else
|
|
{
|
|
OutAudit.MaxEdgeStart = C;
|
|
OutAudit.MaxEdgeEnd = A;
|
|
}
|
|
}
|
|
|
|
const double ABX = B.x - A.x, ABY = B.y - A.y, ABZ = B.z - A.z;
|
|
const double ACX = C.x - A.x, ACY = C.y - A.y, ACZ = C.z - A.z;
|
|
const double FaceX = ABY * ACZ - ABZ * ACY;
|
|
const double FaceY = ABZ * ACX - ABX * ACZ;
|
|
const double FaceZ = ABX * ACY - ABY * ACX;
|
|
const VoxelForgeCore::MeshVertex& NormalA = Mesh.vertices[IA];
|
|
const VoxelForgeCore::MeshVertex& NormalB = Mesh.vertices[IB];
|
|
const VoxelForgeCore::MeshVertex& NormalC = Mesh.vertices[IC];
|
|
const double Alignment = FaceX * (NormalA.normal.x + NormalB.normal.x + NormalC.normal.x)
|
|
+ FaceY * (NormalA.normal.y + NormalB.normal.y + NormalC.normal.y)
|
|
+ FaceZ * (NormalA.normal.z + NormalB.normal.z + NormalC.normal.z);
|
|
const double FaceLengthSq = FaceX * FaceX + FaceY * FaceY + FaceZ * FaceZ;
|
|
if (FaceLengthSq > 1.0e-10)
|
|
{
|
|
if (Alignment < -1.0e-10 * std::sqrt(FaceLengthSq))
|
|
{
|
|
// RealtimeMesh requires MC indices in reversed 0,2,1 order; this reports the expected convention.
|
|
// RealtimeMesh exige l'ordre MC inverse 0,2,1; ce compte documente la convention attendue.
|
|
++OutAudit.TriangleWindingMismatches;
|
|
}
|
|
++OutAudit.TriangleSamples;
|
|
}
|
|
}
|
|
|
|
const std::size_t NormalProbeCount = std::min<std::size_t>(96u, SurfaceVertexCount);
|
|
for (std::size_t Probe = 0; Probe < NormalProbeCount; ++Probe)
|
|
{
|
|
const std::size_t Index = NormalProbeCount <= 1u ? 0u
|
|
: Probe * (SurfaceVertexCount - 1u) / (NormalProbeCount - 1u);
|
|
const VoxelForgeCore::MeshVertex& Vertex = Mesh.vertices[Index];
|
|
const double NX = Vertex.normal.x;
|
|
const double NY = Vertex.normal.y;
|
|
const double NZ = Vertex.normal.z;
|
|
const double NormalLengthSq = NX * NX + NY * NY + NZ * NZ;
|
|
if (!std::isfinite(NormalLengthSq) || std::abs(NormalLengthSq - 1.0) > 0.02)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core geometry audit found a non-unit normal (length squared %.5f)."),
|
|
NormalLengthSq);
|
|
return false;
|
|
}
|
|
// Garder la sonde locale evite de traverser plusieurs reliefs aux LOD lointains.
|
|
// Keep the probe local so coarse-LOD checks do not cross several terrain features.
|
|
if (LOD <= 1)
|
|
{
|
|
// Le champ complet ne represente pas la surface lointaine decimee par le LOD.
|
|
// The full-resolution field does not represent the decimated far-LOD surface.
|
|
const double NormalOffsetVoxels = std::min(0.5 * Step, 1.0);
|
|
const VoxelForgeCore::Vec3d Positive{
|
|
Vertex.position.x / CoreVoxelSizeCm + NX * NormalOffsetVoxels,
|
|
Vertex.position.y / CoreVoxelSizeCm + NY * NormalOffsetVoxels,
|
|
Vertex.position.z / CoreVoxelSizeCm + NZ * NormalOffsetVoxels};
|
|
const VoxelForgeCore::Vec3d Negative{
|
|
Vertex.position.x / CoreVoxelSizeCm - NX * NormalOffsetVoxels,
|
|
Vertex.position.y / CoreVoxelSizeCm - NY * NormalOffsetVoxels,
|
|
Vertex.position.z / CoreVoxelSizeCm - NZ * NormalOffsetVoxels};
|
|
const float PositiveDensity = World.densityAt(Positive);
|
|
const float NegativeDensity = World.densityAt(Negative);
|
|
if (PositiveDensity + 0.01f < NegativeDensity)
|
|
{
|
|
++OutAudit.NormalOrientationMismatches;
|
|
}
|
|
++OutAudit.NormalSamples;
|
|
}
|
|
}
|
|
if (OutAudit.NormalSamples > 0
|
|
&& OutAudit.NormalOrientationMismatches * 10 > OutAudit.NormalSamples)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core geometry audit found %d/%d normals pointing toward denser material in tile (%d,%d,%d) lod=%d step=%d octave_bias=%d."),
|
|
OutAudit.NormalOrientationMismatches, OutAudit.NormalSamples,
|
|
Mesh.key.coordinate.x, Mesh.key.coordinate.y, Mesh.key.coordinate.z,
|
|
LOD, StepVoxels, OctaveBias);
|
|
return false;
|
|
}
|
|
|
|
OutAudit.bValid = true;
|
|
OutAudit.LOD = LOD;
|
|
OutAudit.Tile = FIntVector(static_cast<int32>(Mesh.key.coordinate.x),
|
|
static_cast<int32>(Mesh.key.coordinate.y),
|
|
static_cast<int32>(Mesh.key.coordinate.z));
|
|
OutAudit.ExpectedTileSpanCm = static_cast<double>(ExtentVoxels) * CoreVoxelSizeCm;
|
|
OutAudit.CellWorldCm = CellWorldCm;
|
|
OutAudit.SpanXcm = MaxX - MinX;
|
|
OutAudit.SpanYcm = MaxY - MinY;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
namespace
|
|
{
|
|
using FExploreJsonWriter = TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>;
|
|
|
|
constexpr int32 DefaultRenderWidth = 512;
|
|
constexpr int32 DefaultRenderHeight = 288;
|
|
constexpr float DefaultRenderStepVoxels = 0.5f; // 6.25 cm, as at 25 cm/voxel.
|
|
constexpr float DefaultRenderMaxDistanceVoxels = 320.0f; // 40 m.
|
|
// Schema-v1 follows Standalone's 25 cm voxel scale, independent of the legacy runtime module's 12.5 cm scale.
|
|
// Le schéma-v1 suit l'échelle Standalone de 25 cm, indépendante de l'échelle historique de 12,5 cm du module Runtime.
|
|
constexpr double StandaloneCoreVoxelSizeCm = 25.0;
|
|
constexpr int32 DefaultExportSize = 128;
|
|
constexpr int32 DefaultExportStep = 1;
|
|
constexpr int32 DefaultMaxWalkCells = 32000000;
|
|
constexpr float DefaultBudgetMinutes = 25.0f;
|
|
constexpr float MaxBudgetMinutes = 30.0f;
|
|
constexpr int32 MaxSyntheticStrates = 64;
|
|
constexpr int32 MaxRenderPixels = 1048576;
|
|
constexpr int64 MaxRenderDensitySamples = 400000000ll;
|
|
constexpr int32 MaxExportSize = 128;
|
|
constexpr int64 MaxWalkWorkingBytes = 768ll * 1024ll * 1024ll;
|
|
// The exact fine-grid walk retains Air + Density + player-fit bits + non-finite flags, and can
|
|
// transiently hold Parent + Queue + Path or Visited + NarrowCell + DFS stacks. 21 bytes/cell is a
|
|
// conservative preflight figure for those arrays, including allocator slack/headroom.
|
|
constexpr int64 EstimatedWalkBytesPerCell = 21ll;
|
|
constexpr int64 MaxExportWorkingBytes = 1024ll * 1024ll * 1024ll;
|
|
|
|
FString VoxelForgeSavedRoot()
|
|
{
|
|
FString Root = FPaths::ConvertRelativePathToFull(
|
|
FPaths::Combine(FPaths::ProjectDir(), TEXT("Plugins/VoxelForge/Saved")));
|
|
FPaths::NormalizeDirectoryName(Root);
|
|
return Root;
|
|
}
|
|
|
|
bool IsUnderVoxelForgeSaved(const FString& InPath)
|
|
{
|
|
FString Candidate = FPaths::ConvertRelativePathToFull(InPath);
|
|
FString Root = VoxelForgeSavedRoot();
|
|
FPaths::NormalizeDirectoryName(Candidate);
|
|
FPaths::NormalizeDirectoryName(Root);
|
|
return Candidate.Equals(Root, ESearchCase::IgnoreCase)
|
|
|| Candidate.StartsWith(Root + TEXT("/"), ESearchCase::IgnoreCase)
|
|
|| Candidate.StartsWith(Root + TEXT("\\"), ESearchCase::IgnoreCase);
|
|
}
|
|
|
|
const TCHAR* ArchetypeName(ECaveGeneratorType Archetype)
|
|
{
|
|
switch (Archetype)
|
|
{
|
|
case ECaveGeneratorType::TunnelNetwork: return TEXT("TunnelNetwork");
|
|
case ECaveGeneratorType::FlatPlain: return TEXT("FlatPlain");
|
|
case ECaveGeneratorType::CrystalChamber: return TEXT("CrystalChamber");
|
|
case ECaveGeneratorType::Maze: return TEXT("Maze");
|
|
case ECaveGeneratorType::SurfaceWorld: return TEXT("SurfaceWorld");
|
|
case ECaveGeneratorType::VerticalShafts: return TEXT("VerticalShafts");
|
|
case ECaveGeneratorType::FloatingIslands: return TEXT("FloatingIslands");
|
|
case ECaveGeneratorType::Underwater: return TEXT("Underwater");
|
|
default: return TEXT("Unknown");
|
|
}
|
|
}
|
|
|
|
const TCHAR* ConnectivityResultName(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::OutOfWindow: return TEXT("OutOfWindow");
|
|
case EVoxelConnectivityResult::CoarseLiedBudgetExhausted: return TEXT("CoarseLiedBudgetExhausted");
|
|
case EVoxelConnectivityResult::StartCellNotPlayerFit: return TEXT("StartCellNotPlayerFit");
|
|
case EVoxelConnectivityResult::GoalCellNotPlayerFit: return TEXT("GoalCellNotPlayerFit");
|
|
default: return TEXT("Unknown");
|
|
}
|
|
}
|
|
|
|
bool ParseArchetype(const FString& Text, ECaveGeneratorType& OutArchetype)
|
|
{
|
|
FString Normalized = Text;
|
|
Normalized.TrimStartAndEndInline();
|
|
Normalized.ToLowerInline();
|
|
Normalized.ReplaceInline(TEXT("_"), TEXT(""));
|
|
Normalized.ReplaceInline(TEXT("-"), TEXT(""));
|
|
|
|
const TPair<const TCHAR*, ECaveGeneratorType> Names[] = {
|
|
{ TEXT("tunnelnetwork"), ECaveGeneratorType::TunnelNetwork },
|
|
{ TEXT("flatplain"), ECaveGeneratorType::FlatPlain },
|
|
{ TEXT("crystalchamber"), ECaveGeneratorType::CrystalChamber },
|
|
{ TEXT("maze"), ECaveGeneratorType::Maze },
|
|
{ TEXT("surfaceworld"), ECaveGeneratorType::SurfaceWorld },
|
|
{ TEXT("verticalshafts"), ECaveGeneratorType::VerticalShafts },
|
|
{ TEXT("floatingislands"), ECaveGeneratorType::FloatingIslands },
|
|
{ TEXT("floatingisland"), ECaveGeneratorType::FloatingIslands },
|
|
{ TEXT("underwater"), ECaveGeneratorType::Underwater },
|
|
};
|
|
for (const auto& Pair : Names)
|
|
{
|
|
if (Normalized == Pair.Key)
|
|
{
|
|
OutArchetype = Pair.Value;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const TCHAR* NoiseTypeName(EVoxelNoiseType NoiseType)
|
|
{
|
|
switch (NoiseType)
|
|
{
|
|
case EVoxelNoiseType::FBM: return TEXT("FBM");
|
|
case EVoxelNoiseType::Ridged: return TEXT("Ridged");
|
|
case EVoxelNoiseType::Mixed: return TEXT("Mixed");
|
|
case EVoxelNoiseType::Cellular: return TEXT("Cellular");
|
|
default: return TEXT("Unknown");
|
|
}
|
|
}
|
|
|
|
bool ParseNoiseType(const FString& Text, EVoxelNoiseType& OutNoiseType)
|
|
{
|
|
FString Normalized = Text;
|
|
Normalized.TrimStartAndEndInline();
|
|
Normalized.ToLowerInline();
|
|
Normalized.ReplaceInline(TEXT("_"), TEXT(""));
|
|
Normalized.ReplaceInline(TEXT("-"), TEXT(""));
|
|
|
|
if (Normalized == TEXT("fbm") || Normalized == TEXT("fractal"))
|
|
{
|
|
OutNoiseType = EVoxelNoiseType::FBM;
|
|
return true;
|
|
}
|
|
if (Normalized == TEXT("ridged") || Normalized == TEXT("ridge"))
|
|
{
|
|
OutNoiseType = EVoxelNoiseType::Ridged;
|
|
return true;
|
|
}
|
|
if (Normalized == TEXT("mixed"))
|
|
{
|
|
OutNoiseType = EVoxelNoiseType::Mixed;
|
|
return true;
|
|
}
|
|
if (Normalized == TEXT("cellular") || Normalized == TEXT("worley"))
|
|
{
|
|
OutNoiseType = EVoxelNoiseType::Cellular;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool ParseRenderCamera(const FString& Text, FVector& OutCamera)
|
|
{
|
|
TArray<FString> Components;
|
|
Text.ParseIntoArray(Components, TEXT(","), true);
|
|
if (Components.Num() != 3)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const float X = FCString::Atof(*Components[0]);
|
|
const float Y = FCString::Atof(*Components[1]);
|
|
const float Z = FCString::Atof(*Components[2]);
|
|
if (!FMath::IsFinite(X) || !FMath::IsFinite(Y) || !FMath::IsFinite(Z))
|
|
{
|
|
return false;
|
|
}
|
|
OutCamera = FVector(X, Y, Z);
|
|
return true;
|
|
}
|
|
|
|
bool ParseDensitySlice(
|
|
const FString& Text,
|
|
FVector& OutOrigin,
|
|
FVector& OutDirection,
|
|
int32& OutSamples,
|
|
float& OutStep)
|
|
{
|
|
TArray<FString> Components;
|
|
Text.ParseIntoArray(Components, TEXT(","), true);
|
|
if (Components.Num() != 8)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const float OriginX = FCString::Atof(*Components[0]);
|
|
const float OriginY = FCString::Atof(*Components[1]);
|
|
const float OriginZ = FCString::Atof(*Components[2]);
|
|
const float DirectionX = FCString::Atof(*Components[3]);
|
|
const float DirectionY = FCString::Atof(*Components[4]);
|
|
const float DirectionZ = FCString::Atof(*Components[5]);
|
|
const int32 Samples = FCString::Atoi(*Components[6]);
|
|
const float Step = FCString::Atof(*Components[7]);
|
|
if (!FMath::IsFinite(OriginX) || !FMath::IsFinite(OriginY)
|
|
|| !FMath::IsFinite(OriginZ) || !FMath::IsFinite(DirectionX)
|
|
|| !FMath::IsFinite(DirectionY) || !FMath::IsFinite(DirectionZ)
|
|
|| !FMath::IsFinite(Step) || Samples < 2 || Samples > 4096
|
|
|| Step <= 0.0f)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FVector Direction(DirectionX, DirectionY, DirectionZ);
|
|
if (!Direction.Normalize())
|
|
{
|
|
return false;
|
|
}
|
|
OutOrigin = FVector(OriginX, OriginY, OriginZ);
|
|
OutDirection = Direction;
|
|
OutSamples = Samples;
|
|
OutStep = Step;
|
|
return true;
|
|
}
|
|
|
|
bool NeedsOriginInWindow(ECaveGeneratorType Archetype)
|
|
{
|
|
// This is the same topology policy as VoxelForgePlayerFitWindow.h. That header is test-only,
|
|
// so the editor commandlet keeps the policy here while still delegating the actual fit test to
|
|
// VoxelStrateMeasure's shared VF_ stencil.
|
|
switch (Archetype)
|
|
{
|
|
case ECaveGeneratorType::Maze:
|
|
case ECaveGeneratorType::VerticalShafts:
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
case ECaveGeneratorType::Underwater:
|
|
return true;
|
|
case ECaveGeneratorType::FlatPlain:
|
|
case ECaveGeneratorType::CrystalChamber:
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
float BoundarySealForArchetype(
|
|
const UVoxelStrateDefinition& Definition,
|
|
ECaveGeneratorType Archetype)
|
|
{
|
|
switch (Archetype)
|
|
{
|
|
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;
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
case ECaveGeneratorType::Underwater:
|
|
default:
|
|
return Definition.GenerationParams.BoundarySealThickness;
|
|
}
|
|
}
|
|
|
|
struct FExploreArguments
|
|
{
|
|
int32 Seed = 0;
|
|
bool bSeedOverride = false;
|
|
ECaveGeneratorType Archetype = ECaveGeneratorType::Maze;
|
|
int32 Slot = 4;
|
|
FString StrateReference;
|
|
FString SettingsReference;
|
|
FString CoreWorldAssemblyManifestPath;
|
|
int32 CoreHybridMesherVersion = 0;
|
|
float CoreVoxelSizeCm = 25.0f;
|
|
bool bCoreHybridMesher = false;
|
|
bool bCoreHybridMesherV2 = false;
|
|
bool bCoreHybridFixturePlacement = false;
|
|
bool bCoreHybridCliffOnly = false;
|
|
bool bCoreHybridFixturesOnly = false;
|
|
bool bCoreFloatingIslandTest = false;
|
|
bool bCoreFallbackTest = false;
|
|
bool bCoreOffTest = false;
|
|
bool bCorePlanOnlyTest = false;
|
|
bool bCoreTestSeedOverride = false;
|
|
int32 CoreTestSeed = 0;
|
|
bool bRender = true;
|
|
bool bWalk = true;
|
|
bool bExport = true;
|
|
bool bWormsEnabledOverride = false;
|
|
bool bWormsEnabled = true;
|
|
bool bWormStrengthOverride = false;
|
|
float WormStrength = 0.0f;
|
|
bool bProfileDensity = false;
|
|
bool bProfileDensityFull = false;
|
|
bool bPerfAttribution = false;
|
|
bool bSampleStacks = false;
|
|
uint32 SampleStacksIntervalUs = FVoxelStackSampler::DefaultIntervalUs;
|
|
bool bProfileLod = false;
|
|
bool bOpBounds = false;
|
|
bool bFailureFocusRender = false;
|
|
FString OutDirectory;
|
|
|
|
int32 RenderWidth = DefaultRenderWidth;
|
|
int32 RenderHeight = DefaultRenderHeight;
|
|
float RenderStepVoxels = DefaultRenderStepVoxels;
|
|
float RenderMaxDistanceVoxels = DefaultRenderMaxDistanceVoxels;
|
|
int32 ExportSize = DefaultExportSize;
|
|
int32 ExportStep = DefaultExportStep;
|
|
bool bExportTileOverride = false;
|
|
int32 ExportTileX = 0;
|
|
int32 ExportTileY = 0;
|
|
int32 ExportTileZ = 0;
|
|
// The canonical export owns one immutable lattice for the whole run. Batch cases can pass
|
|
// density_grid_reuse=false to produce a matched wall-clock control.
|
|
bool bReuseDensityGrid = true;
|
|
bool bBlockEarlyOut = false;
|
|
// Opt-in parity mode: copy the runtime mesher presentation/LOD settings from the authored
|
|
// settings object. The default remains the historical bounded export configuration.
|
|
bool bGameRuntimeConfig = false;
|
|
int32 MeshMinBatchSize = 1;
|
|
int32 MaxWalkCells = DefaultMaxWalkCells;
|
|
float BudgetMinutes = DefaultBudgetMinutes;
|
|
bool bSurfaceRoughnessOverride = false;
|
|
float SurfaceRoughness = 0.0f;
|
|
bool bTunnelWarpStrengthOverride = false;
|
|
float TunnelWarpStrength = 0.0f;
|
|
bool bRoughnessNoiseTypeOverride = false;
|
|
EVoxelNoiseType RoughnessNoiseType = EVoxelNoiseType::FBM;
|
|
bool bRoughnessFrequencyOverride = false;
|
|
float RoughnessFrequency = 0.0f;
|
|
bool bTunnelFloorEnabledOverride = false;
|
|
int32 TunnelFloorEnabled = 1;
|
|
bool bTunnelFloorTerracingOverride = false;
|
|
int32 TunnelFloorTerracing = 1;
|
|
bool bTunnelFloorStepOverride = false;
|
|
float TunnelFloorStep = 0.0f;
|
|
bool bTunnelFloorMaxLedgeOverride = false;
|
|
float TunnelFloorMaxLedge = 0.0f;
|
|
bool bTunnelFloorGentleOverride = false;
|
|
float TunnelFloorGentle = 0.0f;
|
|
bool bTunnelFloorLedgesOverride = false;
|
|
int32 TunnelFloorLedges = 0;
|
|
bool bTunnelFloorMaxLedgesOverride = false;
|
|
int32 TunnelFloorMaxLedges = 0;
|
|
bool bRoomMouthRiseOverride = false;
|
|
float RoomMouthRise = 0.0f;
|
|
bool bRoomMouthRiseBlendOverride = false;
|
|
float RoomMouthRiseBlend = 0.0f;
|
|
bool bPassageFloorWritesOverride = false;
|
|
int32 PassageFloorWrites = 1;
|
|
bool bStandaloneWorldExport = false;
|
|
bool bOwnerFourStratePlan = false;
|
|
bool bSkipStandaloneCoreAdapterParity = false;
|
|
bool bCoreWorldTest = false;
|
|
bool bCoreWorldStreamTraceOnly = false;
|
|
bool bRenderCameraOverride = false;
|
|
FVector RenderCamera = FVector::ZeroVector;
|
|
bool bRenderTargetOverride = false;
|
|
FVector RenderTarget = FVector::ZeroVector;
|
|
bool bDensitySlice = false;
|
|
FVector DensitySliceOrigin = FVector::ZeroVector;
|
|
FVector DensitySliceDirection = FVector::ForwardVector;
|
|
int32 DensitySliceSamples = 257;
|
|
float DensitySliceStep = 0.5f;
|
|
bool bTunnelCoreProbe = false;
|
|
|
|
bool IsDensityProfilingEnabled() const
|
|
{
|
|
return bProfileDensity || bPerfAttribution;
|
|
}
|
|
|
|
FString CanonicalModes() const
|
|
{
|
|
FString Result;
|
|
if (bRender) Result += TEXT("render,");
|
|
if (bWalk) Result += TEXT("walk,");
|
|
if (bExport) Result += TEXT("export,");
|
|
if (bOpBounds) Result += TEXT("opbounds,");
|
|
if (bTunnelCoreProbe) Result += TEXT("tunnelcoreprobe,");
|
|
if (Result.EndsWith(TEXT(",")))
|
|
{
|
|
Result.LeftChopInline(1);
|
|
}
|
|
return Result;
|
|
}
|
|
};
|
|
|
|
bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FString& OutError)
|
|
{
|
|
FString ArchetypeText = TEXT("Maze");
|
|
FString ModesText = TEXT("render,walk,export");
|
|
FString OutText;
|
|
|
|
OutArguments.bSeedOverride = FParse::Value(*Params, TEXT("seed="), OutArguments.Seed);
|
|
int32 StandaloneWorldExport = 0;
|
|
FParse::Value(*Params, TEXT("standaloneworldexport="), StandaloneWorldExport);
|
|
OutArguments.bStandaloneWorldExport = StandaloneWorldExport != 0;
|
|
int32 OwnerFourStratePlan = 0;
|
|
FParse::Value(*Params, TEXT("ownerfourstrateplan="), OwnerFourStratePlan);
|
|
OutArguments.bOwnerFourStratePlan = OwnerFourStratePlan != 0;
|
|
int32 SkipStandaloneCoreAdapterParity = 0;
|
|
FParse::Value(*Params, TEXT("skipstandalonecoreadapterparity="), SkipStandaloneCoreAdapterParity);
|
|
OutArguments.bSkipStandaloneCoreAdapterParity = SkipStandaloneCoreAdapterParity != 0;
|
|
int32 CoreWorldTest = 0;
|
|
FParse::Value(*Params, TEXT("coreworldtest="), CoreWorldTest);
|
|
OutArguments.bCoreWorldTest = CoreWorldTest != 0;
|
|
int32 CoreWorldStreamTraceOnly = 0;
|
|
FParse::Value(*Params, TEXT("coreworldstreamtrace="), CoreWorldStreamTraceOnly);
|
|
OutArguments.bCoreWorldStreamTraceOnly = CoreWorldStreamTraceOnly != 0;
|
|
FParse::Value(*Params, TEXT("corevoxelsizecm="), OutArguments.CoreVoxelSizeCm);
|
|
if (!FMath::IsFinite(OutArguments.CoreVoxelSizeCm)
|
|
|| OutArguments.CoreVoxelSizeCm < VOXEL_SIZE
|
|
|| OutArguments.CoreVoxelSizeCm > 2.0f * VOXEL_SIZE)
|
|
{
|
|
OutError = TEXT("corevoxelsizecm must be between one and two legacy voxel widths.");
|
|
return false;
|
|
}
|
|
int32 CoreFloatingIslandTest = 0;
|
|
FParse::Value(*Params, TEXT("coreislandtest="), CoreFloatingIslandTest);
|
|
OutArguments.bCoreFloatingIslandTest = CoreFloatingIslandTest != 0;
|
|
int32 CoreFallbackTest = 0;
|
|
FParse::Value(*Params, TEXT("corefallbacktest="), CoreFallbackTest);
|
|
OutArguments.bCoreFallbackTest = CoreFallbackTest != 0;
|
|
int32 CoreOffTest = 0;
|
|
FParse::Value(*Params, TEXT("coreofftest="), CoreOffTest);
|
|
OutArguments.bCoreOffTest = CoreOffTest != 0;
|
|
int32 CorePlanOnlyTest = 0;
|
|
FParse::Value(*Params, TEXT("coreplantest="), CorePlanOnlyTest);
|
|
OutArguments.bCorePlanOnlyTest = CorePlanOnlyTest != 0;
|
|
OutArguments.bCoreTestSeedOverride = FParse::Value(
|
|
*Params, TEXT("coretestseed="), OutArguments.CoreTestSeed);
|
|
int32 CoreHybridMesher = 0;
|
|
FParse::Value(*Params, TEXT("corehybridmesher="), CoreHybridMesher);
|
|
if (CoreHybridMesher < 0 || CoreHybridMesher > 2)
|
|
{
|
|
OutError = TEXT("corehybridmesher supports only 0 (MC), 1 (hybrid V1), or 2 (hybrid V2).");
|
|
return false;
|
|
}
|
|
OutArguments.CoreHybridMesherVersion = CoreHybridMesher;
|
|
OutArguments.bCoreHybridMesher = CoreHybridMesher != 0;
|
|
OutArguments.bCoreHybridMesherV2 = CoreHybridMesher == 2;
|
|
int32 CoreHybridFixturePlacement = 0;
|
|
FParse::Value(*Params, TEXT("corehybridfixtureplacement="), CoreHybridFixturePlacement);
|
|
OutArguments.bCoreHybridFixturePlacement = CoreHybridFixturePlacement != 0;
|
|
int32 CoreHybridCliffOnly = 0;
|
|
FParse::Value(*Params, TEXT("corehybridcliffonly="), CoreHybridCliffOnly);
|
|
OutArguments.bCoreHybridCliffOnly = CoreHybridCliffOnly != 0;
|
|
int32 CoreHybridFixturesOnly = 0;
|
|
FParse::Value(*Params, TEXT("corehybridfixturesonly="), CoreHybridFixturesOnly);
|
|
OutArguments.bCoreHybridFixturesOnly = CoreHybridFixturesOnly != 0;
|
|
FParse::Value(*Params, TEXT("archetype="), ArchetypeText);
|
|
FParse::Value(*Params, TEXT("slot="), OutArguments.Slot);
|
|
FParse::Value(*Params, TEXT("strateref="), OutArguments.StrateReference);
|
|
FParse::Value(*Params, TEXT("settingsref="), OutArguments.SettingsReference);
|
|
FParse::Value(*Params, TEXT("coreworldassembly="), OutArguments.CoreWorldAssemblyManifestPath);
|
|
FParse::Value(*Params, TEXT("modes="), ModesText);
|
|
// FParse::Value treats a comma as a command-line separator in some UE commandlet
|
|
// launch paths. Recover the complete token so both -modes=walk,export and the
|
|
// quoted -modes="walk,export" form have the same meaning.
|
|
const int32 ModesOffset = Params.Find(TEXT("modes="), ESearchCase::IgnoreCase);
|
|
if (ModesOffset != INDEX_NONE)
|
|
{
|
|
ModesText = Params.Mid(ModesOffset + 6);
|
|
const int32 ModesEnd = ModesText.Find(TEXT(" "));
|
|
if (ModesEnd != INDEX_NONE)
|
|
{
|
|
ModesText.LeftInline(ModesEnd);
|
|
}
|
|
ModesText.TrimQuotesInline();
|
|
}
|
|
int32 WormsEnabled = 1;
|
|
if (FParse::Value(*Params, TEXT("wormsenabled="), WormsEnabled))
|
|
{
|
|
OutArguments.bWormsEnabledOverride = true;
|
|
OutArguments.bWormsEnabled = WormsEnabled != 0;
|
|
}
|
|
if (Params.Contains(TEXT("wormstrength="), ESearchCase::IgnoreCase)
|
|
&& FParse::Value(*Params, TEXT("wormstrength="), OutArguments.WormStrength))
|
|
{
|
|
OutArguments.bWormStrengthOverride = true;
|
|
}
|
|
OutArguments.bProfileDensity = FParse::Param(*Params, TEXT("profiledensity"));
|
|
OutArguments.bProfileDensityFull = FParse::Param(*Params, TEXT("profiledensityfull"));
|
|
OutArguments.bProfileDensity |= OutArguments.bProfileDensityFull;
|
|
OutArguments.bPerfAttribution = FParse::Param(*Params, TEXT("perfattribution"));
|
|
OutArguments.bSampleStacks = FParse::Param(*Params, TEXT("samplestacks"));
|
|
int32 SampleStacksIntervalUs = static_cast<int32>(
|
|
FVoxelStackSampler::DefaultIntervalUs);
|
|
if (FParse::Value(*Params, TEXT("samplestacks="), SampleStacksIntervalUs))
|
|
{
|
|
OutArguments.bSampleStacks = true;
|
|
OutArguments.SampleStacksIntervalUs = static_cast<uint32>(FMath::Max(0, SampleStacksIntervalUs));
|
|
}
|
|
OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod"));
|
|
OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds"));
|
|
OutArguments.bTunnelCoreProbe = FParse::Param(*Params, TEXT("tunnelcoreprobe"));
|
|
FParse::Value(*Params, TEXT("out="), OutText);
|
|
FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth);
|
|
FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight);
|
|
FParse::Value(*Params, TEXT("renderstep="), OutArguments.RenderStepVoxels);
|
|
FParse::Value(*Params, TEXT("rendermaxdistance="), OutArguments.RenderMaxDistanceVoxels);
|
|
FParse::Value(*Params, TEXT("exportsize="), OutArguments.ExportSize);
|
|
FParse::Value(*Params, TEXT("exportstep="), OutArguments.ExportStep);
|
|
const bool bExportTileXSpecified = Params.Contains(
|
|
TEXT("exporttilex="), ESearchCase::IgnoreCase);
|
|
const bool bExportTileYSpecified = Params.Contains(
|
|
TEXT("exporttiley="), ESearchCase::IgnoreCase);
|
|
const bool bExportTileZSpecified = Params.Contains(
|
|
TEXT("exporttilez="), ESearchCase::IgnoreCase);
|
|
if (bExportTileXSpecified || bExportTileYSpecified || bExportTileZSpecified)
|
|
{
|
|
if (!bExportTileXSpecified || !bExportTileYSpecified || !bExportTileZSpecified
|
|
|| !FParse::Value(*Params, TEXT("exporttilex="), OutArguments.ExportTileX)
|
|
|| !FParse::Value(*Params, TEXT("exporttiley="), OutArguments.ExportTileY)
|
|
|| !FParse::Value(*Params, TEXT("exporttilez="), OutArguments.ExportTileZ))
|
|
{
|
|
OutError = TEXT("exporttilex, exporttiley, and exporttilez must be supplied together as level-0 tile coordinates.");
|
|
return false;
|
|
}
|
|
OutArguments.bExportTileOverride = true;
|
|
}
|
|
int32 DensityGridReuse = OutArguments.bReuseDensityGrid ? 1 : 0;
|
|
FParse::Value(*Params, TEXT("densitygridreuse="), DensityGridReuse);
|
|
OutArguments.bReuseDensityGrid = DensityGridReuse != 0;
|
|
int32 BlockEarlyOut = OutArguments.bBlockEarlyOut ? 1 : 0;
|
|
FParse::Value(*Params, TEXT("blockearlyout="), BlockEarlyOut);
|
|
OutArguments.bBlockEarlyOut = BlockEarlyOut != 0;
|
|
int32 GameRuntimeConfig = OutArguments.bGameRuntimeConfig ? 1 : 0;
|
|
FParse::Value(*Params, TEXT("gameconfig="), GameRuntimeConfig);
|
|
FParse::Value(*Params, TEXT("paritygame="), GameRuntimeConfig);
|
|
OutArguments.bGameRuntimeConfig = GameRuntimeConfig != 0;
|
|
FParse::Value(*Params, TEXT("meshminbatch="), OutArguments.MeshMinBatchSize);
|
|
OutArguments.MeshMinBatchSize = FMath::Clamp(OutArguments.MeshMinBatchSize, 1, 64);
|
|
int32 Lod = 0;
|
|
if (FParse::Value(*Params, TEXT("lod="), Lod))
|
|
{
|
|
if (Lod < 0 || Lod > 3)
|
|
{
|
|
OutError = TEXT("lod must be an integer in [0,3].");
|
|
return false;
|
|
}
|
|
OutArguments.ExportStep = 1 << Lod;
|
|
}
|
|
FParse::Value(*Params, TEXT("maxwalkcells="), OutArguments.MaxWalkCells);
|
|
FParse::Value(*Params, TEXT("budget="), OutArguments.BudgetMinutes);
|
|
const bool bSurfaceRoughnessSpecified = Params.Contains(
|
|
TEXT("surfaceroughness="), ESearchCase::IgnoreCase);
|
|
const bool bSurfaceRoughnessParsed = FParse::Value(
|
|
*Params, TEXT("surfaceroughness="), OutArguments.SurfaceRoughness);
|
|
OutArguments.bSurfaceRoughnessOverride = bSurfaceRoughnessSpecified;
|
|
const bool bTunnelWarpStrengthSpecified = Params.Contains(
|
|
TEXT("tunnelwarp="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelWarpStrengthParsed = FParse::Value(
|
|
*Params, TEXT("tunnelwarp="), OutArguments.TunnelWarpStrength);
|
|
OutArguments.bTunnelWarpStrengthOverride = bTunnelWarpStrengthSpecified;
|
|
FString RoughnessNoiseTypeText;
|
|
const bool bRoughnessNoiseTypeSpecified = Params.Contains(
|
|
TEXT("roughnesstype="), ESearchCase::IgnoreCase);
|
|
const bool bRoughnessNoiseTypeParsed = FParse::Value(
|
|
*Params, TEXT("roughnesstype="), RoughnessNoiseTypeText)
|
|
&& ParseNoiseType(RoughnessNoiseTypeText, OutArguments.RoughnessNoiseType);
|
|
OutArguments.bRoughnessNoiseTypeOverride = bRoughnessNoiseTypeSpecified;
|
|
const bool bRoughnessFrequencySpecified = Params.Contains(
|
|
TEXT("roughnessfrequency="), ESearchCase::IgnoreCase);
|
|
const bool bRoughnessFrequencyParsed = FParse::Value(
|
|
*Params, TEXT("roughnessfrequency="), OutArguments.RoughnessFrequency);
|
|
OutArguments.bRoughnessFrequencyOverride = bRoughnessFrequencySpecified;
|
|
const bool bTunnelFloorEnabledSpecified = Params.Contains(
|
|
TEXT("tunnelfloor="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorEnabledParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloor="), OutArguments.TunnelFloorEnabled);
|
|
OutArguments.bTunnelFloorEnabledOverride = bTunnelFloorEnabledSpecified;
|
|
const bool bTunnelFloorTerracingSpecified = Params.Contains(
|
|
TEXT("tunnelfloorterracing="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorTerracingParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloorterracing="), OutArguments.TunnelFloorTerracing);
|
|
OutArguments.bTunnelFloorTerracingOverride = bTunnelFloorTerracingSpecified;
|
|
const bool bTunnelFloorStepSpecified = Params.Contains(
|
|
TEXT("tunnelfloorstep="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorStepParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloorstep="), OutArguments.TunnelFloorStep);
|
|
OutArguments.bTunnelFloorStepOverride = bTunnelFloorStepSpecified;
|
|
const bool bTunnelFloorMaxLedgeSpecified = Params.Contains(
|
|
TEXT("tunnelfloormaxledge="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorMaxLedgeParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloormaxledge="), OutArguments.TunnelFloorMaxLedge);
|
|
OutArguments.bTunnelFloorMaxLedgeOverride = bTunnelFloorMaxLedgeSpecified;
|
|
const bool bTunnelFloorGentleSpecified = Params.Contains(
|
|
TEXT("tunnelfloorgentle="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorGentleParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloorgentle="), OutArguments.TunnelFloorGentle);
|
|
OutArguments.bTunnelFloorGentleOverride = bTunnelFloorGentleSpecified;
|
|
const bool bTunnelFloorLedgesSpecified = Params.Contains(
|
|
TEXT("tunnelfloorledges="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorLedgesParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloorledges="), OutArguments.TunnelFloorLedges);
|
|
OutArguments.bTunnelFloorLedgesOverride = bTunnelFloorLedgesSpecified;
|
|
const bool bTunnelFloorMaxLedgesSpecified = Params.Contains(
|
|
TEXT("tunnelfloormaxledges="), ESearchCase::IgnoreCase);
|
|
const bool bTunnelFloorMaxLedgesParsed = FParse::Value(
|
|
*Params, TEXT("tunnelfloormaxledges="), OutArguments.TunnelFloorMaxLedges);
|
|
OutArguments.bTunnelFloorMaxLedgesOverride = bTunnelFloorMaxLedgesSpecified;
|
|
const bool bRoomMouthRiseSpecified = Params.Contains(
|
|
TEXT("roommouthrise="), ESearchCase::IgnoreCase);
|
|
const bool bRoomMouthRiseParsed = FParse::Value(
|
|
*Params, TEXT("roommouthrise="), OutArguments.RoomMouthRise);
|
|
OutArguments.bRoomMouthRiseOverride = bRoomMouthRiseSpecified;
|
|
const bool bRoomMouthRiseBlendSpecified = Params.Contains(
|
|
TEXT("roommouthriseblend="), ESearchCase::IgnoreCase);
|
|
const bool bRoomMouthRiseBlendParsed = FParse::Value(
|
|
*Params, TEXT("roommouthriseblend="), OutArguments.RoomMouthRiseBlend);
|
|
OutArguments.bRoomMouthRiseBlendOverride = bRoomMouthRiseBlendSpecified;
|
|
const bool bPassageFloorWritesSpecified = Params.Contains(
|
|
TEXT("passagefloorwrites="), ESearchCase::IgnoreCase);
|
|
const bool bPassageFloorWritesParsed = FParse::Value(
|
|
*Params, TEXT("passagefloorwrites="), OutArguments.PassageFloorWrites);
|
|
OutArguments.bPassageFloorWritesOverride = bPassageFloorWritesSpecified;
|
|
|
|
FString RenderCameraText;
|
|
const bool bRenderCameraSpecified = Params.Contains(
|
|
TEXT("rendercamera="), ESearchCase::IgnoreCase);
|
|
bool bRenderCameraParsed = false;
|
|
if (bRenderCameraSpecified)
|
|
{
|
|
const int32 CameraOffset = Params.Find(TEXT("rendercamera="), ESearchCase::IgnoreCase);
|
|
if (CameraOffset != INDEX_NONE)
|
|
{
|
|
RenderCameraText = Params.Mid(CameraOffset + 13);
|
|
const int32 CameraEnd = RenderCameraText.Find(TEXT(" "));
|
|
if (CameraEnd != INDEX_NONE)
|
|
{
|
|
RenderCameraText.LeftInline(CameraEnd);
|
|
}
|
|
RenderCameraText.TrimQuotesInline();
|
|
bRenderCameraParsed = ParseRenderCamera(RenderCameraText, OutArguments.RenderCamera);
|
|
}
|
|
}
|
|
OutArguments.bRenderCameraOverride = bRenderCameraSpecified;
|
|
|
|
FString RenderTargetText;
|
|
const bool bRenderTargetSpecified = Params.Contains(
|
|
TEXT("rendertarget="), ESearchCase::IgnoreCase);
|
|
bool bRenderTargetParsed = false;
|
|
if (bRenderTargetSpecified)
|
|
{
|
|
const int32 TargetOffset = Params.Find(TEXT("rendertarget="), ESearchCase::IgnoreCase);
|
|
if (TargetOffset != INDEX_NONE)
|
|
{
|
|
RenderTargetText = Params.Mid(TargetOffset + 13);
|
|
const int32 TargetEnd = RenderTargetText.Find(TEXT(" "));
|
|
if (TargetEnd != INDEX_NONE)
|
|
{
|
|
RenderTargetText.LeftInline(TargetEnd);
|
|
}
|
|
RenderTargetText.TrimQuotesInline();
|
|
bRenderTargetParsed = ParseRenderCamera(RenderTargetText, OutArguments.RenderTarget);
|
|
}
|
|
}
|
|
OutArguments.bRenderTargetOverride = bRenderTargetSpecified;
|
|
|
|
FString DensitySliceText;
|
|
const bool bDensitySliceSpecified = Params.Contains(
|
|
TEXT("densityslice="), ESearchCase::IgnoreCase);
|
|
bool bDensitySliceParsed = false;
|
|
if (bDensitySliceSpecified)
|
|
{
|
|
const int32 SliceOffset = Params.Find(TEXT("densityslice="), ESearchCase::IgnoreCase);
|
|
if (SliceOffset != INDEX_NONE)
|
|
{
|
|
DensitySliceText = Params.Mid(SliceOffset + 13);
|
|
const int32 SliceEnd = DensitySliceText.Find(TEXT(" "));
|
|
if (SliceEnd != INDEX_NONE)
|
|
{
|
|
DensitySliceText.LeftInline(SliceEnd);
|
|
}
|
|
DensitySliceText.TrimQuotesInline();
|
|
bDensitySliceParsed = ParseDensitySlice(
|
|
DensitySliceText,
|
|
OutArguments.DensitySliceOrigin,
|
|
OutArguments.DensitySliceDirection,
|
|
OutArguments.DensitySliceSamples,
|
|
OutArguments.DensitySliceStep);
|
|
}
|
|
}
|
|
OutArguments.bDensitySlice = bDensitySliceSpecified;
|
|
int32 FailureFocusRender = 0;
|
|
FParse::Value(*Params, TEXT("failurefocus="), FailureFocusRender);
|
|
OutArguments.bFailureFocusRender = FailureFocusRender != 0;
|
|
|
|
if (!ParseArchetype(ArchetypeText, OutArguments.Archetype))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Unknown archetype '%s'. Expected Maze, TunnelNetwork, FlatPlain, "
|
|
"CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, or Underwater."),
|
|
*ArchetypeText);
|
|
return false;
|
|
}
|
|
|
|
OutArguments.bRender = false;
|
|
OutArguments.bWalk = false;
|
|
OutArguments.bExport = false;
|
|
TArray<FString> Modes;
|
|
ModesText.ParseIntoArray(Modes, TEXT(","), true);
|
|
for (FString Mode : Modes)
|
|
{
|
|
Mode.TrimStartAndEndInline();
|
|
Mode.ToLowerInline();
|
|
if (Mode == TEXT("all"))
|
|
{
|
|
OutArguments.bRender = true;
|
|
OutArguments.bWalk = true;
|
|
OutArguments.bExport = true;
|
|
}
|
|
else if (Mode == TEXT("opbounds"))
|
|
{
|
|
OutArguments.bOpBounds = true;
|
|
}
|
|
else if (Mode == TEXT("tunnelcoreprobe"))
|
|
{
|
|
OutArguments.bTunnelCoreProbe = true;
|
|
}
|
|
else if (Mode == TEXT("render"))
|
|
{
|
|
OutArguments.bRender = true;
|
|
}
|
|
else if (Mode == TEXT("walk"))
|
|
{
|
|
OutArguments.bWalk = true;
|
|
}
|
|
else if (Mode == TEXT("export"))
|
|
{
|
|
OutArguments.bExport = true;
|
|
}
|
|
else
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Unknown mode '%s'. Expected render, walk, export, opbounds, tunnelcoreprobe, or all."), *Mode);
|
|
return false;
|
|
}
|
|
}
|
|
if (!OutArguments.bRender && !OutArguments.bWalk && !OutArguments.bExport
|
|
&& !OutArguments.bOpBounds && !OutArguments.bTunnelCoreProbe)
|
|
{
|
|
OutError = TEXT("At least one mode must be selected.");
|
|
return false;
|
|
}
|
|
|
|
// Passage walks need both mouths; a render/export-only generation can inspect an end slot.
|
|
// Parity exports also accept the game's authored single-strate layout.
|
|
const bool bRequiresInteriorSlot = OutArguments.bWalk;
|
|
const int32 MinimumSlot = OutArguments.bGameRuntimeConfig || !bRequiresInteriorSlot ? 0 : 1;
|
|
const int32 MaximumSlot = OutArguments.bGameRuntimeConfig || !bRequiresInteriorSlot
|
|
? MaxSyntheticStrates - 1 : MaxSyntheticStrates - 2;
|
|
if (OutArguments.Slot < MinimumSlot || OutArguments.Slot > MaximumSlot)
|
|
{
|
|
if (OutArguments.bGameRuntimeConfig)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("slot must be in [0,%d] for game-runtime configuration parity."),
|
|
MaxSyntheticStrates - 1);
|
|
}
|
|
else
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("slot must be in [0,%d]; walk mode requires an interior slot in [1,%d] for both passage mouths."),
|
|
MaxSyntheticStrates - 1, MaxSyntheticStrates - 2);
|
|
}
|
|
return false;
|
|
}
|
|
if (OutArguments.RenderWidth < 16 || OutArguments.RenderHeight < 16
|
|
|| static_cast<int64>(OutArguments.RenderWidth)
|
|
* static_cast<int64>(OutArguments.RenderHeight) > MaxRenderPixels)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("renderwidth*renderheight must be between 256 pixels and %d pixels."),
|
|
MaxRenderPixels);
|
|
return false;
|
|
}
|
|
if (!FMath::IsFinite(OutArguments.RenderStepVoxels)
|
|
|| OutArguments.RenderStepVoxels < 0.125f
|
|
|| OutArguments.RenderStepVoxels > 8.0f)
|
|
{
|
|
OutError = TEXT("renderstep must be finite and in [0.125,8] voxels.");
|
|
return false;
|
|
}
|
|
if (!FMath::IsFinite(OutArguments.RenderMaxDistanceVoxels)
|
|
|| OutArguments.RenderMaxDistanceVoxels <= 0.0f
|
|
|| OutArguments.RenderMaxDistanceVoxels > 2048.0f)
|
|
{
|
|
OutError = TEXT("rendermaxdistance must be finite and in (0,2048] voxels.");
|
|
return false;
|
|
}
|
|
// Render does not budget density samples: pixels are rasterised from the one canonical
|
|
// mesh. Keep the legacy renderstep/maxdistance arguments for compatible invocations and use
|
|
// only maxdistance as the raster depth clip.
|
|
if (OutArguments.ExportSize < CHUNK_SIZE
|
|
|| OutArguments.ExportSize > MaxExportSize
|
|
|| OutArguments.ExportSize % CHUNK_SIZE != 0)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("exportsize must be a multiple of CHUNK_SIZE (%d) in [%d,%d] cells per axis."),
|
|
CHUNK_SIZE,
|
|
CHUNK_SIZE,
|
|
MaxExportSize);
|
|
return false;
|
|
}
|
|
if (OutArguments.ExportStep < 1
|
|
|| OutArguments.ExportStep > 8
|
|
|| (OutArguments.ExportStep & (OutArguments.ExportStep - 1)) != 0
|
|
|| CHUNK_SIZE % OutArguments.ExportStep != 0)
|
|
{
|
|
OutError = TEXT("exportstep must be a power of two in [1,8] and divide CHUNK_SIZE.");
|
|
return false;
|
|
}
|
|
if (OutArguments.MaxWalkCells <= 0)
|
|
{
|
|
OutError = TEXT("maxwalkcells must be greater than zero.");
|
|
return false;
|
|
}
|
|
if (!FMath::IsFinite(OutArguments.BudgetMinutes)
|
|
|| OutArguments.BudgetMinutes <= 0.0f
|
|
|| OutArguments.BudgetMinutes > MaxBudgetMinutes)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("budget must be finite and in (0,%g] minutes."), MaxBudgetMinutes);
|
|
return false;
|
|
}
|
|
if (OutArguments.bSampleStacks
|
|
&& (OutArguments.SampleStacksIntervalUs < FVoxelStackSampler::MinIntervalUs
|
|
|| OutArguments.SampleStacksIntervalUs > FVoxelStackSampler::MaxIntervalUs))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("samplestacks interval must be in [%u,%u] microseconds."),
|
|
FVoxelStackSampler::MinIntervalUs,
|
|
FVoxelStackSampler::MaxIntervalUs);
|
|
return false;
|
|
}
|
|
if (OutArguments.bSurfaceRoughnessOverride
|
|
&& !bSurfaceRoughnessParsed)
|
|
{
|
|
OutError = TEXT("surfaceroughness must be a finite number greater than or equal to zero.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bSurfaceRoughnessOverride
|
|
&& (!FMath::IsFinite(OutArguments.SurfaceRoughness)
|
|
|| OutArguments.SurfaceRoughness < 0.0f))
|
|
{
|
|
OutError = TEXT("surfaceroughness must be finite and greater than or equal to zero.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelWarpStrengthOverride
|
|
&& (!bTunnelWarpStrengthParsed
|
|
|| !FMath::IsFinite(OutArguments.TunnelWarpStrength)
|
|
|| OutArguments.TunnelWarpStrength < 0.0f))
|
|
{
|
|
OutError = TEXT("tunnelwarp must be finite and greater than or equal to zero.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bRoughnessNoiseTypeOverride && !bRoughnessNoiseTypeParsed)
|
|
{
|
|
OutError = TEXT("roughnesstype must be FBM, Ridged, Mixed, or Cellular.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bRoughnessFrequencyOverride
|
|
&& (!bRoughnessFrequencyParsed
|
|
|| !FMath::IsFinite(OutArguments.RoughnessFrequency)
|
|
|| OutArguments.RoughnessFrequency <= 0.0f))
|
|
{
|
|
OutError = TEXT("roughnessfrequency must be finite and greater than zero.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorEnabledOverride
|
|
&& (!bTunnelFloorEnabledParsed
|
|
|| (OutArguments.TunnelFloorEnabled != 0
|
|
&& OutArguments.TunnelFloorEnabled != 1)))
|
|
{
|
|
OutError = TEXT("tunnelfloor must be 0 or 1.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorTerracingOverride
|
|
&& (!bTunnelFloorTerracingParsed
|
|
|| (OutArguments.TunnelFloorTerracing != 0
|
|
&& OutArguments.TunnelFloorTerracing != 1)))
|
|
{
|
|
OutError = TEXT("tunnelfloorterracing must be 0 or 1.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorStepOverride
|
|
&& (!bTunnelFloorStepParsed
|
|
|| !FMath::IsFinite(OutArguments.TunnelFloorStep)
|
|
|| OutArguments.TunnelFloorStep < 0.0f))
|
|
{
|
|
OutError = TEXT("tunnelfloorstep must be finite and non-negative.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorMaxLedgeOverride
|
|
&& (!bTunnelFloorMaxLedgeParsed
|
|
|| !FMath::IsFinite(OutArguments.TunnelFloorMaxLedge)
|
|
|| OutArguments.TunnelFloorMaxLedge <= 0.0f))
|
|
{
|
|
OutError = TEXT("tunnelfloormaxledge must be finite and greater than zero.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorGentleOverride
|
|
&& (!bTunnelFloorGentleParsed
|
|
|| !FMath::IsFinite(OutArguments.TunnelFloorGentle)
|
|
|| OutArguments.TunnelFloorGentle < 0.0f
|
|
|| OutArguments.TunnelFloorGentle
|
|
> VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees))
|
|
{
|
|
OutError = TEXT("tunnelfloorgentle must be finite degrees in [0,90].");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorLedgesOverride
|
|
&& (!bTunnelFloorLedgesParsed
|
|
|| OutArguments.TunnelFloorLedges < 0
|
|
|| OutArguments.TunnelFloorLedges > 4096))
|
|
{
|
|
OutError = TEXT("tunnelfloorledges must be an integer in [0,4096].");
|
|
return false;
|
|
}
|
|
if (OutArguments.bTunnelFloorMaxLedgesOverride
|
|
&& (!bTunnelFloorMaxLedgesParsed
|
|
|| OutArguments.TunnelFloorMaxLedges < 1
|
|
|| OutArguments.TunnelFloorMaxLedges > 4096))
|
|
{
|
|
OutError = TEXT("tunnelfloormaxledges must be an integer in [1,4096].");
|
|
return false;
|
|
}
|
|
if (OutArguments.bRoomMouthRiseOverride
|
|
&& (!bRoomMouthRiseParsed
|
|
|| !FMath::IsFinite(OutArguments.RoomMouthRise)
|
|
|| OutArguments.RoomMouthRise < 0.0f
|
|
|| OutArguments.RoomMouthRise > 1.0f))
|
|
{
|
|
OutError = TEXT("roommouthrise must be finite and in [0,1].");
|
|
return false;
|
|
}
|
|
if (OutArguments.bRoomMouthRiseBlendOverride
|
|
&& (!bRoomMouthRiseBlendParsed
|
|
|| !FMath::IsFinite(OutArguments.RoomMouthRiseBlend)
|
|
|| OutArguments.RoomMouthRiseBlend < 8.0f
|
|
|| OutArguments.RoomMouthRiseBlend > 128.0f))
|
|
{
|
|
OutError = TEXT("roommouthriseblend must be finite and in [8,128].");
|
|
return false;
|
|
}
|
|
if (OutArguments.bPassageFloorWritesOverride
|
|
&& (!bPassageFloorWritesParsed
|
|
|| (OutArguments.PassageFloorWrites != 0
|
|
&& OutArguments.PassageFloorWrites != 1)))
|
|
{
|
|
OutError = TEXT("passagefloorwrites must be 0 or 1.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bRenderCameraOverride && !bRenderCameraParsed)
|
|
{
|
|
OutError = TEXT("rendercamera must be three finite comma-separated voxel coordinates.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bRenderTargetOverride && !bRenderTargetParsed)
|
|
{
|
|
OutError = TEXT("rendertarget must be three finite comma-separated voxel coordinates.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bDensitySlice && !bDensitySliceParsed)
|
|
{
|
|
OutError = TEXT(
|
|
"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 (OutArguments.bOwnerFourStratePlan
|
|
&& (OutArguments.SettingsReference.IsEmpty() || !OutArguments.StrateReference.IsEmpty()))
|
|
{
|
|
OutError = TEXT("ownerfourstrateplan requires settingsref and must omit strateref.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bSkipStandaloneCoreAdapterParity && !OutArguments.bStandaloneWorldExport)
|
|
{
|
|
OutError = TEXT("skipstandalonecoreadapterparity requires standaloneworldexport.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreWorldTest
|
|
&& (OutArguments.SettingsReference.IsEmpty() || !OutArguments.StrateReference.IsEmpty()))
|
|
{
|
|
OutError = TEXT("coreworldtest requires settingsref and must omit strateref.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreWorldStreamTraceOnly && !OutArguments.bCoreWorldTest)
|
|
{
|
|
OutError = TEXT("coreworldstreamtrace requires coreworldtest.");
|
|
return false;
|
|
}
|
|
if ((OutArguments.bCoreHybridMesher || OutArguments.bCoreHybridFixturePlacement
|
|
|| OutArguments.bCoreHybridCliffOnly || OutArguments.bCoreHybridFixturesOnly
|
|
|| OutArguments.bCoreFloatingIslandTest || OutArguments.bCoreFallbackTest
|
|
|| OutArguments.bCoreOffTest || OutArguments.bCorePlanOnlyTest)
|
|
&& !OutArguments.bCoreWorldTest)
|
|
{
|
|
OutError = TEXT("Core hybrid tests require coreworldtest.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreHybridFixturePlacement && !OutArguments.bCoreHybridMesher)
|
|
{
|
|
OutError = TEXT("corehybridfixtureplacement requires corehybridmesher=1.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreHybridCliffOnly && !OutArguments.bCoreHybridMesher)
|
|
{
|
|
OutError = TEXT("corehybridcliffonly requires corehybridmesher=1.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreHybridFixturesOnly && !OutArguments.bCoreHybridMesher)
|
|
{
|
|
OutError = TEXT("corehybridfixturesonly requires corehybridmesher=1.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreHybridFixturesOnly
|
|
&& (OutArguments.bCoreHybridCliffOnly || OutArguments.bCoreHybridFixturePlacement))
|
|
{
|
|
OutError = TEXT("corehybridfixturesonly cannot be combined with the other focused hybrid modes.");
|
|
return false;
|
|
}
|
|
const int32 CoreTestModeCount = (OutArguments.bCoreFloatingIslandTest ? 1 : 0)
|
|
+ (OutArguments.bCoreFallbackTest ? 1 : 0)
|
|
+ (OutArguments.bCoreOffTest ? 1 : 0)
|
|
+ (OutArguments.bCorePlanOnlyTest ? 1 : 0);
|
|
if (CoreTestModeCount > 1)
|
|
{
|
|
OutError = TEXT("coreislandtest, corefallbacktest, coreofftest, and coreplantest are exclusive modes.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreFallbackTest && OutArguments.CoreWorldAssemblyManifestPath.IsEmpty())
|
|
{
|
|
OutError = TEXT("corefallbacktest requires a staged coreworldassembly override.");
|
|
return false;
|
|
}
|
|
if (CoreTestModeCount > 0
|
|
&& (OutArguments.bCoreHybridMesher || OutArguments.bCoreHybridFixturePlacement
|
|
|| OutArguments.bCoreHybridCliffOnly || OutArguments.bCoreHybridFixturesOnly))
|
|
{
|
|
OutError = TEXT("The selected Core world mode cannot be combined with hybrid mesher fixtures.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreTestSeedOverride && !OutArguments.bCorePlanOnlyTest)
|
|
{
|
|
OutError = TEXT("coretestseed is accepted only with coreplantest.");
|
|
return false;
|
|
}
|
|
if (OutArguments.bCoreFloatingIslandTest
|
|
&& (OutArguments.bCoreHybridMesher || OutArguments.bCoreHybridFixturePlacement
|
|
|| OutArguments.bCoreHybridCliffOnly || OutArguments.bCoreHybridFixturesOnly))
|
|
{
|
|
OutError = TEXT("coreislandtest cannot be combined with hybrid test modes.");
|
|
return false;
|
|
}
|
|
if (!OutArguments.CoreWorldAssemblyManifestPath.IsEmpty() && !OutArguments.bCoreWorldTest)
|
|
{
|
|
OutError = TEXT("coreworldassembly is accepted only by coreworldtest.");
|
|
return false;
|
|
}
|
|
|
|
if (OutText.IsEmpty())
|
|
{
|
|
OutText = FPaths::Combine(
|
|
VoxelForgeSavedRoot(),
|
|
FString::Printf(TEXT("Explore/seed%d_%s_slot%d"),
|
|
OutArguments.Seed,
|
|
ArchetypeName(OutArguments.Archetype),
|
|
OutArguments.Slot));
|
|
}
|
|
if (FPaths::IsRelative(OutText))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("out must be an absolute path under '%s'."), *VoxelForgeSavedRoot());
|
|
return false;
|
|
}
|
|
OutArguments.OutDirectory = FPaths::ConvertRelativePathToFull(OutText);
|
|
FPaths::NormalizeDirectoryName(OutArguments.OutDirectory);
|
|
if (!IsUnderVoxelForgeSaved(OutArguments.OutDirectory))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("out must be an absolute path under '%s' (got '%s')."),
|
|
*VoxelForgeSavedRoot(), *OutArguments.OutDirectory);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
VoxelDensityProfile::EMode ExploreProfileMode(const FExploreArguments& Arguments)
|
|
{
|
|
if (Arguments.bPerfAttribution)
|
|
{
|
|
return VoxelDensityProfile::EMode::Attribution;
|
|
}
|
|
return Arguments.bProfileDensityFull
|
|
? VoxelDensityProfile::EMode::Full
|
|
: VoxelDensityProfile::EMode::Sampled;
|
|
}
|
|
|
|
struct FExploreBudget
|
|
{
|
|
double StartSeconds = 0.0;
|
|
double LimitSeconds = 0.0;
|
|
bool bTruncated = false;
|
|
FString TruncatedDuring;
|
|
TArray<FString> CompletedModes;
|
|
|
|
FExploreBudget(double InStartSeconds, double InLimitSeconds)
|
|
: StartSeconds(InStartSeconds)
|
|
, LimitSeconds(InLimitSeconds)
|
|
{
|
|
}
|
|
|
|
double ElapsedSeconds() const
|
|
{
|
|
return FPlatformTime::Seconds() - StartSeconds;
|
|
}
|
|
|
|
double RemainingSeconds() const
|
|
{
|
|
return LimitSeconds - ElapsedSeconds();
|
|
}
|
|
|
|
bool ShouldStop(const TCHAR* Phase)
|
|
{
|
|
if (bTruncated)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Leave a small deterministic cleanup window for the current phase to close files and
|
|
// emit the report. The owner-facing hard ceiling is still enforced by the argument cap at
|
|
// 30 minutes; the default 25-minute budget leaves substantially more headroom.
|
|
constexpr double CleanupReserveSeconds = 0.25;
|
|
if (RemainingSeconds() <= CleanupReserveSeconds)
|
|
{
|
|
bTruncated = true;
|
|
TruncatedDuring = Phase != nullptr ? FString(Phase) : TEXT("unknown");
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeExplore] budget reached during %s; keeping completed artifacts."),
|
|
*TruncatedDuring);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void CompleteMode(const TCHAR* Mode)
|
|
{
|
|
if (Mode == nullptr || bTruncated)
|
|
{
|
|
return;
|
|
}
|
|
CompletedModes.AddUnique(FString(Mode));
|
|
}
|
|
};
|
|
|
|
struct FExploreMeshTriangle
|
|
{
|
|
FVector A = FVector::ZeroVector;
|
|
FVector B = FVector::ZeroVector;
|
|
FVector C = FVector::ZeroVector;
|
|
FVector NormalA = FVector::UpVector;
|
|
FVector NormalB = FVector::UpVector;
|
|
FVector NormalC = FVector::UpVector;
|
|
FVector Min = FVector::ZeroVector;
|
|
FVector Max = FVector::ZeroVector;
|
|
FVector Centre = FVector::ZeroVector;
|
|
FVector Normal = FVector::UpVector;
|
|
};
|
|
|
|
struct FExploreMeshBvhNode
|
|
{
|
|
FVector Min = FVector::ZeroVector;
|
|
FVector Max = FVector::ZeroVector;
|
|
int32 Left = INDEX_NONE;
|
|
int32 Right = INDEX_NONE;
|
|
int32 FirstTriangle = 0;
|
|
int32 TriangleCount = 0;
|
|
|
|
bool IsLeaf() const
|
|
{
|
|
return Left == INDEX_NONE && Right == INDEX_NONE;
|
|
}
|
|
};
|
|
|
|
bool ApplyOwnerFourStratePlan(UVoxelSettings* Settings,
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>>& OutDefinitions,
|
|
bool bDuplicateDefinitions, FString& OutError)
|
|
{
|
|
if (Settings == nullptr)
|
|
{
|
|
OutError = TEXT("The transient owner plan has no settings object.");
|
|
return false;
|
|
}
|
|
if (!Settings->Season.IsNull())
|
|
{
|
|
OutError = TEXT("The transient four-strate owner plan cannot replace an assigned season manifest.");
|
|
return false;
|
|
}
|
|
|
|
static const TCHAR* AssetPaths[] = {
|
|
TEXT("/Game/VoxelForge/DA_Strate1.DA_Strate1"),
|
|
TEXT("/Game/VoxelForge/DA_Strate2.DA_Strate2"),
|
|
TEXT("/Game/VoxelForge/DA_Strate3.DA_Strate3"),
|
|
TEXT("/Game/VoxelForge/DA_Strate4.DA_Strate4")
|
|
};
|
|
Settings->TotalStrates = UE_ARRAY_COUNT(AssetPaths);
|
|
Settings->StratePool.Reset();
|
|
Settings->FixedStrates.Reset();
|
|
OutDefinitions.Reset();
|
|
OutDefinitions.Reserve(UE_ARRAY_COUNT(AssetPaths));
|
|
|
|
for (int32 Index = 0; Index < UE_ARRAY_COUNT(AssetPaths); ++Index)
|
|
{
|
|
UVoxelStrateDefinition* AuthoredDefinition = LoadObject<UVoxelStrateDefinition>(
|
|
nullptr, AssetPaths[Index]);
|
|
if (AuthoredDefinition == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Could not load staged owner strate asset '%s'."), AssetPaths[Index]);
|
|
return false;
|
|
}
|
|
UVoxelStrateDefinition* Definition = AuthoredDefinition;
|
|
if (bDuplicateDefinitions)
|
|
{
|
|
Definition = DuplicateObject<UVoxelStrateDefinition>(
|
|
AuthoredDefinition, GetTransientPackage(),
|
|
FName(*FString::Printf(TEXT("VoxelForgeOwnerPlan_Strate_%d"), Index)));
|
|
if (Definition == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Could not make a transient copy of owner strate asset '%s'."), AssetPaths[Index]);
|
|
return false;
|
|
}
|
|
}
|
|
Settings->FixedStrates.Add(Index,
|
|
TSoftObjectPtr<UVoxelStrateDefinition>(Definition));
|
|
OutDefinitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Definition));
|
|
}
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeOwnerPlan] transient fixed slots=4 assets=DA_Strate1,DA_Strate2,DA_Strate3,DA_Strate4 settings_asset_untouched=1"));
|
|
return true;
|
|
}
|
|
|
|
struct FExploreWorld
|
|
{
|
|
TStrongObjectPtr<UVoxelSettings> Settings;
|
|
TStrongObjectPtr<UVoxelStrateManager> Manager;
|
|
TStrongObjectPtr<UVoxelDiffLayer> DiffLayer;
|
|
TStrongObjectPtr<UVoxelGenerator> Generator;
|
|
TStrongObjectPtr<UVoxelMarchingCubesMesher> Mesher;
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
|
|
|
|
// One canonical mesh cache for this commandlet run. Render and export consume these exact
|
|
// triangles; neither path is allowed to ask the density field once per pixel.
|
|
FVoxelMeshData ExploreMesh;
|
|
FVoxelSharedDensityGrid ExploreSharedDensityGrid;
|
|
FIntVector ExploreMeshOrigin = FIntVector::ZeroValue;
|
|
int32 ExploreMeshSize = 0;
|
|
int32 ExploreMeshTileCount = 0;
|
|
int32 ExploreMeshTilesCompleted = 0;
|
|
// Per-tile geometry identity is retained in canonical Z/Y/X order so an optimization can be
|
|
// compared without treating one aggregate hash or triangle total as a shape measurement.
|
|
TArray<int32> ExploreTileTriangleCounts;
|
|
TArray<FString> ExploreTileGeometryHashes;
|
|
double ExploreMeshSeconds = 0.0;
|
|
// Exact wall-clock partition of ExploreMeshSeconds. The first three fields end at the
|
|
// parallel tile launch; merge closes the mesh clock. Remainder is intentionally retained
|
|
// instead of rounding the ledger to 100%.
|
|
double ExploreMeshPreparationSeconds = 0.0;
|
|
double ExploreMeshPreTileSetupSeconds = 0.0;
|
|
double ExploreMeshMergeSeconds = 0.0;
|
|
double ExploreMeshAccountingRemainderSeconds = 0.0;
|
|
double ExploreMeshHashSeconds = 0.0;
|
|
double ExploreDensityGridSeconds = 0.0;
|
|
int64 ExploreDensityGridTotalSamples = 0;
|
|
int64 ExploreDensityGridUniqueSamples = 0;
|
|
int64 ExploreDensityGridDuplicateSamplesBefore = 0;
|
|
int64 ExploreDensityGridDuplicateSamplesAfter = 0;
|
|
bool bExploreDensityGridReused = false;
|
|
int32 ExploreMeshTaskWorkerCount = 0;
|
|
int32 ExploreMeshTaskJobCount = 0;
|
|
int32 ExploreMeshTaskMinBatchSize = 1;
|
|
double ExploreMeshTaskMinSeconds = 0.0;
|
|
double ExploreMeshTaskMeanSeconds = 0.0;
|
|
double ExploreMeshTaskMaxSeconds = 0.0;
|
|
double ExploreMeshTaskSumSeconds = 0.0;
|
|
double ExploreMeshTaskWallSeconds = 0.0;
|
|
double ExploreMeshTaskLaunchOverheadSeconds = 0.0;
|
|
bool bExploreMeshBuilt = false;
|
|
bool bExploreMeshComplete = false;
|
|
FString ExploreMeshError;
|
|
FString ExploreGeometryHash;
|
|
|
|
// A deterministic CPU acceleration structure over the canonical triangles. It is built once
|
|
// after meshing and reused by every viewpoint; no density query is reachable from raster.
|
|
TArray<FExploreMeshTriangle> ExploreTriangles;
|
|
TArray<int32> ExploreTriangleOrder;
|
|
TArray<FExploreMeshBvhNode> ExploreBvhNodes;
|
|
int32 ExploreBvhRoot = INDEX_NONE;
|
|
double ExploreAccelerationSeconds = 0.0;
|
|
bool bExploreAccelerationBuilt = false;
|
|
|
|
int32 TargetBottomWorldZ = 0;
|
|
int32 TargetTopWorldZ = 0;
|
|
float TargetBoundarySealThickness = 4.0f;
|
|
|
|
bool Build(const FExploreArguments& Arguments, FString& OutError)
|
|
{
|
|
const bool bUseAuthoredSettings = !Arguments.SettingsReference.IsEmpty();
|
|
const bool bUseAuthoredStrate = !Arguments.StrateReference.IsEmpty();
|
|
UVoxelStrateDefinition* AuthoredDefinition = nullptr;
|
|
if (bUseAuthoredStrate)
|
|
{
|
|
AuthoredDefinition = LoadObject<UVoxelStrateDefinition>(
|
|
nullptr, *Arguments.StrateReference);
|
|
if (AuthoredDefinition == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Could not load staged strate asset '%s'."),
|
|
*Arguments.StrateReference);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (bUseAuthoredSettings)
|
|
{
|
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
|
nullptr, *Arguments.SettingsReference);
|
|
if (AuthoredSettings == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Could not load staged settings asset '%s'."),
|
|
*Arguments.SettingsReference);
|
|
return false;
|
|
}
|
|
Settings = TStrongObjectPtr<UVoxelSettings>(
|
|
DuplicateObject<UVoxelSettings>(AuthoredSettings, GetTransientPackage()));
|
|
if (!Settings.IsValid())
|
|
{
|
|
OutError = TEXT("Could not duplicate the staged settings asset into the transient package.");
|
|
return false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Settings = TStrongObjectPtr<UVoxelSettings>(
|
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
|
}
|
|
|
|
if (bUseAuthoredSettings)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeScale3AssetSettings] view_xy=%d view_up=%d view_down=%d "
|
|
"pawn_collision_radius_chunks=%d strate_view_margin_chunks=%d clip_radius=%d "
|
|
"render_distance_chunks=%d world_radius_voxels=%.6g edge_seal_thickness_voxels=%.6g "
|
|
"decoration_radius_chunks=%d decoration_near_radius_chunks=%d "
|
|
"decoration_region_size_cells=%d decoration_spacing_voxels=%d "
|
|
"decoration_far_spacing_voxels=%d decoration_march_step_voxels=%d "
|
|
"decoration_column_depth_voxels=%d density_volume_resolution=%d "
|
|
"density_volume_debug_radius_cells=%d origin_spine_radius_voxels=%.6g "
|
|
"max_brush_radius_voxels=%.6g max_total_volume_voxels3=%.6g "
|
|
"inter_strate_gap_chunks=%d skirt_cells=%.6g"),
|
|
Settings->ViewDistanceXY, Settings->ViewDistanceUp, Settings->ViewDistanceDown,
|
|
Settings->PawnCollisionRadiusChunks, Settings->StrateViewMarginChunks,
|
|
Settings->ClipRadius, Settings->RenderDistanceChunks,
|
|
Settings->WorldRadiusVoxels, Settings->EdgeSealThickness,
|
|
Settings->DecorationRadiusChunks, Settings->DecorationNearRadiusChunks,
|
|
Settings->DecorationRegionSizeCells, Settings->DecorationSpacingVoxels,
|
|
Settings->DecorationFarSpacingVoxels, Settings->DecorationMarchStepVoxels,
|
|
Settings->DecorationColumnDepthVoxels, Settings->DensityVolumeResolution,
|
|
Settings->DensityVolumeDebugRadiusCells, Settings->OriginSpineRadius,
|
|
Settings->MaxBrushRadius, Settings->MaxTotalVolume,
|
|
Settings->InterStrateGapChunks, Settings->SkirtCells);
|
|
}
|
|
|
|
const int32 NumStrates = Arguments.bOwnerFourStratePlan ? 4 : bUseAuthoredSettings
|
|
? (Arguments.bGameRuntimeConfig
|
|
? FMath::Max(Settings->TotalStrates, 1)
|
|
: FMath::Max(Settings->TotalStrates, Arguments.Slot + 2))
|
|
: Arguments.Slot + 2;
|
|
if (NumStrates > MaxSyntheticStrates)
|
|
{
|
|
OutError = TEXT("The synthetic layout would exceed the commandlet strate cap.");
|
|
return false;
|
|
}
|
|
|
|
if (!bUseAuthoredSettings)
|
|
{
|
|
Settings->Seed = Arguments.Seed;
|
|
Settings->TotalStrates = NumStrates;
|
|
Settings->InterStrateGapChunks = 0;
|
|
Settings->WorldRadiusVoxels = 0.0f;
|
|
Settings->EdgeSealThickness = 128.0f;
|
|
Settings->OriginSpineRadius = 28.0f;
|
|
Settings->bOpenSurfaceEntry = true;
|
|
}
|
|
else if (Arguments.bSeedOverride)
|
|
{
|
|
Settings->Seed = Arguments.Seed;
|
|
}
|
|
// The transient fixed-definition layout may need to extend an authored settings asset
|
|
// for a requested interior slot. Keep that duplicate's advertised count consistent; the
|
|
// loaded package remains untouched.
|
|
Settings->TotalStrates = NumStrates;
|
|
|
|
if (Arguments.bOwnerFourStratePlan)
|
|
{
|
|
if (!ApplyOwnerFourStratePlan(Settings.Get(), Definitions, true, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (bUseAuthoredStrate)
|
|
{
|
|
// Keep the historical single-asset interior probe isolated from loaded packages.
|
|
Settings->StratePool.Reset();
|
|
Settings->FixedStrates.Reset();
|
|
}
|
|
Definitions.Reserve(NumStrates);
|
|
for (int32 Index = 0; Index < NumStrates; ++Index)
|
|
{
|
|
UVoxelStrateDefinition* Definition = bUseAuthoredStrate
|
|
? DuplicateObject<UVoxelStrateDefinition>(
|
|
AuthoredDefinition, GetTransientPackage())
|
|
: NewObject<UVoxelStrateDefinition>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
if (Definition == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Could not create transient strate definition for slot %d."), Index);
|
|
return false;
|
|
}
|
|
if (!bUseAuthoredStrate)
|
|
{
|
|
Definition->StrateName = FText::FromString(FString::Printf(
|
|
TEXT("VoxelForgeExplore_%s_slot%d"),
|
|
ArchetypeName(Index == Arguments.Slot
|
|
? Arguments.Archetype
|
|
: ECaveGeneratorType::TunnelNetwork),
|
|
Index));
|
|
Definition->StrateDescription = FText::FromString(
|
|
TEXT("Transient, deterministic commandlet definition."));
|
|
// The commandlet's acceptance sweep uses the owner-facing base spacing:
|
|
// 8 chunks * 32 voxels * 0.125 m = 32 m per strate. Production assets may be taller;
|
|
// this keeps the measured tunnel arithmetic tied to the stated ~32 m descent.
|
|
Definition->StrateHeightInChunks = 8;
|
|
Definition->TransitionType = EVoxelStrateTransition::Hard;
|
|
Definition->GeneratorType = Index == Arguments.Slot
|
|
? Arguments.Archetype
|
|
: ECaveGeneratorType::TunnelNetwork;
|
|
if (Arguments.bWormsEnabledOverride)
|
|
{
|
|
Definition->bEnableWorms = Arguments.bWormsEnabled;
|
|
}
|
|
if (Arguments.bWormStrengthOverride
|
|
&& (Definition->GeneratorType == ECaveGeneratorType::TunnelNetwork
|
|
|| Definition->GeneratorType == ECaveGeneratorType::Underwater))
|
|
{
|
|
Definition->GenerationParams.WormStrength = Arguments.WormStrength;
|
|
}
|
|
}
|
|
|
|
const TSoftObjectPtr<UVoxelStrateDefinition> SoftDefinition(Definition);
|
|
Settings->FixedStrates.Add(Index, SoftDefinition);
|
|
if (!bUseAuthoredStrate)
|
|
{
|
|
Settings->StratePool.Add(SoftDefinition);
|
|
}
|
|
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Definition));
|
|
}
|
|
}
|
|
|
|
auto ApplyTargetOverrides = [&Arguments](UVoxelStrateDefinition* Definition) -> bool
|
|
{
|
|
if (Definition == nullptr) { return false; }
|
|
const ECaveGeneratorType TargetArchetype = Definition->GeneratorType;
|
|
if (Arguments.bSurfaceRoughnessOverride)
|
|
{
|
|
switch (TargetArchetype)
|
|
{
|
|
case ECaveGeneratorType::FlatPlain:
|
|
case ECaveGeneratorType::CrystalChamber:
|
|
Definition->SlabParams.FloorRoughness = Arguments.SurfaceRoughness;
|
|
Definition->SlabParams.CeilingRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::Maze:
|
|
Definition->MazeParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
Definition->SurfaceParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::VerticalShafts:
|
|
Definition->VerticalShaftParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
Definition->FloatingIslandParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
case ECaveGeneratorType::Underwater:
|
|
default:
|
|
Definition->GenerationParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
}
|
|
}
|
|
if (Arguments.bRoughnessNoiseTypeOverride
|
|
|| Arguments.bRoughnessFrequencyOverride)
|
|
{
|
|
if (TargetArchetype != ECaveGeneratorType::TunnelNetwork
|
|
&& TargetArchetype != ECaveGeneratorType::Underwater)
|
|
{
|
|
return false;
|
|
}
|
|
if (Arguments.bRoughnessNoiseTypeOverride)
|
|
{
|
|
Definition->GenerationParams.RoughnessNoiseType = Arguments.RoughnessNoiseType;
|
|
}
|
|
if (Arguments.bRoughnessFrequencyOverride)
|
|
{
|
|
Definition->GenerationParams.RoughnessFrequency = Arguments.RoughnessFrequency;
|
|
}
|
|
}
|
|
FStrateGenerationParams& TunnelParams = Definition->GenerationParams;
|
|
if (Arguments.bTunnelWarpStrengthOverride)
|
|
{
|
|
if (TargetArchetype != ECaveGeneratorType::TunnelNetwork
|
|
&& TargetArchetype != ECaveGeneratorType::Underwater)
|
|
{
|
|
return false;
|
|
}
|
|
TunnelParams.TunnelWarpStrength = Arguments.TunnelWarpStrength;
|
|
}
|
|
if (Arguments.bTunnelFloorEnabledOverride)
|
|
{
|
|
// Retain the command-line spelling for old probes, but do not feed the removed
|
|
// floor switch into generation. The serialized property has the same
|
|
// compatibility-only treatment in UVoxelStrateDefinition::PostLoad.
|
|
}
|
|
if (Arguments.bTunnelFloorTerracingOverride)
|
|
{
|
|
TunnelParams.bTunnelFloorTerracingEnabled = Arguments.TunnelFloorTerracing != 0;
|
|
}
|
|
if (Arguments.bTunnelFloorStepOverride)
|
|
{
|
|
TunnelParams.TunnelFloorTerraceStepHeight = Arguments.TunnelFloorStep;
|
|
}
|
|
if (Arguments.bTunnelFloorMaxLedgeOverride)
|
|
{
|
|
TunnelParams.TunnelFloorMaxLedgeHeight = Arguments.TunnelFloorMaxLedge;
|
|
}
|
|
if (Arguments.bTunnelFloorGentleOverride)
|
|
{
|
|
TunnelParams.TunnelFloorGentleSlopeThreshold = Arguments.TunnelFloorGentle;
|
|
}
|
|
if (Arguments.bTunnelFloorLedgesOverride)
|
|
{
|
|
TunnelParams.TunnelFloorLedgeCountPreference = Arguments.TunnelFloorLedges;
|
|
}
|
|
if (Arguments.bTunnelFloorMaxLedgesOverride)
|
|
{
|
|
TunnelParams.TunnelFloorMaxLedges = Arguments.TunnelFloorMaxLedges;
|
|
}
|
|
if (Arguments.bRoomMouthRiseOverride)
|
|
{
|
|
TunnelParams.RoomMouthRiseStrength = Arguments.RoomMouthRise;
|
|
}
|
|
if (Arguments.bRoomMouthRiseBlendOverride)
|
|
{
|
|
TunnelParams.RoomMouthRiseBlendVoxels = Arguments.RoomMouthRiseBlend;
|
|
}
|
|
return true;
|
|
};
|
|
if (!Definitions.IsValidIndex(Arguments.Slot)
|
|
|| !ApplyTargetOverrides(Definitions[Arguments.Slot].Get()))
|
|
{
|
|
OutError = TEXT("The target definition rejected a commandlet override.");
|
|
return false;
|
|
}
|
|
|
|
Manager = TStrongObjectPtr<UVoxelStrateManager>(
|
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!Manager->Initialize(Settings.Get(), Settings->Seed))
|
|
{
|
|
OutError = TEXT("UVoxelStrateManager::Initialize refused the transient layout.");
|
|
return false;
|
|
}
|
|
if (Arguments.bPassageFloorWritesOverride)
|
|
{
|
|
Manager->SetPassageSupportFloorWritesEnabledForDiagnostics(
|
|
Arguments.PassageFloorWrites != 0);
|
|
}
|
|
|
|
if (!Manager->GetLayout().IsValidIndex(Arguments.Slot))
|
|
{
|
|
OutError = TEXT("The requested target slot did not enter the manager layout.");
|
|
return false;
|
|
}
|
|
const FStrateSlot& Target = Manager->GetLayout()[Arguments.Slot];
|
|
if (Target.Definition == nullptr)
|
|
{
|
|
OutError = TEXT("The requested target slot has no resolved definition.");
|
|
return false;
|
|
}
|
|
const ECaveGeneratorType TargetArchetype = Target.Definition->GeneratorType;
|
|
const FStrateGenerationParams& ReportParams = Target.Definition->GenerationParams;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore][OwnerAssets] strate=%s settings=%s slot=%d "
|
|
"seed=%d height_chunks=%d archetype=%s "
|
|
"floor_switch_ignored=1 floor_terracing=%d floor_step=%.9g max_ledge=%.9g "
|
|
"gentle_slope_degrees=%.9g gentle_gradient=%.9g route_cap=%.9g "
|
|
"wind_wave_cap=%d "
|
|
"ledge_preference=%d max_ledges=%d tunnel_warp=%.9g terrain_ops=%d terrace_step=%.9g "
|
|
"layer_spacing=%.9g rib_spacing=%.9g"),
|
|
bUseAuthoredStrate ? *Arguments.StrateReference : TEXT("synthetic"),
|
|
bUseAuthoredSettings ? *Arguments.SettingsReference : TEXT("synthetic"),
|
|
Arguments.Slot, Settings->Seed, Target.Definition->StrateHeightInChunks,
|
|
ArchetypeName(TargetArchetype),
|
|
ReportParams.bTunnelFloorTerracingEnabled ? 1 : 0,
|
|
ReportParams.TunnelFloorTerraceStepHeight,
|
|
ReportParams.TunnelFloorMaxLedgeHeight,
|
|
ReportParams.TunnelFloorGentleSlopeThreshold,
|
|
VoxelPassageGeometry::TunnelFloorGradientFromDegrees(
|
|
ReportParams.TunnelFloorGentleSlopeThreshold),
|
|
VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels,
|
|
VoxelPassageGeometry::TunnelFloorMaximumWindWaves,
|
|
ReportParams.TunnelFloorLedgeCountPreference,
|
|
ReportParams.TunnelFloorMaxLedges,
|
|
ReportParams.TunnelWarpStrength,
|
|
Target.Definition->TerrainOperations.Num(),
|
|
ReportParams.TerraceStepHeight,
|
|
ReportParams.LayerLineSpacing,
|
|
ReportParams.RibbingSpacing);
|
|
TargetBottomWorldZ = Target.BottomChunkZ * CHUNK_SIZE;
|
|
TargetTopWorldZ = (Target.TopChunkZ + 1) * CHUNK_SIZE;
|
|
TargetBoundarySealThickness = BoundarySealForArchetype(
|
|
*Target.Definition, TargetArchetype);
|
|
if (TargetTopWorldZ <= TargetBottomWorldZ)
|
|
{
|
|
OutError = TEXT("The target strate has no positive voxel-space height.");
|
|
return false;
|
|
}
|
|
|
|
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
|
|
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
|
Generator = TStrongObjectPtr<UVoxelGenerator>(
|
|
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
|
|
Generator->InitializeSettings(Settings.Get());
|
|
Generator->SetStrateManager(Manager.Get());
|
|
Generator->SetDiffLayer(DiffLayer.Get());
|
|
Mesher = TStrongObjectPtr<UVoxelMarchingCubesMesher>(
|
|
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
|
|
Mesher->SetGenerator(Generator.Get());
|
|
Mesher->bUseBlockEarlyOut = Arguments.bBlockEarlyOut;
|
|
if (Arguments.bGameRuntimeConfig)
|
|
{
|
|
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
|
|
Mesher->SkirtCells = Settings->SkirtCells;
|
|
Mesher->LODOctaveDrop = Settings->LODOctaveDrop;
|
|
}
|
|
|
|
if (Settings->WorldRadiusVoxels != 0.0f
|
|
|| Generator->WorldRadiusVoxels != 0.0f
|
|
|| Settings->InterStrateGapChunks != 0)
|
|
{
|
|
OutError = TEXT(
|
|
"Explorer invariant failed: WorldRadiusVoxels must stay 0 and lateral regions must stay gated off.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
};
|
|
|
|
struct FRegionEvalPass
|
|
{
|
|
VoxelForgeRegionEval::FTiming Timing;
|
|
VoxelForgeRegionEval::FCandidateVisits Visits;
|
|
TArray<float> Densities;
|
|
TArray<int64> SampleIndices;
|
|
FIntVector Origin = FIntVector::ZeroValue;
|
|
int32 RecipeIndex = INDEX_NONE;
|
|
uint32 RecipeHash = 0;
|
|
int32 PassageCount = 0;
|
|
int32 EditChunkCount = 0;
|
|
int32 TargetedSampleCount = 0;
|
|
double WorldPrepareSeconds = 0.0;
|
|
FString StartedUtc;
|
|
FString EndedUtc;
|
|
};
|
|
|
|
struct FRegionEvalStackSamplerSession
|
|
{
|
|
bool bStarted = false;
|
|
|
|
~FRegionEvalStackSamplerSession()
|
|
{
|
|
if (bStarted)
|
|
{
|
|
FVoxelStackSampler::Get().StopAndWrite();
|
|
}
|
|
}
|
|
|
|
bool Start(uint32 IntervalUs, const FString& OutputDirectory,
|
|
const TCHAR* Scenario = TEXT("region_eval_live_ring"))
|
|
{
|
|
bStarted = FVoxelStackSampler::Get().Start(
|
|
IntervalUs, OutputDirectory, Scenario);
|
|
return bStarted;
|
|
}
|
|
|
|
bool Stop(FVoxelStackSampler::FSummary& OutSummary)
|
|
{
|
|
if (!bStarted)
|
|
{
|
|
return false;
|
|
}
|
|
OutSummary = FVoxelStackSampler::Get().StopAndWrite();
|
|
bStarted = false;
|
|
return OutSummary.bOutputWritten
|
|
&& OutSummary.RegisteredThreadSamples > 0
|
|
&& OutSummary.RetainedSamples > 0;
|
|
}
|
|
};
|
|
|
|
bool SavePng(const FString& FilePath, const TArray<FColor>& Pixels,
|
|
int32 Width, int32 Height, FString& OutError);
|
|
|
|
int32 FindRoomGraphRecipeIndex(int32 Seed)
|
|
{
|
|
for (int32 Index = 0; Index < 64; ++Index)
|
|
{
|
|
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(Seed, Index);
|
|
if (Recipe.ShapeSource.OpClass == EVoxelStrateOpClass::RoomGraphSource)
|
|
{
|
|
return Index;
|
|
}
|
|
}
|
|
return INDEX_NONE;
|
|
}
|
|
|
|
bool PrepareRegionEvalWorld(
|
|
FExploreWorld& World,
|
|
const FExploreArguments& BaseArguments,
|
|
int32 Seed,
|
|
int32& OutRecipeIndex,
|
|
uint32& OutRecipeHash,
|
|
int32& OutEditChunkCount,
|
|
FString& OutError)
|
|
{
|
|
FExploreArguments Arguments = BaseArguments;
|
|
Arguments.Seed = Seed;
|
|
Arguments.bSeedOverride = true;
|
|
Arguments.bRender = false;
|
|
Arguments.bWalk = false;
|
|
Arguments.bExport = false;
|
|
Arguments.bBlockEarlyOut = false;
|
|
if (!World.Build(Arguments, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!World.Definitions.IsValidIndex(Arguments.Slot))
|
|
{
|
|
OutError = TEXT("Region evaluator target definition is missing.");
|
|
return false;
|
|
}
|
|
UVoxelStrateDefinition* Definition = World.Definitions[Arguments.Slot].Get();
|
|
if (Definition == nullptr || Definition->GeneratorType != ECaveGeneratorType::TunnelNetwork)
|
|
{
|
|
OutError = TEXT("Region evaluator requires the owner TunnelNetwork target slot.");
|
|
return false;
|
|
}
|
|
|
|
OutRecipeIndex = FindRoomGraphRecipeIndex(Seed);
|
|
if (OutRecipeIndex == INDEX_NONE)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("No RoomGraphSource recipe found in the 64-entry structure roll for seed %d."), Seed);
|
|
return false;
|
|
}
|
|
const FVoxelOpStackRecipe Recipe = VF_RollStrateStructure(Seed, OutRecipeIndex);
|
|
OutRecipeHash = VF_HashStrateStructureRecipe(Recipe);
|
|
|
|
// Make the selected RoomGraph recipe exercise the authored warp path. The definition is a
|
|
// transient duplicate of DA_Strate3, and both the live and prepared passes receive the same
|
|
// operator parameters and recipe.
|
|
FVoxelStrateArchetypeParams NativeParams;
|
|
NativeParams.TunnelNetworkParams = Definition->GenerationParams;
|
|
NativeParams.TunnelNetworkParams.TunnelWarpStrength = FMath::Max(
|
|
NativeParams.TunnelNetworkParams.TunnelWarpStrength, 1.25f);
|
|
NativeParams.SlabParams = Definition->SlabParams;
|
|
NativeParams.MazeParams = Definition->MazeParams;
|
|
NativeParams.SurfaceParams = Definition->SurfaceParams;
|
|
NativeParams.VerticalShaftParams = Definition->VerticalShaftParams;
|
|
NativeParams.FloatingIslandParams = Definition->FloatingIslandParams;
|
|
FString ComposerError;
|
|
if (!World.Manager->SetComposerOverrideForStrate(
|
|
Arguments.Slot, Seed, Definition->GeneratorType,
|
|
NativeParams, true, &Recipe, ComposerError))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Could not install rolled composer recipe for seed %d: %s"),
|
|
Seed, *ComposerError);
|
|
return false;
|
|
}
|
|
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
FVoxelModification Carve;
|
|
Carve.Center = FVector(8.0f, 4.0f, static_cast<float>(CenterZ));
|
|
Carve.Radius = 6.0f;
|
|
Carve.Strength = -12.0f;
|
|
Carve.Shape = EVoxelBrushShape::Sphere;
|
|
const TArray<FIntVector> CarvedChunks = World.DiffLayer->ApplyModification(Carve);
|
|
FVoxelModification Fill;
|
|
Fill.Center = FVector(-12.0f, 6.0f, static_cast<float>(CenterZ - 10));
|
|
Fill.Radius = 4.0f;
|
|
Fill.Strength = 12.0f;
|
|
Fill.Shape = EVoxelBrushShape::Sphere;
|
|
const TArray<FIntVector> FilledChunks = World.DiffLayer->ApplyModification(Fill);
|
|
if (CarvedChunks.IsEmpty() || FilledChunks.IsEmpty())
|
|
{
|
|
OutError = TEXT("Transient player carve/fill edits did not enter the diff layer.");
|
|
return false;
|
|
}
|
|
OutEditChunkCount = CarvedChunks.Num() + FilledChunks.Num();
|
|
|
|
int32 RecipeSeed = 0;
|
|
ECaveGeneratorType RecipeArchetype = ECaveGeneratorType::TunnelNetwork;
|
|
FVoxelStrateArchetypeParams ResolvedParams;
|
|
FVoxelOpStackRecipe ResolvedRecipe;
|
|
const int32 ChunkZ = FMath::FloorToInt(static_cast<float>(CenterZ) / CHUNK_SIZE);
|
|
if (!World.Manager->GetRecipeForChunk(
|
|
FIntVector(0, 0, ChunkZ), RecipeSeed, RecipeArchetype,
|
|
ResolvedParams, ResolvedRecipe)
|
|
|| !VF_AreStrateStructureRecipesIdentical(Recipe, ResolvedRecipe))
|
|
{
|
|
OutError = TEXT("Composer recipe was not active on the target density lattice.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void AddRegionSampleIndex(
|
|
TSet<int64>& Unique,
|
|
int32 X, int32 Y, int32 Z,
|
|
FIntVector Origin, int32 Dimension)
|
|
{
|
|
const int32 LX = X - Origin.X;
|
|
const int32 LY = Y - Origin.Y;
|
|
const int32 LZ = Z - Origin.Z;
|
|
if (LX >= 0 && LX < Dimension && LY >= 0 && LY < Dimension
|
|
&& LZ >= 0 && LZ < Dimension)
|
|
{
|
|
Unique.Add(VoxelForgeRegionEval::LinearIndex(LX, LY, LZ, Dimension));
|
|
}
|
|
}
|
|
|
|
TArray<int64> BuildRegionEvalSampleIndices(
|
|
const FExploreWorld& World,
|
|
FIntVector Origin,
|
|
int32 Dimension,
|
|
int32 RequestedRandomSamples,
|
|
int32 Seed,
|
|
int32& OutTargetedSamples)
|
|
{
|
|
const int64 Total = static_cast<int64>(Dimension) * Dimension * Dimension;
|
|
const int32 RandomCount = FMath::Clamp(RequestedRandomSamples, 1, static_cast<int32>(Total));
|
|
TArray<int64> Indices;
|
|
Indices.Reserve(RandomCount + 50000);
|
|
|
|
auto Gcd = [](int64 A, int64 B)
|
|
{
|
|
while (B != 0)
|
|
{
|
|
const int64 R = A % B;
|
|
A = B;
|
|
B = R;
|
|
}
|
|
return A;
|
|
};
|
|
int64 Multiplier = 104729;
|
|
while (Gcd(Multiplier, Total) != 1)
|
|
{
|
|
Multiplier += 2;
|
|
}
|
|
const int64 Offset = (static_cast<int64>(Seed) * 48271ll) % Total;
|
|
for (int32 Sample = 0; Sample < RandomCount; ++Sample)
|
|
{
|
|
Indices.Add((static_cast<int64>(Sample) * Multiplier + Offset) % Total);
|
|
}
|
|
|
|
TSet<int64> Unique;
|
|
Unique.Reserve(50000);
|
|
for (const int64 Index : Indices)
|
|
{
|
|
Unique.Add(Index);
|
|
}
|
|
const int32 RandomUniqueCount = Unique.Num();
|
|
|
|
// Dense probes around both edited volumes.
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
const FVector EditCenters[] = {
|
|
FVector(8.0f, 4.0f, static_cast<float>(CenterZ)),
|
|
FVector(-12.0f, 6.0f, static_cast<float>(CenterZ - 10)),
|
|
};
|
|
for (const FVector& Center : EditCenters)
|
|
{
|
|
const int32 CX = FMath::FloorToInt(Center.X);
|
|
const int32 CY = FMath::FloorToInt(Center.Y);
|
|
const int32 CZ = FMath::FloorToInt(Center.Z);
|
|
for (int32 Z = CZ - 8; Z <= CZ + 8; ++Z)
|
|
{
|
|
for (int32 Y = CY - 8; Y <= CY + 8; ++Y)
|
|
{
|
|
for (int32 X = CX - 8; X <= CX + 8; ++X)
|
|
{
|
|
AddRegionSampleIndex(Unique, X, Y, Z, Origin, Dimension);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include lattice sites around each authored route tube, landing room, and floor anchor.
|
|
for (const FVoxelPassage& Passage : World.Manager->GetPassages())
|
|
{
|
|
TArray<FVector> Anchors;
|
|
Anchors.Add(Passage.UpperPoint);
|
|
Anchors.Add(Passage.LowerPoint);
|
|
Anchors.Add(Passage.UpperLanding.StandingPoint);
|
|
Anchors.Add(Passage.UpperLanding.DoorPoint);
|
|
Anchors.Add(Passage.LowerLanding.StandingPoint);
|
|
Anchors.Add(Passage.LowerLanding.DoorPoint);
|
|
Anchors.Append(Passage.ControlPoints);
|
|
for (const FVector& Anchor : Anchors)
|
|
{
|
|
const int32 AX = FMath::FloorToInt(Anchor.X);
|
|
const int32 AY = FMath::FloorToInt(Anchor.Y);
|
|
const int32 AZ = FMath::FloorToInt(Anchor.Z);
|
|
for (int32 Z = AZ - 3; Z <= AZ + 3; ++Z)
|
|
{
|
|
for (int32 Y = AY - 3; Y <= AY + 3; ++Y)
|
|
{
|
|
for (int32 X = AX - 3; X <= AX + 3; ++X)
|
|
{
|
|
AddRegionSampleIndex(Unique, X, Y, Z, Origin, Dimension);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Both strate boundaries are swept across the horizontal region, including ±3 lattice rows.
|
|
const int32 Boundaries[] = { World.TargetTopWorldZ, World.TargetBottomWorldZ };
|
|
for (const int32 BoundaryZ : Boundaries)
|
|
{
|
|
for (int32 Dz = -3; Dz <= 3; ++Dz)
|
|
{
|
|
for (int32 Y = -128; Y <= 128; Y += 8)
|
|
{
|
|
for (int32 X = -128; X <= 128; X += 8)
|
|
{
|
|
AddRegionSampleIndex(Unique, X, Y, BoundaryZ + Dz, Origin, Dimension);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Indices = Unique.Array();
|
|
Indices.Sort();
|
|
OutTargetedSamples = FMath::Max(0, Indices.Num() - RandomUniqueCount);
|
|
return Indices;
|
|
}
|
|
|
|
bool RunRegionEvalPass(
|
|
const FExploreArguments& BaseArguments,
|
|
int32 Seed,
|
|
bool bPrepared,
|
|
bool bRegionProgram,
|
|
float ReachScale,
|
|
bool bFullRing,
|
|
const TArray<int64>* RequestedIndices,
|
|
int32 RequestedSamples,
|
|
bool bCountCandidates,
|
|
bool bKeepDensities,
|
|
FRegionEvalPass& OutPass,
|
|
FString& OutError,
|
|
int32 RequestedWorkerCount = VoxelForgeRegionEval::WorkerCount,
|
|
bool bCollectWholeRunCounters = false)
|
|
{
|
|
OutPass = FRegionEvalPass();
|
|
FExploreWorld World;
|
|
const double PrepareStartSeconds = FPlatformTime::Seconds();
|
|
if (!PrepareRegionEvalWorld(
|
|
World, BaseArguments, Seed, OutPass.RecipeIndex,
|
|
OutPass.RecipeHash, OutPass.EditChunkCount, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
OutPass.WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStartSeconds;
|
|
OutPass.PassageCount = World.Manager->GetPassages().Num();
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
OutPass.Origin = FIntVector(-129, -129, CenterZ - 129);
|
|
if (OutPass.PassageCount <= 0)
|
|
{
|
|
OutError = FString::Printf(TEXT("Seed %d generated no inter-strate route passages."), Seed);
|
|
return false;
|
|
}
|
|
if (RequestedIndices != nullptr)
|
|
{
|
|
OutPass.SampleIndices = *RequestedIndices;
|
|
}
|
|
else
|
|
{
|
|
OutPass.SampleIndices = BuildRegionEvalSampleIndices(
|
|
World, OutPass.Origin, VoxelForgeRegionEval::RingDimension,
|
|
RequestedSamples, Seed, OutPass.TargetedSampleCount);
|
|
}
|
|
if (OutPass.SampleIndices.IsEmpty())
|
|
{
|
|
OutError = TEXT("Region evaluator produced an empty correctness sample set.");
|
|
return false;
|
|
}
|
|
|
|
OutPass.StartedUtc = FDateTime::UtcNow().ToIso8601();
|
|
const bool bEvaluated = VoxelForgeRegionEval::Evaluate(
|
|
*World.Generator, OutPass.Origin, VoxelForgeRegionEval::RingDimension,
|
|
bFullRing ? nullptr : &OutPass.SampleIndices,
|
|
bFullRing, bPrepared, ReachScale,
|
|
bKeepDensities ? &OutPass.Densities : nullptr,
|
|
OutPass.Timing, bRegionProgram, RequestedWorkerCount, bCollectWholeRunCounters);
|
|
OutPass.EndedUtc = FDateTime::UtcNow().ToIso8601();
|
|
if (!bEvaluated)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Density evaluation failed or emitted a non-finite value (seed %d)."), Seed);
|
|
return false;
|
|
}
|
|
if (bCountCandidates)
|
|
{
|
|
OutPass.Visits = VoxelForgeRegionEval::CountCandidates(
|
|
*World.Generator, OutPass.Origin,
|
|
VoxelForgeRegionEval::RingDimension,
|
|
OutPass.SampleIndices, bPrepared, ReachScale);
|
|
if (OutPass.Visits.SampleCount != OutPass.SampleIndices.Num())
|
|
{
|
|
OutError = TEXT("Candidate counter sweep did not consume the full fixed sample set.");
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
TSharedRef<FJsonObject> RegionEvalTimingJson(const FRegionEvalPass& Pass, bool bPrepared)
|
|
{
|
|
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
|
Json->SetStringField(TEXT("path"), Pass.Timing.bRegionProgram
|
|
? TEXT("editor_region_chunk_program")
|
|
: (bPrepared ? TEXT("prepared_8cube_bins") : TEXT("live_GetDensityAt")));
|
|
Json->SetStringField(TEXT("started_utc"), Pass.StartedUtc);
|
|
Json->SetStringField(TEXT("ended_utc"), Pass.EndedUtc);
|
|
Json->SetNumberField(TEXT("worker_count"), Pass.Timing.Workers);
|
|
Json->SetNumberField(TEXT("observed_worker_threads"), Pass.Timing.ObservedWorkerThreads);
|
|
Json->SetNumberField(TEXT("samples"), static_cast<double>(Pass.Timing.SampleCount));
|
|
Json->SetNumberField(TEXT("evaluated_points"), static_cast<double>(Pass.Timing.EvaluatedSamples));
|
|
Json->SetNumberField(TEXT("region_count"), static_cast<double>(Pass.Timing.RegionCount));
|
|
Json->SetNumberField(TEXT("fallback_points"), static_cast<double>(Pass.Timing.FallbackSamples));
|
|
Json->SetNumberField(TEXT("fallback_share"), Pass.Timing.FallbackShare);
|
|
Json->SetNumberField(TEXT("general_path_entries"), static_cast<double>(Pass.Timing.GeneralPathEntries));
|
|
Json->SetNumberField(TEXT("general_path_entries_per_evaluated_sample"),
|
|
Pass.Timing.GeneralPathEntriesPerSample);
|
|
Json->SetNumberField(TEXT("supported_block_setup_entries"),
|
|
static_cast<double>(Pass.Timing.SupportedBlockSetupEntries));
|
|
Json->SetBoolField(TEXT("whole_run_counters_present"), Pass.Timing.bHasWholeRunCounters);
|
|
if (Pass.Timing.bHasWholeRunCounters)
|
|
{
|
|
Json->SetNumberField(TEXT("room_graph_sdf_cache_builds"),
|
|
static_cast<double>(Pass.Timing.SdfCacheBuilds));
|
|
Json->SetNumberField(TEXT("active_op_list_builds"),
|
|
static_cast<double>(Pass.Timing.ActiveOpListBuilds));
|
|
Json->SetNumberField(TEXT("broad_phase_index_builds"),
|
|
static_cast<double>(Pass.Timing.BroadPhaseIndexBuilds));
|
|
Json->SetNumberField(TEXT("sdf_spatial_index_builds"),
|
|
static_cast<double>(Pass.Timing.SdfSpatialIndexBuilds));
|
|
Json->SetNumberField(TEXT("sdf_3d_brick_index_builds"),
|
|
static_cast<double>(Pass.Timing.SdfBrickIndexBuilds));
|
|
Json->SetNumberField(TEXT("candidate_visits_total"),
|
|
static_cast<double>(Pass.Timing.TotalCandidateVisits));
|
|
Json->SetNumberField(TEXT("candidate_visits_per_sample"),
|
|
Pass.Timing.CandidateVisitsPerSample);
|
|
Json->SetNumberField(TEXT("candidate_evaluations_total"),
|
|
static_cast<double>(Pass.Timing.TotalCandidateEvaluations));
|
|
Json->SetNumberField(TEXT("candidate_evaluations_per_sample"),
|
|
Pass.Timing.CandidateEvaluationsPerSample);
|
|
Json->SetNumberField(TEXT("room_candidate_visits"),
|
|
static_cast<double>(Pass.Timing.RoomCandidateVisits));
|
|
Json->SetNumberField(TEXT("room_evaluations"),
|
|
static_cast<double>(Pass.Timing.RoomEvaluations));
|
|
Json->SetNumberField(TEXT("tunnel_candidate_visits"),
|
|
static_cast<double>(Pass.Timing.TunnelCandidateVisits));
|
|
Json->SetNumberField(TEXT("tunnel_evaluations"),
|
|
static_cast<double>(Pass.Timing.TunnelEvaluations));
|
|
Json->SetNumberField(TEXT("tunnel_core_candidate_visits"),
|
|
static_cast<double>(Pass.Timing.TunnelCoreCandidateVisits));
|
|
Json->SetNumberField(TEXT("tunnel_core_evaluations"),
|
|
static_cast<double>(Pass.Timing.TunnelCoreEvaluations));
|
|
Json->SetNumberField(TEXT("passage_candidate_visits"),
|
|
static_cast<double>(Pass.Timing.PassageCandidateVisits));
|
|
Json->SetNumberField(TEXT("passage_evaluations"),
|
|
static_cast<double>(Pass.Timing.PassageEvaluations));
|
|
|
|
TSharedRef<FJsonObject> Attribution = MakeShared<FJsonObject>();
|
|
Attribution->SetNumberField(TEXT("get_density_at_ns_per_sample"),
|
|
Pass.Timing.GetDensityAtNsPerSample);
|
|
Attribution->SetNumberField(TEXT("operator_block_ns_per_sample"),
|
|
Pass.Timing.OperatorBlockNsPerSample);
|
|
Attribution->SetNumberField(TEXT("room_graph_source_ns_per_sample"),
|
|
Pass.Timing.RoomGraphSourceNsPerSample);
|
|
Attribution->SetNumberField(TEXT("room_graph_build_ns_per_sample"),
|
|
Pass.Timing.RoomGraphBuildNsPerSample);
|
|
Attribution->SetNumberField(TEXT("room_graph_sdf_ns_per_sample"),
|
|
Pass.Timing.RoomGraphSdfNsPerSample);
|
|
Attribution->SetNumberField(TEXT("fused_evaluator_ns_per_sample"),
|
|
Pass.Timing.FusedEvaluatorNsPerSample);
|
|
Attribution->SetNumberField(TEXT("tunnel_core_sdf_ns_per_sample"),
|
|
Pass.Timing.TunnelCoreSdfNsPerSample);
|
|
Attribution->SetNumberField(TEXT("tunnel_core_world_ns_per_sample"),
|
|
Pass.Timing.TunnelCoreWorldNsPerSample);
|
|
Attribution->SetNumberField(TEXT("tunnel_core_posts_ns_per_sample"),
|
|
Pass.Timing.TunnelCorePostsNsPerSample);
|
|
Attribution->SetNumberField(TEXT("passage_modifier_ns_per_sample"),
|
|
Pass.Timing.PassageModifierNsPerSample);
|
|
Attribution->SetNumberField(TEXT("passage_structural_posts_ns_per_sample"),
|
|
Pass.Timing.PassageStructuralPostsNsPerSample);
|
|
Attribution->SetNumberField(TEXT("passage_floor_ns_per_sample"),
|
|
Pass.Timing.PassageFloorNsPerSample);
|
|
Attribution->SetNumberField(TEXT("structural_posts_ns_per_sample"),
|
|
Pass.Timing.StructuralPostsNsPerSample);
|
|
Attribution->SetNumberField(TEXT("density_prologue_ns_per_sample"),
|
|
Pass.Timing.DensityPrologueNsPerSample);
|
|
Attribution->SetNumberField(TEXT("density_core_ns_per_sample"),
|
|
Pass.Timing.DensityCoreNsPerSample);
|
|
Attribution->SetNumberField(TEXT("disturbances_ns_per_sample"),
|
|
Pass.Timing.DisturbancesNsPerSample);
|
|
Attribution->SetNumberField(TEXT("boundary_seal_ns_per_sample"),
|
|
Pass.Timing.DensityBoundarySealNsPerSample);
|
|
Attribution->SetNumberField(TEXT("diff_layer_ns_per_sample"),
|
|
Pass.Timing.DiffLayerNsPerSample);
|
|
Attribution->SetNumberField(TEXT("density_tail_ns_per_sample"),
|
|
Pass.Timing.DensityTailNsPerSample);
|
|
Json->SetObjectField(TEXT("fresh_attribution"), Attribution);
|
|
}
|
|
Json->SetNumberField(TEXT("world_setup_seconds_before_density_ring"), Pass.WorldPrepareSeconds);
|
|
Json->SetNumberField(TEXT("wall_seconds"), Pass.Timing.WallSeconds);
|
|
Json->SetNumberField(TEXT("worker_cpu_seconds"), Pass.Timing.WorkerCpuSeconds);
|
|
Json->SetNumberField(TEXT("worker_cpu_ns_per_sample"), Pass.Timing.NanosecondsPerSample);
|
|
const FString WorkerCpuSource = Pass.Timing.bWorkerCpuFromOsThreadTimes
|
|
? FString::Printf(TEXT("summed Windows GetThreadTimes user+kernel CPU across %d fixed work partitions"),
|
|
Pass.Timing.Workers)
|
|
: TEXT("summed worker elapsed fallback; OS thread CPU counters unavailable");
|
|
Json->SetStringField(TEXT("worker_cpu_source"), WorkerCpuSource);
|
|
Json->SetStringField(TEXT("quantized_hash_fnv1a64"),
|
|
FString::Printf(TEXT("%016llx"), static_cast<unsigned long long>(Pass.Timing.QuantizedHash)));
|
|
Json->SetStringField(TEXT("raw_float_hash_fnv1a64"),
|
|
FString::Printf(TEXT("%016llx"), static_cast<unsigned long long>(Pass.Timing.RawFloatHash)));
|
|
Json->SetNumberField(TEXT("saturated_samples"), static_cast<double>(Pass.Timing.SaturatedSamples));
|
|
Json->SetNumberField(TEXT("non_finite_samples"), static_cast<double>(Pass.Timing.NonFiniteSamples));
|
|
if (Pass.Visits.SampleCount > 0)
|
|
{
|
|
Json->SetNumberField(TEXT("probe_candidate_visits_per_sample"), Pass.Visits.PerSample);
|
|
Json->SetNumberField(TEXT("probe_candidate_evaluations_per_sample"), Pass.Visits.EvaluatedPerSample);
|
|
Json->SetNumberField(TEXT("probe_room_candidate_visits"), static_cast<double>(Pass.Visits.Room));
|
|
Json->SetNumberField(TEXT("probe_room_candidate_evaluations"), static_cast<double>(Pass.Visits.RoomEvaluated));
|
|
Json->SetNumberField(TEXT("probe_tunnel_candidate_visits"), static_cast<double>(Pass.Visits.Tunnel));
|
|
Json->SetNumberField(TEXT("probe_tunnel_candidate_evaluations"), static_cast<double>(Pass.Visits.TunnelEvaluated));
|
|
Json->SetNumberField(TEXT("probe_tunnel_core_candidate_visits"), static_cast<double>(Pass.Visits.TunnelCore));
|
|
Json->SetNumberField(TEXT("probe_tunnel_core_candidate_evaluations"), static_cast<double>(Pass.Visits.TunnelCoreEvaluated));
|
|
Json->SetNumberField(TEXT("probe_passage_candidate_visits"), static_cast<double>(Pass.Visits.Passage));
|
|
Json->SetNumberField(TEXT("probe_passage_candidate_evaluations"), static_cast<double>(Pass.Visits.PassageEvaluated));
|
|
Json->SetNumberField(TEXT("probe_tunnel_cache_lookups"), static_cast<double>(Pass.Visits.TunnelCacheLookups));
|
|
Json->SetNumberField(TEXT("probe_tunnel_cache_hits"), static_cast<double>(Pass.Visits.TunnelCacheHits));
|
|
Json->SetNumberField(TEXT("probe_tunnel_cache_misses"), static_cast<double>(Pass.Visits.TunnelCacheMisses));
|
|
Json->SetNumberField(TEXT("probe_sdf_cache_builds"), static_cast<double>(Pass.Visits.SdfCacheBuilds));
|
|
Json->SetNumberField(TEXT("probe_candidate_counter_samples"), static_cast<double>(Pass.Visits.SampleCount));
|
|
Json->SetNumberField(TEXT("probe_attribution_sample_interval"), Pass.Visits.AttributionSampleInterval);
|
|
Json->SetStringField(TEXT("probe_attribution_basis"), TEXT("sampled VoxelDensityProfile phases; diagnostic only, excluded from clean timing"));
|
|
Json->SetNumberField(TEXT("probe_get_density_at_attributed_ns_per_sample"), Pass.Visits.GetDensityAtAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_prologue_attributed_ns_per_sample"), Pass.Visits.DensityPrologueAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_core_attributed_ns_per_sample"), Pass.Visits.DensityCoreAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_disturbances_attributed_ns_per_sample"), Pass.Visits.DensityDisturbancesAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_structural_posts_attributed_ns_per_sample"), Pass.Visits.DensityStructuralPostsAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_boundary_seal_attributed_ns_per_sample"), Pass.Visits.DensityBoundarySealAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_diff_layer_attributed_ns_per_sample"), Pass.Visits.DensityDiffLayerAttributedNsPerSample);
|
|
Json->SetNumberField(TEXT("probe_density_tail_attributed_ns_per_sample"), Pass.Visits.DensityTailAttributedNsPerSample);
|
|
}
|
|
return Json;
|
|
}
|
|
|
|
struct FQuantizedComparison
|
|
{
|
|
int64 Disagreements = 0;
|
|
int64 SignDisagreements = 0;
|
|
double MaxAbsoluteFloatDelta = 0.0;
|
|
int64 FirstIndex = INDEX_NONE;
|
|
};
|
|
|
|
FQuantizedComparison CompareQuantizedArrays(
|
|
const TArray<float>& A, const TArray<float>& B,
|
|
const TArray<int64>* LatticeIndices = nullptr)
|
|
{
|
|
FQuantizedComparison Result;
|
|
if (A.Num() != B.Num())
|
|
{
|
|
Result.Disagreements = FMath::Max(A.Num(), B.Num());
|
|
return Result;
|
|
}
|
|
for (int32 Index = 0; Index < A.Num(); ++Index)
|
|
{
|
|
bool bSaturatedA = false, bFiniteA = false;
|
|
bool bSaturatedB = false, bFiniteB = false;
|
|
const int16 QA = VoxelForgeRegionEval::QuantizeDensity(A[Index], bSaturatedA, bFiniteA);
|
|
const int16 QB = VoxelForgeRegionEval::QuantizeDensity(B[Index], bSaturatedB, bFiniteB);
|
|
Result.MaxAbsoluteFloatDelta = FMath::Max(
|
|
Result.MaxAbsoluteFloatDelta,
|
|
FMath::Abs(static_cast<double>(A[Index]) - static_cast<double>(B[Index])));
|
|
if (!bFiniteA || !bFiniteB || bSaturatedA || bSaturatedB || QA != QB)
|
|
{
|
|
++Result.Disagreements;
|
|
if (Result.FirstIndex == INDEX_NONE)
|
|
{
|
|
Result.FirstIndex = LatticeIndices != nullptr
|
|
? (*LatticeIndices)[Index] : static_cast<int64>(Index);
|
|
}
|
|
}
|
|
if ((QA < 0) != (QB < 0))
|
|
{
|
|
++Result.SignDisagreements;
|
|
}
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
TSharedRef<FJsonObject> RegionEvalComparisonJson(const FQuantizedComparison& Comparison)
|
|
{
|
|
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
|
Json->SetNumberField(TEXT("quantized_disagreements"), static_cast<double>(Comparison.Disagreements));
|
|
Json->SetNumberField(TEXT("quantized_sign_disagreements"), static_cast<double>(Comparison.SignDisagreements));
|
|
Json->SetNumberField(TEXT("max_abs_float_delta"), Comparison.MaxAbsoluteFloatDelta);
|
|
Json->SetNumberField(TEXT("first_disagreement_linear_index"), static_cast<double>(Comparison.FirstIndex));
|
|
return Json;
|
|
}
|
|
|
|
int32 RunRegionEval(const FString& Params)
|
|
{
|
|
FExploreArguments Arguments;
|
|
FString Error;
|
|
if (!ParseArguments(Params, Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] argument error: %s"), *Error);
|
|
return 2;
|
|
}
|
|
int32 RequestedSamples = 262144;
|
|
FParse::Value(*Params, TEXT("regionsamples="), RequestedSamples);
|
|
RequestedSamples = FMath::Clamp(RequestedSamples, 32768, 1000000);
|
|
int32 RequestedWorkerCount = VoxelForgeRegionEval::WorkerCount;
|
|
FParse::Value(*Params, TEXT("regionworkers="), RequestedWorkerCount);
|
|
RequestedWorkerCount = FMath::Clamp(RequestedWorkerCount, 1, 32);
|
|
IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true);
|
|
|
|
if (FParse::Param(*Params, TEXT("regionhashprobe")))
|
|
{
|
|
FRegionEvalStackSamplerSession WorkerHashStackSampler;
|
|
if (Arguments.bSampleStacks
|
|
&& !WorkerHashStackSampler.Start(
|
|
Arguments.SampleStacksIntervalUs, Arguments.OutDirectory,
|
|
TEXT("region_eval_brick_worker_hash")))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] worker hash stack sampler could not be started."));
|
|
return 2;
|
|
}
|
|
|
|
FRegionEvalPass WorkerHashPass;
|
|
const bool bHashPassSucceeded = RunRegionEvalPass(
|
|
Arguments, 1337, true, true, 1.0f, true, nullptr,
|
|
RequestedSamples, false, true, WorkerHashPass, Error,
|
|
RequestedWorkerCount);
|
|
bool bStackCaptureSucceeded = true;
|
|
if (Arguments.bSampleStacks)
|
|
{
|
|
FVoxelStackSampler::FSummary StackSummary;
|
|
bStackCaptureSucceeded = WorkerHashStackSampler.Stop(StackSummary);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeRegionEval] worker-hash stack capture registered=%llu retained=%llu "
|
|
"raw=%s summary=%s"),
|
|
static_cast<unsigned long long>(StackSummary.RegisteredThreadSamples),
|
|
static_cast<unsigned long long>(StackSummary.RetainedSamples),
|
|
*StackSummary.RawSamplesPath, *StackSummary.SummaryPath);
|
|
}
|
|
if (!bHashPassSucceeded || !bStackCaptureSucceeded)
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] worker hash probe failed: %s"),
|
|
Error.IsEmpty() ? TEXT("stack capture retained no worker samples") : *Error);
|
|
return 2;
|
|
}
|
|
|
|
FString ReferenceRawHash;
|
|
FParse::Value(*Params, TEXT("regionreferencehash="), ReferenceRawHash);
|
|
const FString ActualRawHash = FString::Printf(TEXT("%016llx"),
|
|
static_cast<unsigned long long>(WorkerHashPass.Timing.RawFloatHash));
|
|
const bool bHasReferenceHash = !ReferenceRawHash.IsEmpty();
|
|
const bool bMatchesReference = bHasReferenceHash
|
|
&& ActualRawHash.Equals(ReferenceRawHash, ESearchCase::IgnoreCase);
|
|
const bool bProbePassed = bMatchesReference
|
|
&& WorkerHashPass.Timing.SampleCount
|
|
== static_cast<int64>(VoxelForgeRegionEval::RingDimension)
|
|
* VoxelForgeRegionEval::RingDimension * VoxelForgeRegionEval::RingDimension
|
|
&& WorkerHashPass.Timing.SaturatedSamples == 0
|
|
&& WorkerHashPass.Timing.NonFiniteSamples == 0;
|
|
|
|
TSharedRef<FJsonObject> ProbeReport = MakeShared<FJsonObject>();
|
|
ProbeReport->SetBoolField(TEXT("pass"), bProbePassed);
|
|
ProbeReport->SetBoolField(TEXT("worker_count_hash_probe"), true);
|
|
ProbeReport->SetNumberField(TEXT("worker_count"), WorkerHashPass.Timing.Workers);
|
|
ProbeReport->SetNumberField(TEXT("observed_worker_threads"),
|
|
WorkerHashPass.Timing.ObservedWorkerThreads);
|
|
ProbeReport->SetNumberField(TEXT("samples"), WorkerHashPass.Timing.SampleCount);
|
|
ProbeReport->SetNumberField(TEXT("region_count"), WorkerHashPass.Timing.RegionCount);
|
|
ProbeReport->SetNumberField(TEXT("fallback_points"), WorkerHashPass.Timing.FallbackSamples);
|
|
ProbeReport->SetNumberField(TEXT("fallback_share"), WorkerHashPass.Timing.FallbackShare);
|
|
ProbeReport->SetNumberField(TEXT("wall_seconds"), WorkerHashPass.Timing.WallSeconds);
|
|
ProbeReport->SetNumberField(TEXT("worker_cpu_seconds"), WorkerHashPass.Timing.WorkerCpuSeconds);
|
|
ProbeReport->SetStringField(TEXT("reference_raw_float_hash_fnv1a64"), ReferenceRawHash);
|
|
ProbeReport->SetStringField(TEXT("raw_float_hash_fnv1a64"), ActualRawHash);
|
|
ProbeReport->SetBoolField(TEXT("matches_reference"), bMatchesReference);
|
|
|
|
FString ProbeJsonText;
|
|
TSharedRef<TJsonWriter<>> ProbeWriter = TJsonWriterFactory<>::Create(&ProbeJsonText);
|
|
if (!FJsonSerializer::Serialize(ProbeReport, ProbeWriter)
|
|
|| !FFileHelper::SaveStringToFile(
|
|
ProbeJsonText, *FPaths::Combine(Arguments.OutDirectory, TEXT("region_eval.json")),
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] could not write worker hash probe report."));
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeRegionEval] worker hash probe workers=%d observed=%d samples=%lld "
|
|
"raw=%s reference_match=%s report=%s"),
|
|
WorkerHashPass.Timing.Workers, WorkerHashPass.Timing.ObservedWorkerThreads,
|
|
static_cast<long long>(WorkerHashPass.Timing.SampleCount), *ActualRawHash,
|
|
bMatchesReference ? TEXT("yes") : TEXT("no"),
|
|
*FPaths::Combine(Arguments.OutDirectory, TEXT("region_eval.json")));
|
|
return bProbePassed ? 0 : 2;
|
|
}
|
|
|
|
// Region-eval returns before the normal Explore sampler setup. Capture only its two cold live
|
|
// ring trials so prepared-path and multi-seed work cannot pollute the baseline attribution.
|
|
FRegionEvalStackSamplerSession StackSamplerSession;
|
|
if (Arguments.bSampleStacks
|
|
&& !StackSamplerSession.Start(
|
|
Arguments.SampleStacksIntervalUs, Arguments.OutDirectory))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] requested stack sampler could not be started."));
|
|
return 2;
|
|
}
|
|
|
|
const int32 Seeds[] = { 1337, 7331, 19427, 30241, 41011 };
|
|
TArray<TSharedPtr<FJsonValue>> SeedReports;
|
|
TArray<TSharedPtr<FJsonValue>> OldTimingReports;
|
|
TArray<TSharedPtr<FJsonValue>> PreparedTimingReports;
|
|
TSharedPtr<FJsonObject> LiveFullRingDiagnostics;
|
|
TSharedPtr<FJsonObject> PreparedFullRingDiagnostics;
|
|
TArray<float> OldFullDensities;
|
|
TArray<float> PreparedFullDensities;
|
|
TArray<int64> PrimarySampleIndices;
|
|
FQuantizedComparison FullComparison;
|
|
uint64 OldFullRawHash = 0;
|
|
uint64 PreparedFullRawHash = 0;
|
|
uint64 OldFullSaturatedSamples = 0;
|
|
uint64 PreparedFullSaturatedSamples = 0;
|
|
bool bAllSeedComparisonsPass = true;
|
|
bool bFullDeterminismPass = true;
|
|
int32 PrimaryTargetedSampleCount = 0;
|
|
int32 PrimaryPassageCount = 0;
|
|
int32 PrimaryEditChunkCount = 0;
|
|
int32 PrimaryRecipeIndex = INDEX_NONE;
|
|
uint32 PrimaryRecipeHash = 0;
|
|
|
|
// Cold full-ring timings use independent transient worlds for every pass. Caches cannot be
|
|
// warmed by the comparison leg, and the seed/layout/composer/edit payload remains identical.
|
|
for (int32 Trial = 0; Trial < 2; ++Trial)
|
|
{
|
|
FRegionEvalPass Pass;
|
|
const bool bOk = RunRegionEvalPass(
|
|
Arguments, Seeds[0], false, false, 1.0f, true, nullptr,
|
|
RequestedSamples, Trial == 0 && !Arguments.bSampleStacks, true, Pass, Error);
|
|
if (!bOk)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] live full-ring trial failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
if (Trial == 0)
|
|
{
|
|
OldFullDensities = MoveTemp(Pass.Densities);
|
|
OldFullRawHash = Pass.Timing.RawFloatHash;
|
|
OldFullSaturatedSamples = Pass.Timing.SaturatedSamples;
|
|
PrimarySampleIndices = Pass.SampleIndices;
|
|
PrimaryTargetedSampleCount = Pass.TargetedSampleCount;
|
|
PrimaryPassageCount = Pass.PassageCount;
|
|
PrimaryEditChunkCount = Pass.EditChunkCount;
|
|
PrimaryRecipeIndex = Pass.RecipeIndex;
|
|
PrimaryRecipeHash = Pass.RecipeHash;
|
|
}
|
|
else
|
|
{
|
|
bFullDeterminismPass &= Pass.Timing.RawFloatHash == OldFullRawHash;
|
|
}
|
|
OldTimingReports.Add(MakeShared<FJsonValueObject>(RegionEvalTimingJson(Pass, false)));
|
|
}
|
|
if (Arguments.bSampleStacks)
|
|
{
|
|
FVoxelStackSampler::FSummary StackSummary;
|
|
if (!StackSamplerSession.Stop(StackSummary))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] stack capture retained no live-ring worker samples."));
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeRegionEval] live-ring stack capture registered=%llu retained=%llu "
|
|
"raw=%s summary=%s"),
|
|
static_cast<unsigned long long>(StackSummary.RegisteredThreadSamples),
|
|
static_cast<unsigned long long>(StackSummary.RetainedSamples),
|
|
*StackSummary.RawSamplesPath, *StackSummary.SummaryPath);
|
|
}
|
|
if (PrimarySampleIndices.IsEmpty())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] primary statistical sample set is empty."));
|
|
return 2;
|
|
}
|
|
|
|
for (int32 Trial = 0; Trial < 2; ++Trial)
|
|
{
|
|
FRegionEvalPass Pass;
|
|
const bool bOk = RunRegionEvalPass(
|
|
Arguments, Seeds[0], true, true, 1.0f, true, nullptr,
|
|
RequestedSamples, Trial == 0, true, Pass, Error);
|
|
if (!bOk)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] prepared full-ring trial failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
if (Trial == 0)
|
|
{
|
|
PreparedFullDensities = MoveTemp(Pass.Densities);
|
|
PreparedFullRawHash = Pass.Timing.RawFloatHash;
|
|
PreparedFullSaturatedSamples = Pass.Timing.SaturatedSamples;
|
|
FullComparison = CompareQuantizedArrays(OldFullDensities, PreparedFullDensities);
|
|
}
|
|
else
|
|
{
|
|
bFullDeterminismPass &= Pass.Timing.RawFloatHash == PreparedFullRawHash;
|
|
}
|
|
PreparedTimingReports.Add(MakeShared<FJsonValueObject>(RegionEvalTimingJson(Pass, true)));
|
|
}
|
|
bAllSeedComparisonsPass &= FullComparison.Disagreements == 0
|
|
&& FullComparison.SignDisagreements == 0
|
|
&& OldFullSaturatedSamples == 0
|
|
&& PreparedFullSaturatedSamples == 0;
|
|
|
|
// Exact whole-ring counters and sampled phase attribution are collected in separate cold
|
|
// passes. Their diagnostic hooks stay out of the two wall-time trials used for the 600 ms bar.
|
|
FRegionEvalPass LiveDiagnosticPass;
|
|
if (!RunRegionEvalPass(
|
|
Arguments, Seeds[0], false, false, 1.0f, true, nullptr,
|
|
RequestedSamples, false, false, LiveDiagnosticPass, Error,
|
|
VoxelForgeRegionEval::WorkerCount, true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] live full-ring diagnostic pass failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
LiveFullRingDiagnostics = RegionEvalTimingJson(LiveDiagnosticPass, false);
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
FRegionEvalPass PreparedDiagnosticPass;
|
|
if (!RunRegionEvalPass(
|
|
Arguments, Seeds[0], true, true, 1.0f, true, nullptr,
|
|
RequestedSamples, false, false, PreparedDiagnosticPass, Error,
|
|
VoxelForgeRegionEval::WorkerCount, true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] prepared full-ring diagnostic pass failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
PreparedFullRingDiagnostics = RegionEvalTimingJson(PreparedDiagnosticPass, true);
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
|
|
// Statistical fixed-lattice comparisons across four additional independent seeds. The sample
|
|
// set is a deterministic affine permutation of the entire 259^3 ring plus dense strata,
|
|
// passage-floor, landing, carve, and fill probes.
|
|
if (Arguments.bSampleStacks)
|
|
{
|
|
FRegionEvalStackSamplerSession RegionStackSamplerSession;
|
|
if (!RegionStackSamplerSession.Start(
|
|
Arguments.SampleStacksIntervalUs, Arguments.OutDirectory,
|
|
TEXT("region_eval_chunk_ring")))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] chunk stack sampler could not be started."));
|
|
return 2;
|
|
}
|
|
FRegionEvalPass ProfiledRegionPass;
|
|
if (!RunRegionEvalPass(
|
|
Arguments, Seeds[0], true, true, 1.0f, true, nullptr,
|
|
RequestedSamples, false, false, ProfiledRegionPass, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] chunk attribution run failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
FVoxelStackSampler::FSummary RegionStackSummary;
|
|
if (!RegionStackSamplerSession.Stop(RegionStackSummary))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeRegionEval] chunk stack capture retained no worker samples."));
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeRegionEval] chunk stack capture registered=%llu retained=%llu "
|
|
"raw=%s summary=%s"),
|
|
static_cast<unsigned long long>(RegionStackSummary.RegisteredThreadSamples),
|
|
static_cast<unsigned long long>(RegionStackSummary.RetainedSamples),
|
|
*RegionStackSummary.RawSamplesPath, *RegionStackSummary.SummaryPath);
|
|
}
|
|
|
|
for (int32 SeedIndex = 1; SeedIndex < UE_ARRAY_COUNT(Seeds); ++SeedIndex)
|
|
{
|
|
FRegionEvalPass OldPass;
|
|
if (!RunRegionEvalPass(
|
|
Arguments, Seeds[SeedIndex], false, false, 1.0f, false, nullptr,
|
|
RequestedSamples, true, true, OldPass, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] seed %d live sample failed: %s"),
|
|
Seeds[SeedIndex], *Error);
|
|
return 2;
|
|
}
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
FRegionEvalPass NewPass;
|
|
if (!RunRegionEvalPass(
|
|
Arguments, Seeds[SeedIndex], true, true, 1.0f, false, &OldPass.SampleIndices,
|
|
RequestedSamples, true, true, NewPass, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] seed %d prepared sample failed: %s"),
|
|
Seeds[SeedIndex], *Error);
|
|
return 2;
|
|
}
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
const FQuantizedComparison Comparison = CompareQuantizedArrays(
|
|
OldPass.Densities, NewPass.Densities, &OldPass.SampleIndices);
|
|
bAllSeedComparisonsPass &= Comparison.Disagreements == 0
|
|
&& Comparison.SignDisagreements == 0
|
|
&& OldPass.Timing.SaturatedSamples == 0
|
|
&& NewPass.Timing.SaturatedSamples == 0;
|
|
|
|
TSharedRef<FJsonObject> SeedJson = MakeShared<FJsonObject>();
|
|
SeedJson->SetNumberField(TEXT("seed"), Seeds[SeedIndex]);
|
|
SeedJson->SetNumberField(TEXT("samples"), OldPass.Timing.SampleCount);
|
|
SeedJson->SetNumberField(TEXT("targeted_route_edit_boundary_samples"), OldPass.TargetedSampleCount);
|
|
SeedJson->SetNumberField(TEXT("composer_recipe_index"), OldPass.RecipeIndex);
|
|
SeedJson->SetStringField(TEXT("composer_recipe_hash"), FString::Printf(TEXT("%08x"), OldPass.RecipeHash));
|
|
SeedJson->SetNumberField(TEXT("passage_count"), OldPass.PassageCount);
|
|
SeedJson->SetNumberField(TEXT("edit_chunk_count"), OldPass.EditChunkCount);
|
|
SeedJson->SetObjectField(TEXT("comparison"), RegionEvalComparisonJson(Comparison));
|
|
SeedJson->SetObjectField(TEXT("live_candidate_visits"), RegionEvalTimingJson(OldPass, false));
|
|
SeedJson->SetObjectField(TEXT("prepared_candidate_visits"), RegionEvalTimingJson(NewPass, true));
|
|
SeedReports.Add(MakeShared<FJsonValueObject>(SeedJson));
|
|
}
|
|
|
|
// Deliberately shrink one reach, evaluate the same statistical lattice points, and require
|
|
// the reference comparison to catch omitted room influence. The scale is scoped to this one
|
|
// commandlet pass and every worker resets it to 1.0 afterward.
|
|
FRegionEvalPass ShrunkPass;
|
|
if (!RunRegionEvalPass(
|
|
Arguments, Seeds[0], true, true, 0.50f, false, &PrimarySampleIndices,
|
|
RequestedSamples, false, true, ShrunkPass, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] deliberate reach-shrink run failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
FQuantizedComparison ShrinkComparison;
|
|
ShrinkComparison.FirstIndex = INDEX_NONE;
|
|
for (int32 Sample = 0; Sample < ShrunkPass.Densities.Num(); ++Sample)
|
|
{
|
|
const int64 LatticeIndex = PrimarySampleIndices[Sample];
|
|
bool bSaturatedOld = false, bFiniteOld = false;
|
|
bool bSaturatedShrink = false, bFiniteShrink = false;
|
|
const int16 QOld = VoxelForgeRegionEval::QuantizeDensity(
|
|
OldFullDensities[static_cast<int32>(LatticeIndex)], bSaturatedOld, bFiniteOld);
|
|
const int16 QShrink = VoxelForgeRegionEval::QuantizeDensity(
|
|
ShrunkPass.Densities[Sample], bSaturatedShrink, bFiniteShrink);
|
|
ShrinkComparison.MaxAbsoluteFloatDelta = FMath::Max(
|
|
ShrinkComparison.MaxAbsoluteFloatDelta,
|
|
FMath::Abs(static_cast<double>(OldFullDensities[static_cast<int32>(LatticeIndex)])
|
|
- static_cast<double>(ShrunkPass.Densities[Sample])));
|
|
if (!bFiniteOld || !bFiniteShrink || bSaturatedOld || bSaturatedShrink || QOld != QShrink)
|
|
{
|
|
++ShrinkComparison.Disagreements;
|
|
if (ShrinkComparison.FirstIndex == INDEX_NONE) ShrinkComparison.FirstIndex = LatticeIndex;
|
|
}
|
|
if ((QOld < 0) != (QShrink < 0)) ++ShrinkComparison.SignDisagreements;
|
|
}
|
|
|
|
double PreparedWallSum = 0.0;
|
|
for (const TSharedPtr<FJsonValue>& Value : PreparedTimingReports)
|
|
{
|
|
const TSharedPtr<FJsonObject> Json = Value->AsObject();
|
|
PreparedWallSum += Json->GetNumberField(TEXT("wall_seconds"));
|
|
}
|
|
const double PreparedMedianWall = PreparedWallSum / 2.0;
|
|
double OldWallSum = 0.0;
|
|
for (const TSharedPtr<FJsonValue>& Value : OldTimingReports)
|
|
{
|
|
OldWallSum += Value->AsObject()->GetNumberField(TEXT("wall_seconds"));
|
|
}
|
|
const double OldMedianWall = OldWallSum / 2.0;
|
|
bool bSixWorkersObserved = true;
|
|
for (const TSharedPtr<FJsonValue>& Value : OldTimingReports)
|
|
{
|
|
bSixWorkersObserved &= Value->AsObject()->GetNumberField(TEXT("observed_worker_threads"))
|
|
>= VoxelForgeRegionEval::WorkerCount;
|
|
}
|
|
for (const TSharedPtr<FJsonValue>& Value : PreparedTimingReports)
|
|
{
|
|
bSixWorkersObserved &= Value->AsObject()->GetNumberField(TEXT("observed_worker_threads"))
|
|
>= VoxelForgeRegionEval::WorkerCount;
|
|
}
|
|
const bool bSpeedPass = PreparedMedianWall <= 0.600 && bSixWorkersObserved;
|
|
|
|
TSharedRef<FJsonObject> Report = MakeShared<FJsonObject>();
|
|
Report->SetNumberField(TEXT("schema_version"), 2);
|
|
Report->SetStringField(TEXT("route"), TEXT("editor-only explore commandlet; required Unreal composer, manager, and generator types"));
|
|
Report->SetStringField(TEXT("evaluator"), TEXT("editor-only chunk program; region-scoped room/SDF caches, one operator block per chunk, canonical post-stack writers, and counted live point fallbacks"));
|
|
Report->SetStringField(TEXT("timing_basis"), TEXT("two cold wall-time trials per path with counters disabled; exact whole-ring counters and sampled phase attribution are separate diagnostic passes"));
|
|
Report->SetBoolField(TEXT("diagnostic_passes_excluded_from_speed_verdict"), true);
|
|
Report->SetStringField(TEXT("comparison_quantization"), TEXT("signed int16; round ties away from zero; multiply density by 16; saturate outside [-2048, 2047.9375] density voxels"));
|
|
Report->SetNumberField(TEXT("worker_count"), VoxelForgeRegionEval::WorkerCount);
|
|
Report->SetNumberField(TEXT("ring_dimension"), VoxelForgeRegionEval::RingDimension);
|
|
Report->SetNumberField(TEXT("ring_lattice_samples"), static_cast<double>(VoxelForgeRegionEval::RingDimension) * VoxelForgeRegionEval::RingDimension * VoxelForgeRegionEval::RingDimension);
|
|
Report->SetNumberField(TEXT("ring_interval_span_voxels"), VoxelForgeRegionEval::RingDimension - 1);
|
|
Report->SetNumberField(TEXT("ring_wall_p50_live_seconds"), OldMedianWall);
|
|
Report->SetNumberField(TEXT("ring_wall_p50_prepared_seconds"), PreparedMedianWall);
|
|
Report->SetBoolField(TEXT("speed_pass_600ms"), bSpeedPass);
|
|
Report->SetBoolField(TEXT("all_full_ring_trials_used_six_threads"), bSixWorkersObserved);
|
|
Report->SetBoolField(TEXT("five_seed_correctness_pass"), bAllSeedComparisonsPass);
|
|
Report->SetBoolField(TEXT("two_run_determinism_pass"), bFullDeterminismPass);
|
|
Report->SetStringField(TEXT("determinism_basis"), TEXT("bitwise float32 lattice hash on two independent cold worlds for live and prepared paths"));
|
|
Report->SetObjectField(TEXT("full_ring_comparison"), RegionEvalComparisonJson(FullComparison));
|
|
Report->SetArrayField(TEXT("live_full_ring_trials"), OldTimingReports);
|
|
Report->SetArrayField(TEXT("prepared_full_ring_trials"), PreparedTimingReports);
|
|
Report->SetObjectField(TEXT("live_full_ring_diagnostics"), LiveFullRingDiagnostics.ToSharedRef());
|
|
Report->SetObjectField(TEXT("prepared_full_ring_diagnostics"), PreparedFullRingDiagnostics.ToSharedRef());
|
|
Report->SetArrayField(TEXT("additional_seed_comparisons"), SeedReports);
|
|
|
|
TSharedRef<FJsonObject> Coverage = MakeShared<FJsonObject>();
|
|
Coverage->SetStringField(TEXT("owner_settings"), Arguments.SettingsReference);
|
|
Coverage->SetStringField(TEXT("owner_strate"), Arguments.StrateReference);
|
|
Coverage->SetNumberField(TEXT("primary_seed"), Seeds[0]);
|
|
Coverage->SetNumberField(TEXT("primary_targeted_route_edit_boundary_samples"), PrimaryTargetedSampleCount);
|
|
Coverage->SetNumberField(TEXT("target_passages"), PrimaryPassageCount);
|
|
Coverage->SetNumberField(TEXT("transient_edit_chunks"), PrimaryEditChunkCount);
|
|
Coverage->SetNumberField(TEXT("composer_recipe_index"), PrimaryRecipeIndex);
|
|
Coverage->SetStringField(TEXT("composer_recipe_hash"), FString::Printf(TEXT("%08x"), PrimaryRecipeHash));
|
|
Coverage->SetStringField(TEXT("composer_recipe_family"), TEXT("RoomGraphSource with TunnelNetwork params; rolled by VF_RollStrateStructure"));
|
|
Coverage->SetNumberField(TEXT("room_warp_strength_lower_bound"), 1.25);
|
|
Coverage->SetStringField(TEXT("room_bin_reach"), TEXT("cached CullRadiusSq: shape extent + floor-relief envelope + 3x SDF blend + conservative terrain-op reach; mouth-rise reach uses center-to-mouth distance + blend"));
|
|
Coverage->SetStringField(TEXT("tunnel_bin_reach"), TEXT("union of finalized warped-SDF and world-chain centerline bounds expanded by their influence radii, plus both conservative bounding spheres"));
|
|
Coverage->SetStringField(TEXT("warp_coordinates"), TEXT("room bins are queried after the exact live cave warp in the same coordinate domain; tunnel bins union warped-SDF and unwarped world-chain reach"));
|
|
Coverage->SetStringField(TEXT("tunnel_bin_unit"), TEXT("whole cached tunnel chain; per-chain segment loops remain in the exact evaluator"));
|
|
Coverage->SetStringField(TEXT("unbinned_writers"), TEXT("passage/landing posts, disturbances, and player diff edits retain their canonical per-sample order inside the chunk block writer pass"));
|
|
Coverage->SetBoolField(TEXT("route_landing_floor_probes"), PrimaryPassageCount > 0);
|
|
Coverage->SetBoolField(TEXT("strata_top_and_bottom_boundary_sweep"), true);
|
|
Coverage->SetBoolField(TEXT("player_carve_and_fill_edits"), true);
|
|
Report->SetObjectField(TEXT("content_coverage"), Coverage);
|
|
|
|
TSharedRef<FJsonObject> Shrink = MakeShared<FJsonObject>();
|
|
Shrink->SetNumberField(TEXT("room_reach_scale"), 0.50);
|
|
Shrink->SetBoolField(TEXT("deliberately_shrunk_bin_was_caught"), ShrinkComparison.Disagreements > 0);
|
|
Shrink->SetNumberField(TEXT("saturated_samples"), static_cast<double>(ShrunkPass.Timing.SaturatedSamples));
|
|
Shrink->SetNumberField(TEXT("quantized_disagreements"), static_cast<double>(ShrinkComparison.Disagreements));
|
|
Shrink->SetNumberField(TEXT("sign_disagreements"), static_cast<double>(ShrinkComparison.SignDisagreements));
|
|
Shrink->SetNumberField(TEXT("max_abs_float_delta"), ShrinkComparison.MaxAbsoluteFloatDelta);
|
|
Shrink->SetNumberField(TEXT("first_disagreement_linear_index"), static_cast<double>(ShrinkComparison.FirstIndex));
|
|
Report->SetObjectField(TEXT("deliberate_reach_shrink"), Shrink);
|
|
|
|
const bool bPassed = bAllSeedComparisonsPass && bFullDeterminismPass
|
|
&& ShrinkComparison.Disagreements > 0
|
|
&& ShrunkPass.Timing.SaturatedSamples == 0;
|
|
Report->SetStringField(TEXT("status"), bPassed ? TEXT("correctness_pass_speed_measured") : TEXT("correctness_failed"));
|
|
Report->SetBoolField(TEXT("pass"), bPassed);
|
|
Report->SetStringField(TEXT("speed_verdict"), bSpeedPass
|
|
? TEXT("meets 600 ms") : TEXT("misses 600 ms; candidate bins are an ablation, not a full region compiler"));
|
|
FString JsonText;
|
|
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&JsonText);
|
|
if (!FJsonSerializer::Serialize(Report, Writer)
|
|
|| !FFileHelper::SaveStringToFile(
|
|
JsonText, *FPaths::Combine(Arguments.OutDirectory, TEXT("region_eval.json")),
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] could not write region_eval.json."));
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeRegionEval] status=%s speed600=%s live_ms=%.3f prepared_ms=%.3f "
|
|
"full_q16_diff=%lld five_seed=%s deterministic=%s shrink_q16_diff=%lld report=%s"),
|
|
bPassed ? TEXT("pass") : TEXT("fail"), bSpeedPass ? TEXT("yes") : TEXT("no"),
|
|
OldMedianWall * 1000.0, PreparedMedianWall * 1000.0,
|
|
static_cast<long long>(FullComparison.Disagreements),
|
|
bAllSeedComparisonsPass ? TEXT("pass") : TEXT("fail"),
|
|
bFullDeterminismPass ? TEXT("pass") : TEXT("fail"),
|
|
static_cast<long long>(ShrinkComparison.Disagreements),
|
|
*FPaths::Combine(Arguments.OutDirectory, TEXT("region_eval.json")));
|
|
return bPassed ? 0 : 2;
|
|
}
|
|
|
|
struct FStep2dSurfaceSpectrum
|
|
{
|
|
uint64 SurfaceBandSamples = 0;
|
|
double RMSLag1 = 0.0;
|
|
double RMSLag2 = 0.0;
|
|
double RMSLag4 = 0.0;
|
|
};
|
|
|
|
struct FStep2dBoundaryStats
|
|
{
|
|
uint64 BrickBoundarySamples = 0;
|
|
uint64 InteriorSamples = 0;
|
|
uint64 ChunkBoundarySamples = 0;
|
|
uint64 FeatureCellBoundarySamples = 0;
|
|
uint64 FeatureCellInteriorSamples = 0;
|
|
double BrickJumpRms = 0.0;
|
|
double InteriorJumpRms = 0.0;
|
|
double ChunkBoundaryJumpRms = 0.0;
|
|
double FeatureCellJumpRms = 0.0;
|
|
double FeatureCellInteriorJumpRms = 0.0;
|
|
double BrickJumpMax = 0.0;
|
|
double ChunkBoundaryJumpMax = 0.0;
|
|
double FeatureCellJumpMax = 0.0;
|
|
};
|
|
|
|
struct FStep2dBrickProofAudit
|
|
{
|
|
uint64 CorrectlySkippedBricksAudited = 0;
|
|
uint64 CorrectProofSamplesChecked = 0;
|
|
uint64 CorrectProofViolations = 0;
|
|
FIntVector FirstViolationBrickOrigin = FIntVector::ZeroValue;
|
|
FIntVector FirstViolationBrickSize = FIntVector::ZeroValue;
|
|
FIntVector FirstViolationSample = FIntVector::ZeroValue;
|
|
float FirstViolationDensity = 0.0f;
|
|
float FirstViolationCenterMacro = 0.0f;
|
|
float FirstViolationSampleMacro = 0.0f;
|
|
float FirstViolationCenterRoomMacro = 0.0f;
|
|
float FirstViolationSampleRoomMacro = 0.0f;
|
|
float FirstViolationSampleDetail = 0.0f;
|
|
float FirstViolationMacroDeltaPerVoxel = 0.0f;
|
|
float FirstViolationPathMaxGradient = 0.0f;
|
|
float FirstViolationTinyStepMaxGradient = 0.0f;
|
|
FVector FirstViolationPathMaxFrom = FVector::ZeroVector;
|
|
FVector FirstViolationPathMaxTo = FVector::ZeroVector;
|
|
float FirstViolationPathMaxFromMacro = 0.0f;
|
|
float FirstViolationPathMaxToMacro = 0.0f;
|
|
float FirstViolationPathMaxFromRoomMacro = 0.0f;
|
|
float FirstViolationPathMaxToRoomMacro = 0.0f;
|
|
FString FirstViolationPathMaxFromRoomDiagnostic;
|
|
FString FirstViolationPathMaxToRoomDiagnostic;
|
|
bool bFirstViolationWasInsideBrick = false;
|
|
bool bFirstViolationClaimedAir = false;
|
|
uint64 DeliberatelyUndersizedBoundClaimsChecked = 0;
|
|
uint64 DeliberateBoundDisagreements = 0;
|
|
};
|
|
|
|
FStep2dBrickProofAudit AuditStep2dBrickProofs(
|
|
const UVoxelGenerator& Generator,
|
|
FIntVector Origin,
|
|
int32 Dimension,
|
|
bool bParameterized = false)
|
|
{
|
|
FStep2dBrickProofAudit Result;
|
|
const FIntVector Last = Origin + FIntVector(Dimension - 1, Dimension - 1, Dimension - 1);
|
|
const FIntVector FirstBrick(
|
|
FMath::FloorToInt(static_cast<float>(Origin.X) / 8.0f),
|
|
FMath::FloorToInt(static_cast<float>(Origin.Y) / 8.0f),
|
|
FMath::FloorToInt(static_cast<float>(Origin.Z) / 8.0f));
|
|
const FIntVector LastBrick(
|
|
FMath::FloorToInt(static_cast<float>(Last.X) / 8.0f),
|
|
FMath::FloorToInt(static_cast<float>(Last.Y) / 8.0f),
|
|
FMath::FloorToInt(static_cast<float>(Last.Z) / 8.0f));
|
|
uint64 CorrectSkipOrdinal = 0;
|
|
for (int32 BZ = FirstBrick.Z; BZ <= LastBrick.Z; ++BZ)
|
|
for (int32 BY = FirstBrick.Y; BY <= LastBrick.Y; ++BY)
|
|
for (int32 BX = FirstBrick.X; BX <= LastBrick.X; ++BX)
|
|
{
|
|
const FIntVector BrickWorldMin(BX * 8, BY * 8, BZ * 8);
|
|
const FIntVector BrickOrigin(
|
|
FMath::Max(Origin.X, BrickWorldMin.X),
|
|
FMath::Max(Origin.Y, BrickWorldMin.Y),
|
|
FMath::Max(Origin.Z, BrickWorldMin.Z));
|
|
const FIntVector End(
|
|
FMath::Min(BrickWorldMin.X + 8, Origin.X + Dimension),
|
|
FMath::Min(BrickWorldMin.Y + 8, Origin.Y + Dimension),
|
|
FMath::Min(BrickWorldMin.Z + 8, Origin.Z + Dimension));
|
|
const FIntVector Size = End - BrickOrigin;
|
|
bool bProvenAir = false;
|
|
const bool bSoundClaim = bParameterized
|
|
? VoxelForgeRedefinedDensityField::TryClassifyParameterizedBrick(
|
|
Generator, BrickOrigin, Size, bProvenAir, 1.0f)
|
|
: VoxelForgeRedefinedDensityField::TryClassifyBrick(
|
|
Generator, BrickOrigin, Size, bProvenAir, 1.0f);
|
|
if (bSoundClaim)
|
|
{
|
|
const bool bAuditThisSkip = (CorrectSkipOrdinal++ % 128u) == 0u;
|
|
if (bAuditThisSkip)
|
|
{
|
|
++Result.CorrectlySkippedBricksAudited;
|
|
for (int32 Z = -1; Z <= Size.Z; ++Z)
|
|
for (int32 Y = -1; Y <= Size.Y; ++Y)
|
|
for (int32 X = -1; X <= Size.X; ++X)
|
|
{
|
|
const float Density = bParameterized
|
|
? VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, static_cast<float>(BrickOrigin.X + X),
|
|
static_cast<float>(BrickOrigin.Y + Y),
|
|
static_cast<float>(BrickOrigin.Z + Z), nullptr, 1.0f)
|
|
: VoxelForgeRedefinedDensityField::Evaluate(
|
|
Generator, static_cast<float>(BrickOrigin.X + X),
|
|
static_cast<float>(BrickOrigin.Y + Y),
|
|
static_cast<float>(BrickOrigin.Z + Z), nullptr, 1.0f);
|
|
++Result.CorrectProofSamplesChecked;
|
|
if ((Density > 0.0f) != bProvenAir)
|
|
{
|
|
if (Result.CorrectProofViolations == 0)
|
|
{
|
|
Result.FirstViolationBrickOrigin = BrickOrigin;
|
|
Result.FirstViolationBrickSize = Size;
|
|
Result.FirstViolationSample = BrickOrigin + FIntVector(X, Y, Z);
|
|
Result.FirstViolationDensity = Density;
|
|
const FVector ProofMin(
|
|
static_cast<float>(BrickOrigin.X - 1),
|
|
static_cast<float>(BrickOrigin.Y - 1),
|
|
static_cast<float>(BrickOrigin.Z - 1));
|
|
const FVector ProofMax(
|
|
static_cast<float>(BrickOrigin.X + Size.X),
|
|
static_cast<float>(BrickOrigin.Y + Size.Y),
|
|
static_cast<float>(BrickOrigin.Z + Size.Z));
|
|
const FVector ProofCentre = (ProofMin + ProofMax) * 0.5f;
|
|
const FVector SamplePoint(
|
|
static_cast<float>(BrickOrigin.X + X),
|
|
static_cast<float>(BrickOrigin.Y + Y),
|
|
static_cast<float>(BrickOrigin.Z + Z));
|
|
Result.FirstViolationCenterMacro = bParameterized
|
|
? VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, ProofCentre.X, ProofCentre.Y, ProofCentre.Z,
|
|
nullptr, 0.0f)
|
|
: VoxelForgeRedefinedDensityField::Evaluate(
|
|
Generator, ProofCentre.X, ProofCentre.Y, ProofCentre.Z,
|
|
nullptr, 0.0f);
|
|
Result.FirstViolationSampleMacro = bParameterized
|
|
? VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, SamplePoint.X, SamplePoint.Y, SamplePoint.Z,
|
|
nullptr, 0.0f)
|
|
: VoxelForgeRedefinedDensityField::Evaluate(
|
|
Generator, SamplePoint.X, SamplePoint.Y, SamplePoint.Z,
|
|
nullptr, 0.0f);
|
|
if (bParameterized)
|
|
{
|
|
Result.FirstViolationCenterRoomMacro =
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedMacroOnly(
|
|
Generator, ProofCentre.X, ProofCentre.Y, ProofCentre.Z);
|
|
Result.FirstViolationSampleRoomMacro =
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedMacroOnly(
|
|
Generator, SamplePoint.X, SamplePoint.Y, SamplePoint.Z);
|
|
}
|
|
Result.FirstViolationSampleDetail = Density
|
|
- Result.FirstViolationSampleMacro;
|
|
const float CentreDistance = (SamplePoint - ProofCentre).Size();
|
|
Result.FirstViolationMacroDeltaPerVoxel = CentreDistance > 1.0e-6f
|
|
? FMath::Abs(Result.FirstViolationCenterMacro
|
|
- Result.FirstViolationSampleMacro) / CentreDistance
|
|
: 0.0f;
|
|
if (CentreDistance > 1.0e-6f)
|
|
{
|
|
constexpr int32 PathSteps = 32;
|
|
float PreviousMacro = Result.FirstViolationSampleMacro;
|
|
FVector PreviousPoint = SamplePoint;
|
|
for (int32 PathStep = 1; PathStep <= PathSteps; ++PathStep)
|
|
{
|
|
const float Alpha = static_cast<float>(PathStep) / PathSteps;
|
|
const FVector Point = FMath::Lerp(
|
|
SamplePoint, ProofCentre, Alpha);
|
|
const float PathMacro = bParameterized
|
|
? VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, Point.X, Point.Y, Point.Z, nullptr, 0.0f)
|
|
: VoxelForgeRedefinedDensityField::Evaluate(
|
|
Generator, Point.X, Point.Y, Point.Z, nullptr, 0.0f);
|
|
const float PathGradient = FMath::Abs(
|
|
PathMacro - PreviousMacro) * PathSteps / CentreDistance;
|
|
if (PathGradient > Result.FirstViolationPathMaxGradient)
|
|
{
|
|
Result.FirstViolationPathMaxGradient = PathGradient;
|
|
Result.FirstViolationPathMaxFrom = PreviousPoint;
|
|
Result.FirstViolationPathMaxTo = Point;
|
|
Result.FirstViolationPathMaxFromMacro = PreviousMacro;
|
|
Result.FirstViolationPathMaxToMacro = PathMacro;
|
|
if (bParameterized)
|
|
{
|
|
Result.FirstViolationPathMaxFromRoomMacro =
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedMacroOnly(
|
|
Generator, PreviousPoint.X, PreviousPoint.Y,
|
|
PreviousPoint.Z);
|
|
Result.FirstViolationPathMaxToRoomMacro =
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedMacroOnly(
|
|
Generator, Point.X, Point.Y, Point.Z);
|
|
Result.FirstViolationPathMaxFromRoomDiagnostic =
|
|
VoxelForgeRedefinedDensityField::DescribeParameterizedMacroAt(
|
|
Generator, PreviousPoint.X, PreviousPoint.Y,
|
|
PreviousPoint.Z);
|
|
Result.FirstViolationPathMaxToRoomDiagnostic =
|
|
VoxelForgeRedefinedDensityField::DescribeParameterizedMacroAt(
|
|
Generator, Point.X, Point.Y, Point.Z);
|
|
}
|
|
}
|
|
PreviousMacro = PathMacro;
|
|
PreviousPoint = Point;
|
|
}
|
|
}
|
|
constexpr float TinyStep = 0.01f;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
for (int32 SignIndex = 0; SignIndex < 2; ++SignIndex)
|
|
{
|
|
const int32 Sign = SignIndex == 0 ? -1 : 1;
|
|
FVector Point = SamplePoint;
|
|
Point[Axis] += static_cast<float>(Sign) * TinyStep;
|
|
const float NeighborMacro = bParameterized
|
|
? VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, Point.X, Point.Y, Point.Z, nullptr, 0.0f)
|
|
: VoxelForgeRedefinedDensityField::Evaluate(
|
|
Generator, Point.X, Point.Y, Point.Z, nullptr, 0.0f);
|
|
Result.FirstViolationTinyStepMaxGradient = FMath::Max(
|
|
Result.FirstViolationTinyStepMaxGradient,
|
|
FMath::Abs(NeighborMacro - Result.FirstViolationSampleMacro)
|
|
/ TinyStep);
|
|
}
|
|
Result.bFirstViolationWasInsideBrick = X >= 0 && X < Size.X
|
|
&& Y >= 0 && Y < Size.Y && Z >= 0 && Z < Size.Z;
|
|
Result.bFirstViolationClaimedAir = bProvenAir;
|
|
}
|
|
++Result.CorrectProofViolations;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Negative control: classify while omitting all detail, then check the supported
|
|
// maximum detail amplitude. A valid supremum must reject at least some of these
|
|
// sign claims; otherwise this audit would not demonstrate that it can catch a bad bound.
|
|
bool bDeliberatelyClaimedAir = false;
|
|
const bool bUndersizedClaim = bParameterized
|
|
? VoxelForgeRedefinedDensityField::TryClassifyParameterizedBrick(
|
|
Generator, BrickOrigin, Size, bDeliberatelyClaimedAir, 0.0f)
|
|
: VoxelForgeRedefinedDensityField::TryClassifyBrick(
|
|
Generator, BrickOrigin, Size, bDeliberatelyClaimedAir, 0.0f);
|
|
if (bUndersizedClaim)
|
|
{
|
|
++Result.DeliberatelyUndersizedBoundClaimsChecked;
|
|
for (int32 Z = 0; Z < Size.Z; ++Z)
|
|
for (int32 Y = 0; Y < Size.Y; ++Y)
|
|
for (int32 X = 0; X < Size.X; ++X)
|
|
{
|
|
const float MaximumDetailDensity = bParameterized
|
|
? VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, static_cast<float>(BrickOrigin.X + X),
|
|
static_cast<float>(BrickOrigin.Y + Y),
|
|
static_cast<float>(BrickOrigin.Z + Z), nullptr, 4.0f)
|
|
: VoxelForgeRedefinedDensityField::Evaluate(
|
|
Generator, static_cast<float>(BrickOrigin.X + X),
|
|
static_cast<float>(BrickOrigin.Y + Y),
|
|
static_cast<float>(BrickOrigin.Z + Z), nullptr, 4.0f);
|
|
if ((MaximumDetailDensity > 0.0f) != bDeliberatelyClaimedAir)
|
|
{
|
|
++Result.DeliberateBoundDisagreements;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
FStep2dSurfaceSpectrum ComputeStep2dSpectrum(const TArray<float>& Density, int32 Dimension)
|
|
{
|
|
FStep2dSurfaceSpectrum Result;
|
|
double SumSquares[3] = {0.0, 0.0, 0.0};
|
|
uint64 Counts[3] = {0, 0, 0};
|
|
const int32 Lags[] = {1, 2, 4};
|
|
for (int32 Z = 4; Z < Dimension - 4; ++Z)
|
|
for (int32 Y = 4; Y < Dimension - 4; ++Y)
|
|
for (int32 X = 4; X < Dimension - 4; ++X)
|
|
{
|
|
const int32 CenterIndex = (Z * Dimension + Y) * Dimension + X;
|
|
const float Center = Density[CenterIndex];
|
|
if (!FMath::IsFinite(Center) || FMath::Abs(Center) > 12.0f)
|
|
{
|
|
continue;
|
|
}
|
|
++Result.SurfaceBandSamples;
|
|
for (int32 LagIndex = 0; LagIndex < UE_ARRAY_COUNT(Lags); ++LagIndex)
|
|
{
|
|
const int32 Lag = Lags[LagIndex];
|
|
const float Laplacian =
|
|
Density[CenterIndex - Lag] + Density[CenterIndex + Lag]
|
|
+ Density[CenterIndex - Lag * Dimension]
|
|
+ Density[CenterIndex + Lag * Dimension]
|
|
+ Density[CenterIndex - Lag * Dimension * Dimension]
|
|
+ Density[CenterIndex + Lag * Dimension * Dimension]
|
|
- 6.0f * Center;
|
|
SumSquares[LagIndex] += static_cast<double>(Laplacian) * Laplacian;
|
|
++Counts[LagIndex];
|
|
}
|
|
}
|
|
Result.RMSLag1 = Counts[0] > 0 ? FMath::Sqrt(SumSquares[0] / Counts[0]) : 0.0;
|
|
Result.RMSLag2 = Counts[1] > 0 ? FMath::Sqrt(SumSquares[1] / Counts[1]) : 0.0;
|
|
Result.RMSLag4 = Counts[2] > 0 ? FMath::Sqrt(SumSquares[2] / Counts[2]) : 0.0;
|
|
return Result;
|
|
}
|
|
|
|
FIntVector SelectStep2dMeshOrigin(
|
|
const TArray<float>& OldDensity,
|
|
const TArray<float>& NewDensity,
|
|
FIntVector RingOrigin,
|
|
int32 Dimension,
|
|
bool& bOutContainsBothIsoBands)
|
|
{
|
|
bOutContainsBothIsoBands = false;
|
|
float BestCombinedBand = FLT_MAX;
|
|
FIntVector BestSample(Dimension / 2, Dimension / 2, Dimension / 2);
|
|
for (int32 Z = 32; Z < Dimension - 32; ++Z)
|
|
for (int32 Y = 32; Y < Dimension - 32; ++Y)
|
|
for (int32 X = 32; X < Dimension - 32; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
const float OldAbs = FMath::Abs(OldDensity[Index]);
|
|
const float NewAbs = FMath::Abs(NewDensity[Index]);
|
|
if (OldAbs <= 8.0f && NewAbs <= 8.0f)
|
|
{
|
|
const float Combined = OldAbs + NewAbs;
|
|
if (Combined < BestCombinedBand)
|
|
{
|
|
BestCombinedBand = Combined;
|
|
BestSample = FIntVector(X, Y, Z);
|
|
bOutContainsBothIsoBands = true;
|
|
}
|
|
}
|
|
}
|
|
if (!bOutContainsBothIsoBands)
|
|
{
|
|
float BestNewBand = FLT_MAX;
|
|
for (int32 Z = 32; Z < Dimension - 32; ++Z)
|
|
for (int32 Y = 32; Y < Dimension - 32; ++Y)
|
|
for (int32 X = 32; X < Dimension - 32; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
const float NewAbs = FMath::Abs(NewDensity[Index]);
|
|
if (NewAbs < BestNewBand)
|
|
{
|
|
BestNewBand = NewAbs;
|
|
BestSample = FIntVector(X, Y, Z);
|
|
}
|
|
}
|
|
}
|
|
return RingOrigin + BestSample - FIntVector(32, 32, 32);
|
|
}
|
|
|
|
FStep2dBoundaryStats ComputeStep2dBoundaryStats(
|
|
const TArray<float>& Density, int32 Dimension, FIntVector Origin, int32 FeatureCellSpacing)
|
|
{
|
|
FStep2dBoundaryStats Result;
|
|
double BrickSquares = 0.0;
|
|
double InteriorSquares = 0.0;
|
|
double ChunkSquares = 0.0;
|
|
double FeatureSquares = 0.0;
|
|
double FeatureInteriorSquares = 0.0;
|
|
const int32 CellSpacing = FMath::Max(FeatureCellSpacing, 1);
|
|
for (int32 Z = 1; Z < Dimension - 1; ++Z)
|
|
for (int32 Y = 1; Y < Dimension - 1; ++Y)
|
|
for (int32 X = 1; X < Dimension - 1; ++X)
|
|
{
|
|
const int32 CenterIndex = (Z * Dimension + Y) * Dimension + X;
|
|
const int32 Stride[3] = {1, Dimension, Dimension * Dimension};
|
|
const int32 World[3] = {Origin.X + X, Origin.Y + Y, Origin.Z + Z};
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
const double LeftGradient = Density[CenterIndex] - Density[CenterIndex - Stride[Axis]];
|
|
const double RightGradient = Density[CenterIndex + Stride[Axis]] - Density[CenterIndex];
|
|
const double Jump = FMath::Abs(RightGradient - LeftGradient);
|
|
const bool bBrickBoundary = (World[Axis] % 8) == 0;
|
|
const bool bChunkBoundary = (World[Axis] % CHUNK_SIZE) == 0 && Axis < 2;
|
|
const bool bFeatureBoundary = ((World[Axis] + CellSpacing / 2) % CellSpacing) == 0;
|
|
if (FMath::Abs(Density[CenterIndex]) <= VoxelPassageGeometry::MetersToVoxels(3.0f))
|
|
{
|
|
if (bBrickBoundary)
|
|
{
|
|
++Result.BrickBoundarySamples;
|
|
BrickSquares += Jump * Jump;
|
|
Result.BrickJumpMax = FMath::Max(Result.BrickJumpMax, Jump);
|
|
}
|
|
else
|
|
{
|
|
++Result.InteriorSamples;
|
|
InteriorSquares += Jump * Jump;
|
|
}
|
|
if (bChunkBoundary)
|
|
{
|
|
++Result.ChunkBoundarySamples;
|
|
ChunkSquares += Jump * Jump;
|
|
Result.ChunkBoundaryJumpMax = FMath::Max(Result.ChunkBoundaryJumpMax, Jump);
|
|
}
|
|
}
|
|
if (bFeatureBoundary)
|
|
{
|
|
++Result.FeatureCellBoundarySamples;
|
|
FeatureSquares += Jump * Jump;
|
|
}
|
|
else
|
|
{
|
|
++Result.FeatureCellInteriorSamples;
|
|
FeatureInteriorSquares += Jump * Jump;
|
|
}
|
|
if (bFeatureBoundary)
|
|
Result.FeatureCellJumpMax = FMath::Max(Result.FeatureCellJumpMax, Jump);
|
|
}
|
|
}
|
|
Result.BrickJumpRms = Result.BrickBoundarySamples > 0
|
|
? FMath::Sqrt(BrickSquares / Result.BrickBoundarySamples) : 0.0;
|
|
Result.InteriorJumpRms = Result.InteriorSamples > 0
|
|
? FMath::Sqrt(InteriorSquares / Result.InteriorSamples) : 0.0;
|
|
Result.ChunkBoundaryJumpRms = Result.ChunkBoundarySamples > 0
|
|
? FMath::Sqrt(ChunkSquares / Result.ChunkBoundarySamples) : 0.0;
|
|
Result.FeatureCellJumpRms = Result.FeatureCellBoundarySamples > 0
|
|
? FMath::Sqrt(FeatureSquares / Result.FeatureCellBoundarySamples) : 0.0;
|
|
Result.FeatureCellInteriorJumpRms = Result.FeatureCellInteriorSamples > 0
|
|
? FMath::Sqrt(FeatureInteriorSquares / Result.FeatureCellInteriorSamples) : 0.0;
|
|
return Result;
|
|
}
|
|
|
|
bool Step2eBoundaryStatsPass(const FStep2dBoundaryStats& Stats)
|
|
{
|
|
return Stats.BrickBoundarySamples > 0
|
|
&& Stats.InteriorSamples > 0
|
|
&& Stats.ChunkBoundarySamples > 0
|
|
&& Stats.FeatureCellBoundarySamples > 0
|
|
&& Stats.BrickJumpRms <= Stats.InteriorJumpRms * 2.0 + 0.25
|
|
&& Stats.ChunkBoundaryJumpRms <= Stats.InteriorJumpRms * 2.0 + 0.25
|
|
&& Stats.FeatureCellJumpRms <= Stats.FeatureCellInteriorJumpRms * 2.0 + 0.25;
|
|
}
|
|
|
|
FColor Step2dDensityColor(float Density)
|
|
{
|
|
if (FMath::Abs(Density) < 1.0f)
|
|
{
|
|
return FColor(56, 184, 206, 255);
|
|
}
|
|
return Density >= 0.0f
|
|
? FColor(226, 220, 202, 255)
|
|
: FColor(22, 30, 45, 255);
|
|
}
|
|
|
|
void SaveStep2dSideBySideSlice(
|
|
const FString& Path,
|
|
const TArray<float>& OldDensity,
|
|
const TArray<float>& NewDensity,
|
|
int32 Dimension,
|
|
TCHAR Plane,
|
|
int32 FixedIndex,
|
|
FString& OutError)
|
|
{
|
|
const int32 Width = Dimension * 2;
|
|
const int32 Height = Dimension;
|
|
TArray<FColor> Pixels;
|
|
Pixels.SetNumUninitialized(Width * Height);
|
|
for (int32 Row = 0; Row < Height; ++Row)
|
|
for (int32 Column = 0; Column < Dimension; ++Column)
|
|
{
|
|
int32 OldIndex = 0;
|
|
int32 NewIndex = 0;
|
|
if (Plane == TEXT('h'))
|
|
{
|
|
const int32 X = Column;
|
|
const int32 Y = Dimension - 1 - Row;
|
|
const int32 Z = FixedIndex;
|
|
OldIndex = (Z * Dimension + Y) * Dimension + X;
|
|
}
|
|
else if (Plane == TEXT('x'))
|
|
{
|
|
const int32 X = Column;
|
|
const int32 Y = FixedIndex;
|
|
const int32 Z = Dimension - 1 - Row;
|
|
OldIndex = (Z * Dimension + Y) * Dimension + X;
|
|
}
|
|
else
|
|
{
|
|
const int32 X = FixedIndex;
|
|
const int32 Y = Column;
|
|
const int32 Z = Dimension - 1 - Row;
|
|
OldIndex = (Z * Dimension + Y) * Dimension + X;
|
|
}
|
|
NewIndex = OldIndex;
|
|
Pixels[Row * Width + Column] = Step2dDensityColor(OldDensity[OldIndex]);
|
|
Pixels[Row * Width + Dimension + Column] = Step2dDensityColor(NewDensity[NewIndex]);
|
|
}
|
|
SavePng(Path, Pixels, Width, Height, OutError);
|
|
}
|
|
|
|
struct FStep2eVarietyMetrics
|
|
{
|
|
double OpenFraction = 0.0;
|
|
double SurfaceAreaSquareMeters = 0.0;
|
|
double SurfaceAreaPerVolume = 0.0;
|
|
double VerticalAdjacencyShare = 0.0;
|
|
double VerticalAirColumnShare = 0.0;
|
|
double MeanLongestVerticalAirRunVoxels = 0.0;
|
|
int32 LongestVerticalAirRunVoxels = 0;
|
|
double TunnelNeckShare = 0.0;
|
|
int32 ConnectedComponentsAtOneMeter = 0;
|
|
int64 LargestComponentCubicMeters = 0;
|
|
int64 SmallComponentsUpToEightCubicMeters = 0;
|
|
int64 MediumComponentsNineToSixtyFourCubicMeters = 0;
|
|
int64 LargeComponentsSixtyFiveToFiveHundredTwelveCubicMeters = 0;
|
|
int64 VeryLargeComponentsOverFiveHundredTwelveCubicMeters = 0;
|
|
};
|
|
|
|
FStep2eVarietyMetrics ComputeStep2eVarietyMetrics(
|
|
const TArray<float>& Density, int32 Dimension)
|
|
{
|
|
FStep2eVarietyMetrics Result;
|
|
const int64 Total = static_cast<int64>(Dimension) * Dimension * Dimension;
|
|
int64 AirCount = 0;
|
|
uint64 SurfaceEdgeCount = 0;
|
|
uint64 HorizontalAdjacencies = 0;
|
|
uint64 VerticalAdjacencies = 0;
|
|
const int32 ColumnCount = Dimension * Dimension;
|
|
TArray<int32> CurrentVerticalRun;
|
|
CurrentVerticalRun.SetNumZeroed(ColumnCount);
|
|
TArray<int32> LongestVerticalRun;
|
|
LongestVerticalRun.SetNumZeroed(ColumnCount);
|
|
for (int32 Z = 0; Z < Dimension; ++Z)
|
|
for (int32 Y = 0; Y < Dimension; ++Y)
|
|
for (int32 X = 0; X < Dimension; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
const bool bAir = Density[Index] > 0.0f;
|
|
AirCount += bAir ? 1 : 0;
|
|
const int32 ColumnIndex = Y * Dimension + X;
|
|
CurrentVerticalRun[ColumnIndex] = bAir ? CurrentVerticalRun[ColumnIndex] + 1 : 0;
|
|
LongestVerticalRun[ColumnIndex] = FMath::Max(
|
|
LongestVerticalRun[ColumnIndex], CurrentVerticalRun[ColumnIndex]);
|
|
if (X + 1 < Dimension)
|
|
{
|
|
const bool bNextAir = Density[Index + 1] > 0.0f;
|
|
SurfaceEdgeCount += bAir != bNextAir ? 1u : 0u;
|
|
HorizontalAdjacencies += bAir && bNextAir ? 1u : 0u;
|
|
}
|
|
if (Y + 1 < Dimension)
|
|
{
|
|
const bool bNextAir = Density[Index + Dimension] > 0.0f;
|
|
SurfaceEdgeCount += bAir != bNextAir ? 1u : 0u;
|
|
HorizontalAdjacencies += bAir && bNextAir ? 1u : 0u;
|
|
}
|
|
if (Z + 1 < Dimension)
|
|
{
|
|
const bool bNextAir = Density[Index + Dimension * Dimension] > 0.0f;
|
|
SurfaceEdgeCount += bAir != bNextAir ? 1u : 0u;
|
|
VerticalAdjacencies += bAir && bNextAir ? 1u : 0u;
|
|
}
|
|
}
|
|
Result.OpenFraction = Total > 0 ? static_cast<double>(AirCount) / Total : 0.0;
|
|
// Each sign-changing lattice edge estimates one voxel-face patch. This is a
|
|
// consistent edge-count estimate, not a marching-cubes triangle integral.
|
|
constexpr double VoxelMeters = static_cast<double>(VOXEL_SIZE) / 100.0;
|
|
Result.SurfaceAreaSquareMeters = static_cast<double>(SurfaceEdgeCount)
|
|
* VoxelMeters * VoxelMeters;
|
|
const double VolumeCubicMeters = static_cast<double>(Total)
|
|
* VoxelMeters * VoxelMeters * VoxelMeters;
|
|
Result.SurfaceAreaPerVolume = VolumeCubicMeters > 0.0
|
|
? Result.SurfaceAreaSquareMeters / VolumeCubicMeters : 0.0;
|
|
const uint64 AllAdjacencies = HorizontalAdjacencies + VerticalAdjacencies;
|
|
Result.VerticalAdjacencyShare = AllAdjacencies > 0
|
|
? static_cast<double>(VerticalAdjacencies) / AllAdjacencies : 0.0;
|
|
uint64 ColumnsWithAir = 0;
|
|
uint64 TotalLongestRunVoxels = 0;
|
|
for (const int32 LongestRun : LongestVerticalRun)
|
|
{
|
|
if (LongestRun > 0)
|
|
{
|
|
++ColumnsWithAir;
|
|
TotalLongestRunVoxels += static_cast<uint64>(LongestRun);
|
|
Result.LongestVerticalAirRunVoxels = FMath::Max(
|
|
Result.LongestVerticalAirRunVoxels, LongestRun);
|
|
}
|
|
}
|
|
Result.VerticalAirColumnShare = ColumnCount > 0
|
|
? static_cast<double>(ColumnsWithAir) / ColumnCount : 0.0;
|
|
Result.MeanLongestVerticalAirRunVoxels = ColumnsWithAir > 0
|
|
? static_cast<double>(TotalLongestRunVoxels) / ColumnsWithAir : 0.0;
|
|
|
|
constexpr int32 VoxelsPerMeter = static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
constexpr int32 CoarseStep = VoxelsPerMeter;
|
|
const int32 CoarseDimension = (Dimension - 1) / CoarseStep + 1;
|
|
const int32 CoarseCount = CoarseDimension * CoarseDimension * CoarseDimension;
|
|
TArray<int32> Parent;
|
|
Parent.Init(INDEX_NONE, CoarseCount);
|
|
const auto CoarseIndex = [CoarseDimension](int32 X, int32 Y, int32 Z)
|
|
{
|
|
return (Z * CoarseDimension + Y) * CoarseDimension + X;
|
|
};
|
|
int64 CoarseAirCount = 0;
|
|
int64 TunnelNeckCount = 0;
|
|
for (int32 Z = 0; Z < CoarseDimension; ++Z)
|
|
for (int32 Y = 0; Y < CoarseDimension; ++Y)
|
|
for (int32 X = 0; X < CoarseDimension; ++X)
|
|
{
|
|
const int32 SourceIndex = ((Z * CoarseStep) * Dimension + Y * CoarseStep)
|
|
* Dimension + X * CoarseStep;
|
|
if (Density[SourceIndex] <= 0.0f)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 Index = CoarseIndex(X, Y, Z);
|
|
Parent[Index] = Index;
|
|
++CoarseAirCount;
|
|
int32 OpenNeighbors = 0;
|
|
const int32 Delta[6][3] = {
|
|
{-1, 0, 0}, {1, 0, 0}, {0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}};
|
|
for (const auto& D : Delta)
|
|
{
|
|
const int32 NX = X + D[0];
|
|
const int32 NY = Y + D[1];
|
|
const int32 NZ = Z + D[2];
|
|
if (NX < 0 || NY < 0 || NZ < 0
|
|
|| NX >= CoarseDimension || NY >= CoarseDimension || NZ >= CoarseDimension)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 NSource = ((NZ * CoarseStep) * Dimension + NY * CoarseStep)
|
|
* Dimension + NX * CoarseStep;
|
|
OpenNeighbors += Density[NSource] > 0.0f ? 1 : 0;
|
|
}
|
|
TunnelNeckCount += OpenNeighbors <= 4 ? 1 : 0;
|
|
}
|
|
Result.TunnelNeckShare = CoarseAirCount > 0
|
|
? static_cast<double>(TunnelNeckCount) / CoarseAirCount : 0.0;
|
|
|
|
const auto FindRoot = [&Parent](int32 Index)
|
|
{
|
|
int32 Root = Index;
|
|
while (Parent[Root] != Root)
|
|
{
|
|
Root = Parent[Root];
|
|
}
|
|
while (Parent[Index] != Index)
|
|
{
|
|
const int32 Next = Parent[Index];
|
|
Parent[Index] = Root;
|
|
Index = Next;
|
|
}
|
|
return Root;
|
|
};
|
|
for (int32 Z = 0; Z < CoarseDimension; ++Z)
|
|
for (int32 Y = 0; Y < CoarseDimension; ++Y)
|
|
for (int32 X = 0; X < CoarseDimension; ++X)
|
|
{
|
|
const int32 Index = CoarseIndex(X, Y, Z);
|
|
if (Parent[Index] == INDEX_NONE)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 Neighbors[3] = {
|
|
X > 0 ? CoarseIndex(X - 1, Y, Z) : INDEX_NONE,
|
|
Y > 0 ? CoarseIndex(X, Y - 1, Z) : INDEX_NONE,
|
|
Z > 0 ? CoarseIndex(X, Y, Z - 1) : INDEX_NONE};
|
|
for (const int32 Neighbor : Neighbors)
|
|
{
|
|
if (Neighbor == INDEX_NONE || Parent[Neighbor] == INDEX_NONE)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 RootA = FindRoot(Index);
|
|
const int32 RootB = FindRoot(Neighbor);
|
|
if (RootA != RootB)
|
|
{
|
|
Parent[RootB] = RootA;
|
|
}
|
|
}
|
|
}
|
|
TMap<int32, int64> ComponentSizes;
|
|
for (int32 Index = 0; Index < CoarseCount; ++Index)
|
|
{
|
|
if (Parent[Index] != INDEX_NONE)
|
|
{
|
|
++ComponentSizes.FindOrAdd(FindRoot(Index));
|
|
}
|
|
}
|
|
Result.ConnectedComponentsAtOneMeter = ComponentSizes.Num();
|
|
for (const TPair<int32, int64>& Pair : ComponentSizes)
|
|
{
|
|
const int64 Size = Pair.Value;
|
|
Result.LargestComponentCubicMeters = FMath::Max(Result.LargestComponentCubicMeters, Size);
|
|
if (Size <= 8) ++Result.SmallComponentsUpToEightCubicMeters;
|
|
else if (Size <= 64) ++Result.MediumComponentsNineToSixtyFourCubicMeters;
|
|
else if (Size <= 512) ++Result.LargeComponentsSixtyFiveToFiveHundredTwelveCubicMeters;
|
|
else ++Result.VeryLargeComponentsOverFiveHundredTwelveCubicMeters;
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
struct FStep2fFragmentMetrics
|
|
{
|
|
int32 AirComponents = 0;
|
|
int32 SolidComponents = 0;
|
|
int64 AirCrumbsBelowOneCubicMeter = 0;
|
|
int64 SolidCrumbsBelowOneCubicMeter = 0;
|
|
int64 AirCrumbVoxelSamples = 0;
|
|
int64 SolidCrumbVoxelSamples = 0;
|
|
int64 AirCrumbsAfterBoundaryPadding = -1;
|
|
int64 SolidCrumbsAfterBoundaryPadding = -1;
|
|
int32 LargestAirComponentVoxels = 0;
|
|
int32 LargestSolidComponentVoxels = 0;
|
|
struct FCrumbComponent
|
|
{
|
|
bool bAir = false;
|
|
int32 Samples = 0;
|
|
FIntVector Min = FIntVector::ZeroValue;
|
|
FIntVector Max = FIntVector::ZeroValue;
|
|
FIntVector Root = FIntVector::ZeroValue;
|
|
int32 SignMismatchSamples = 0;
|
|
float DensityAtRoot = 0.0f;
|
|
float DensityAtMin = 0.0f;
|
|
};
|
|
TArray<FCrumbComponent> CrumbComponents;
|
|
};
|
|
|
|
FStep2fFragmentMetrics ComputeStep2fFragmentMetrics(
|
|
const TArray<float>& Density, int32 Dimension,
|
|
int32 CoreInset = 0, int32 CoreDimension = 0)
|
|
{
|
|
FStep2fFragmentMetrics Result;
|
|
const int32 Count = Density.Num();
|
|
if (Count <= 0 || Count != Dimension * Dimension * Dimension)
|
|
{
|
|
return Result;
|
|
}
|
|
TArray<int32> Parent;
|
|
Parent.Init(-1, Count);
|
|
const auto FindRoot = [&Parent](int32 Index)
|
|
{
|
|
int32 Root = Index;
|
|
while (Parent[Root] >= 0)
|
|
{
|
|
Root = Parent[Root];
|
|
}
|
|
while (Index != Root && Parent[Index] >= 0)
|
|
{
|
|
const int32 Next = Parent[Index];
|
|
Parent[Index] = Root;
|
|
Index = Next;
|
|
}
|
|
return Root;
|
|
};
|
|
const auto Join = [&Parent, &FindRoot](int32 A, int32 B)
|
|
{
|
|
int32 RootA = FindRoot(A);
|
|
int32 RootB = FindRoot(B);
|
|
if (RootA == RootB)
|
|
{
|
|
return;
|
|
}
|
|
if (Parent[RootA] > Parent[RootB])
|
|
{
|
|
Swap(RootA, RootB);
|
|
}
|
|
Parent[RootA] += Parent[RootB];
|
|
Parent[RootB] = RootA;
|
|
};
|
|
for (int32 Z = 0; Z < Dimension; ++Z)
|
|
for (int32 Y = 0; Y < Dimension; ++Y)
|
|
for (int32 X = 0; X < Dimension; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
const bool bAir = Density[Index] > 0.0f;
|
|
// Use 26-neighbor connectivity on the sign lattice. Diagonally adjacent
|
|
// samples belong to the same continuous marching-cubes component; mesh
|
|
// islands and folded triangles are measured separately below.
|
|
for (int32 DeltaZ = -1; DeltaZ <= 0; ++DeltaZ)
|
|
for (int32 DeltaY = -1; DeltaY <= 1; ++DeltaY)
|
|
for (int32 DeltaX = -1; DeltaX <= 1; ++DeltaX)
|
|
{
|
|
if (DeltaZ == 0 && (DeltaY > 0 || (DeltaY == 0 && DeltaX >= 0)))
|
|
{
|
|
continue;
|
|
}
|
|
const int32 NeighborX = X + DeltaX;
|
|
const int32 NeighborY = Y + DeltaY;
|
|
const int32 NeighborZ = Z + DeltaZ;
|
|
if (NeighborX < 0 || NeighborY < 0 || NeighborZ < 0
|
|
|| NeighborX >= Dimension || NeighborY >= Dimension
|
|
|| NeighborZ >= Dimension)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 Neighbor = (NeighborZ * Dimension + NeighborY) * Dimension
|
|
+ NeighborX;
|
|
if ((Density[Neighbor] > 0.0f) == bAir)
|
|
{
|
|
Join(Index, Neighbor);
|
|
}
|
|
}
|
|
}
|
|
TSet<int32> ComponentsIntersectingCore;
|
|
if (CoreDimension > 0 && CoreInset >= 0
|
|
&& CoreInset + CoreDimension <= Dimension)
|
|
{
|
|
for (int32 Z = CoreInset; Z < CoreInset + CoreDimension; ++Z)
|
|
for (int32 Y = CoreInset; Y < CoreInset + CoreDimension; ++Y)
|
|
for (int32 X = CoreInset; X < CoreInset + CoreDimension; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
ComponentsIntersectingCore.Add(FindRoot(Index));
|
|
}
|
|
}
|
|
constexpr int32 VoxelsPerMeter = static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
constexpr int32 VoxelsPerCubicMeter = VoxelsPerMeter * VoxelsPerMeter * VoxelsPerMeter;
|
|
TMap<int32, FStep2fFragmentMetrics::FCrumbComponent> CrumbByRoot;
|
|
for (int32 Index = 0; Index < Count; ++Index)
|
|
{
|
|
if (Parent[Index] >= 0)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 ComponentVoxels = -Parent[Index];
|
|
const bool bAir = Density[Index] > 0.0f;
|
|
const bool bIntersectsCore = ComponentsIntersectingCore.IsEmpty()
|
|
|| ComponentsIntersectingCore.Contains(Index);
|
|
if (bAir)
|
|
{
|
|
++Result.AirComponents;
|
|
Result.LargestAirComponentVoxels = FMath::Max(
|
|
Result.LargestAirComponentVoxels, ComponentVoxels);
|
|
if (ComponentVoxels < VoxelsPerCubicMeter && bIntersectsCore)
|
|
{
|
|
++Result.AirCrumbsBelowOneCubicMeter;
|
|
Result.AirCrumbVoxelSamples += ComponentVoxels;
|
|
if (Result.CrumbComponents.Num() + CrumbByRoot.Num() < 32)
|
|
{
|
|
FStep2fFragmentMetrics::FCrumbComponent& Crumb =
|
|
CrumbByRoot.Add(Index);
|
|
Crumb.bAir = true;
|
|
Crumb.Samples = ComponentVoxels;
|
|
Crumb.Min = FIntVector(Dimension, Dimension, Dimension);
|
|
Crumb.Max = FIntVector::ZeroValue;
|
|
Crumb.Root = FIntVector(Index % Dimension,
|
|
(Index / Dimension) % Dimension, Index / (Dimension * Dimension));
|
|
Crumb.DensityAtRoot = Density[Index];
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
++Result.SolidComponents;
|
|
Result.LargestSolidComponentVoxels = FMath::Max(
|
|
Result.LargestSolidComponentVoxels, ComponentVoxels);
|
|
if (ComponentVoxels < VoxelsPerCubicMeter && bIntersectsCore)
|
|
{
|
|
++Result.SolidCrumbsBelowOneCubicMeter;
|
|
Result.SolidCrumbVoxelSamples += ComponentVoxels;
|
|
if (Result.CrumbComponents.Num() + CrumbByRoot.Num() < 32)
|
|
{
|
|
FStep2fFragmentMetrics::FCrumbComponent& Crumb =
|
|
CrumbByRoot.Add(Index);
|
|
Crumb.bAir = false;
|
|
Crumb.Samples = ComponentVoxels;
|
|
Crumb.Min = FIntVector(Dimension, Dimension, Dimension);
|
|
Crumb.Max = FIntVector::ZeroValue;
|
|
Crumb.Root = FIntVector(Index % Dimension,
|
|
(Index / Dimension) % Dimension, Index / (Dimension * Dimension));
|
|
Crumb.DensityAtRoot = Density[Index];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!CrumbByRoot.IsEmpty())
|
|
{
|
|
for (int32 Index = 0; Index < Count; ++Index)
|
|
{
|
|
const int32 Root = FindRoot(Index);
|
|
FStep2fFragmentMetrics::FCrumbComponent* Crumb = CrumbByRoot.Find(Root);
|
|
if (Crumb == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 X = Index % Dimension;
|
|
const int32 Y = (Index / Dimension) % Dimension;
|
|
const int32 Z = Index / (Dimension * Dimension);
|
|
Crumb->SignMismatchSamples +=
|
|
(Density[Index] > 0.0f) != Crumb->bAir ? 1 : 0;
|
|
Crumb->Min.X = FMath::Min(Crumb->Min.X, X);
|
|
Crumb->Min.Y = FMath::Min(Crumb->Min.Y, Y);
|
|
Crumb->Min.Z = FMath::Min(Crumb->Min.Z, Z);
|
|
Crumb->Max.X = FMath::Max(Crumb->Max.X, X);
|
|
Crumb->Max.Y = FMath::Max(Crumb->Max.Y, Y);
|
|
Crumb->Max.Z = FMath::Max(Crumb->Max.Z, Z);
|
|
}
|
|
TArray<int32> Roots;
|
|
CrumbByRoot.GetKeys(Roots);
|
|
Roots.Sort();
|
|
for (const int32 Root : Roots)
|
|
{
|
|
FStep2fFragmentMetrics::FCrumbComponent& Crumb = CrumbByRoot[Root];
|
|
const int32 MinIndex = (Crumb.Min.Z * Dimension + Crumb.Min.Y)
|
|
* Dimension + Crumb.Min.X;
|
|
Crumb.DensityAtMin = Density[MinIndex];
|
|
Result.CrumbComponents.Add(Crumb);
|
|
}
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
struct FStep2fGridSignatureMetrics
|
|
{
|
|
double PitchCorrelationPeak = 0.0;
|
|
double NeighborCorrelationPeak = 0.0;
|
|
double PitchPeakExcess = 0.0;
|
|
};
|
|
|
|
FStep2fGridSignatureMetrics ComputeStep2fGridSignature(
|
|
const UVoxelGenerator& Generator, FIntVector RingOrigin, int32 RingDimension,
|
|
float PitchVoxels)
|
|
{
|
|
FStep2fGridSignatureMetrics Result;
|
|
FScopedVoxelStackRegistration StackRegistration;
|
|
constexpr int32 SampleStep = static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
constexpr int32 HalfWindow = static_cast<int32>(
|
|
3200.0f / VOXEL_SIZE + 0.5f);
|
|
constexpr int32 XYDimension = 2 * HalfWindow / SampleStep + 1;
|
|
constexpr int32 HeightLayers = 9;
|
|
const FIntVector RingCentre = RingOrigin + FIntVector(
|
|
(RingDimension - 1) / 2, (RingDimension - 1) / 2, (RingDimension - 1) / 2);
|
|
TArray<uint8> AirSamples;
|
|
AirSamples.SetNumUninitialized(XYDimension * XYDimension * HeightLayers);
|
|
int64 AirCount = 0;
|
|
int64 SampleCount = 0;
|
|
for (int32 ZIndex = 0; ZIndex < HeightLayers; ++ZIndex)
|
|
{
|
|
const int32 Z = RingOrigin.Z + FMath::RoundToInt(
|
|
static_cast<float>(RingDimension - 1) * ZIndex / (HeightLayers - 1));
|
|
for (int32 YIndex = 0; YIndex < XYDimension; ++YIndex)
|
|
for (int32 XIndex = 0; XIndex < XYDimension; ++XIndex)
|
|
{
|
|
const float WorldX = static_cast<float>(RingCentre.X - HalfWindow
|
|
+ XIndex * SampleStep);
|
|
const float WorldY = static_cast<float>(RingCentre.Y - HalfWindow
|
|
+ YIndex * SampleStep);
|
|
const uint8 bAir = VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
Generator, WorldX, WorldY, static_cast<float>(Z)) > 0.0f ? 1 : 0;
|
|
AirSamples[(ZIndex * XYDimension + YIndex) * XYDimension + XIndex] = bAir;
|
|
AirCount += bAir;
|
|
++SampleCount;
|
|
}
|
|
}
|
|
const double Mean = SampleCount > 0
|
|
? static_cast<double>(AirCount) / SampleCount : 0.0;
|
|
const double Variance = Mean * (1.0 - Mean);
|
|
if (Variance <= 1.0e-6)
|
|
{
|
|
return Result;
|
|
}
|
|
const auto CorrelationAtRadius = [&](float Radius)
|
|
{
|
|
double BestCorrelation = -1.0;
|
|
for (int32 Direction = 0; Direction < 8; ++Direction)
|
|
{
|
|
const float Angle = static_cast<float>(Direction) * 3.14159265358979323846f / 8.0f;
|
|
const int32 DX = FMath::RoundToInt(
|
|
Radius * FMath::Cos(Angle) / SampleStep);
|
|
const int32 DY = FMath::RoundToInt(
|
|
Radius * FMath::Sin(Angle) / SampleStep);
|
|
if (DX == 0 && DY == 0)
|
|
{
|
|
continue;
|
|
}
|
|
double Covariance = 0.0;
|
|
int64 Pairs = 0;
|
|
for (int32 Z = 0; Z < HeightLayers; ++Z)
|
|
for (int32 Y = 0; Y < XYDimension; ++Y)
|
|
{
|
|
const int32 OtherY = Y + DY;
|
|
if (OtherY < 0 || OtherY >= XYDimension)
|
|
{
|
|
continue;
|
|
}
|
|
for (int32 X = 0; X < XYDimension; ++X)
|
|
{
|
|
const int32 OtherX = X + DX;
|
|
if (OtherX < 0 || OtherX >= XYDimension)
|
|
{
|
|
continue;
|
|
}
|
|
const bool bAirA = AirSamples[
|
|
(Z * XYDimension + Y) * XYDimension + X] != 0;
|
|
const bool bAirB = AirSamples[
|
|
(Z * XYDimension + OtherY) * XYDimension + OtherX] != 0;
|
|
Covariance += (static_cast<double>(bAirA) - Mean)
|
|
* (static_cast<double>(bAirB) - Mean);
|
|
++Pairs;
|
|
}
|
|
}
|
|
if (Pairs > 0)
|
|
{
|
|
BestCorrelation = FMath::Max(
|
|
BestCorrelation, Covariance / (static_cast<double>(Pairs) * Variance));
|
|
}
|
|
}
|
|
return BestCorrelation;
|
|
};
|
|
Result.PitchCorrelationPeak = CorrelationAtRadius(PitchVoxels);
|
|
Result.NeighborCorrelationPeak = FMath::Max(
|
|
CorrelationAtRadius(PitchVoxels * 0.82f),
|
|
CorrelationAtRadius(PitchVoxels * 1.18f));
|
|
Result.PitchPeakExcess = Result.PitchCorrelationPeak - Result.NeighborCorrelationPeak;
|
|
return Result;
|
|
}
|
|
|
|
struct FStep2fDetailSpectrum
|
|
{
|
|
double Rms = 0.0;
|
|
double LatticeScaleEnergyRatio = 0.0;
|
|
};
|
|
|
|
FStep2fDetailSpectrum ComputeStep2fDetailSpectrum(const TArray<float>& Detail, int32 Dimension)
|
|
{
|
|
FStep2fDetailSpectrum Result;
|
|
if (Detail.Num() != Dimension * Dimension * Dimension || Detail.IsEmpty())
|
|
{
|
|
return Result;
|
|
}
|
|
double Mean = 0.0;
|
|
for (const float Value : Detail)
|
|
{
|
|
Mean += Value;
|
|
}
|
|
Mean /= Detail.Num();
|
|
double Variance = 0.0;
|
|
for (const float Value : Detail)
|
|
{
|
|
Variance += FMath::Square(static_cast<double>(Value) - Mean);
|
|
}
|
|
Variance /= Detail.Num();
|
|
double DifferenceEnergy = 0.0;
|
|
int64 DifferenceCount = 0;
|
|
for (int32 Z = 0; Z < Dimension; ++Z)
|
|
for (int32 Y = 0; Y < Dimension; ++Y)
|
|
for (int32 X = 0; X < Dimension; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
constexpr int32 PhysicalLagVoxels = static_cast<int32>(25.0f / VOXEL_SIZE + 0.5f);
|
|
if (X + PhysicalLagVoxels < Dimension)
|
|
{
|
|
DifferenceEnergy += FMath::Square(
|
|
static_cast<double>(Detail[Index + PhysicalLagVoxels] - Detail[Index]));
|
|
++DifferenceCount;
|
|
}
|
|
if (Y + PhysicalLagVoxels < Dimension)
|
|
{
|
|
DifferenceEnergy += FMath::Square(
|
|
static_cast<double>(Detail[Index + PhysicalLagVoxels * Dimension] - Detail[Index]));
|
|
++DifferenceCount;
|
|
}
|
|
if (Z + PhysicalLagVoxels < Dimension)
|
|
{
|
|
DifferenceEnergy += FMath::Square(
|
|
static_cast<double>(Detail[Index + PhysicalLagVoxels * Dimension * Dimension]
|
|
- Detail[Index]));
|
|
++DifferenceCount;
|
|
}
|
|
}
|
|
Result.Rms = FMath::Sqrt(Variance);
|
|
Result.LatticeScaleEnergyRatio = Variance > 1.0e-8 && DifferenceCount > 0
|
|
? DifferenceEnergy / DifferenceCount / (2.0 * Variance) : 0.0;
|
|
return Result;
|
|
}
|
|
|
|
struct FStep2fEdgeUse
|
|
{
|
|
FVector FirstNormal = FVector::ZeroVector;
|
|
FVector SecondNormal = FVector::ZeroVector;
|
|
int32 Count = 0;
|
|
};
|
|
|
|
struct FStep2fMeshMetrics
|
|
{
|
|
int32 TriangleCount = 0;
|
|
int64 ManifoldEdgeCount = 0;
|
|
double ShareDihedralsOver45Degrees = 0.0;
|
|
double P95DihedralDegrees = 0.0;
|
|
};
|
|
|
|
float Step2fPointTriangleDistanceSquared(
|
|
const FVector& Point, const FVector& A, const FVector& B, const FVector& C)
|
|
{
|
|
const FVector AB = B - A;
|
|
const FVector AC = C - A;
|
|
const FVector AP = Point - A;
|
|
const FVector Normal = FVector::CrossProduct(AB, AC);
|
|
const double NormalSquared = Normal.SizeSquared();
|
|
const double D00 = FVector::DotProduct(AB, AB);
|
|
const double D01 = FVector::DotProduct(AB, AC);
|
|
const double D11 = FVector::DotProduct(AC, AC);
|
|
const double D20 = FVector::DotProduct(AP, AB);
|
|
const double D21 = FVector::DotProduct(AP, AC);
|
|
const double Denominator = D00 * D11 - D01 * D01;
|
|
if (NormalSquared > 1.0e-8 && Denominator > 1.0e-8)
|
|
{
|
|
const double V = (D11 * D20 - D01 * D21) / Denominator;
|
|
const double W = (D00 * D21 - D01 * D20) / Denominator;
|
|
if (V >= 0.0 && W >= 0.0 && V + W <= 1.0)
|
|
{
|
|
const double PlaneDistance = FVector::DotProduct(AP, Normal)
|
|
/ FMath::Sqrt(NormalSquared);
|
|
return FMath::Square(PlaneDistance);
|
|
}
|
|
}
|
|
const auto SegmentDistanceSquared = [&Point](const FVector& Start, const FVector& End)
|
|
{
|
|
const FVector Segment = End - Start;
|
|
const double LengthSquared = Segment.SizeSquared();
|
|
const double T = LengthSquared > 1.0e-8
|
|
? FMath::Clamp(FVector::DotProduct(Point - Start, Segment) / LengthSquared,
|
|
0.0, 1.0)
|
|
: 0.0;
|
|
return FVector::DistSquared(Point, Start + Segment * T);
|
|
};
|
|
return FMath::Min3(
|
|
SegmentDistanceSquared(A, B),
|
|
SegmentDistanceSquared(B, C),
|
|
SegmentDistanceSquared(C, A));
|
|
}
|
|
|
|
FIntVector SelectStep2fMeshOrigin(
|
|
const TArray<float>& Density, FIntVector RingOrigin, int32 Dimension)
|
|
{
|
|
constexpr int32 VoxelsPerMeter = static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
constexpr int32 PhysicalTileVoxels = 16 * VoxelsPerMeter;
|
|
constexpr int32 ScoreStepVoxels = VoxelsPerMeter;
|
|
constexpr int32 CandidateStepVoxels = 8 * VoxelsPerMeter;
|
|
const int32 MaximumStart = Dimension - 1 - PhysicalTileVoxels;
|
|
if (Density.Num() != Dimension * Dimension * Dimension || MaximumStart < 0)
|
|
{
|
|
return RingOrigin;
|
|
}
|
|
|
|
TArray<int32, TInlineAllocator<16>> Starts;
|
|
for (int32 Start = 0; Start <= MaximumStart; Start += CandidateStepVoxels)
|
|
{
|
|
Starts.Add(Start);
|
|
}
|
|
if (Starts.IsEmpty() || Starts.Last() != MaximumStart)
|
|
{
|
|
Starts.Add(MaximumStart);
|
|
}
|
|
|
|
int64 BestSurfaceEdgeCount = -1;
|
|
FIntVector BestStart(MaximumStart / 2, MaximumStart / 2, MaximumStart / 2);
|
|
for (const int32 StartZ : Starts)
|
|
for (const int32 StartY : Starts)
|
|
for (const int32 StartX : Starts)
|
|
{
|
|
int64 SurfaceEdgeCount = 0;
|
|
for (int32 Z = 0; Z <= PhysicalTileVoxels; Z += ScoreStepVoxels)
|
|
for (int32 Y = 0; Y <= PhysicalTileVoxels; Y += ScoreStepVoxels)
|
|
for (int32 X = 0; X <= PhysicalTileVoxels; X += ScoreStepVoxels)
|
|
{
|
|
const int32 SampleX = StartX + X;
|
|
const int32 SampleY = StartY + Y;
|
|
const int32 SampleZ = StartZ + Z;
|
|
const int32 Index = (SampleZ * Dimension + SampleY) * Dimension + SampleX;
|
|
const bool bAir = Density[Index] > 0.0f;
|
|
if (X < PhysicalTileVoxels)
|
|
{
|
|
SurfaceEdgeCount += bAir != (Density[Index + ScoreStepVoxels] > 0.0f) ? 1 : 0;
|
|
}
|
|
if (Y < PhysicalTileVoxels)
|
|
{
|
|
SurfaceEdgeCount += bAir
|
|
!= (Density[Index + ScoreStepVoxels * Dimension] > 0.0f) ? 1 : 0;
|
|
}
|
|
if (Z < PhysicalTileVoxels)
|
|
{
|
|
SurfaceEdgeCount += bAir
|
|
!= (Density[Index + ScoreStepVoxels * Dimension * Dimension] > 0.0f)
|
|
? 1 : 0;
|
|
}
|
|
}
|
|
if (SurfaceEdgeCount > BestSurfaceEdgeCount)
|
|
{
|
|
BestSurfaceEdgeCount = SurfaceEdgeCount;
|
|
BestStart = FIntVector(StartX, StartY, StartZ);
|
|
}
|
|
}
|
|
return RingOrigin + BestStart;
|
|
}
|
|
|
|
FStep2fMeshMetrics ComputeStep2fMeshMetrics(const FVoxelMeshData& Mesh)
|
|
{
|
|
FStep2fMeshMetrics Result;
|
|
Result.TriangleCount = Mesh.Triangles.Num() / 3;
|
|
TMap<uint64, FStep2fEdgeUse> EdgeUses;
|
|
EdgeUses.Reserve(Mesh.Triangles.Num());
|
|
const auto AddEdge = [&EdgeUses](int32 A, int32 B, const FVector& Normal)
|
|
{
|
|
if (A > B)
|
|
{
|
|
Swap(A, B);
|
|
}
|
|
const uint64 Key = (static_cast<uint64>(static_cast<uint32>(A)) << 32)
|
|
| static_cast<uint32>(B);
|
|
FStep2fEdgeUse& Use = EdgeUses.FindOrAdd(Key);
|
|
if (Use.Count == 0)
|
|
{
|
|
Use.FirstNormal = Normal;
|
|
}
|
|
else if (Use.Count == 1)
|
|
{
|
|
Use.SecondNormal = Normal;
|
|
}
|
|
++Use.Count;
|
|
};
|
|
for (int32 Triangle = 0; Triangle + 2 < Mesh.Triangles.Num(); Triangle += 3)
|
|
{
|
|
const int32 IA = Mesh.Triangles[Triangle];
|
|
const int32 IB = Mesh.Triangles[Triangle + 1];
|
|
const int32 IC = Mesh.Triangles[Triangle + 2];
|
|
if (!Mesh.Vertices.IsValidIndex(IA) || !Mesh.Vertices.IsValidIndex(IB)
|
|
|| !Mesh.Vertices.IsValidIndex(IC))
|
|
{
|
|
continue;
|
|
}
|
|
FVector Normal = FVector::CrossProduct(
|
|
Mesh.Vertices[IB] - Mesh.Vertices[IA],
|
|
Mesh.Vertices[IC] - Mesh.Vertices[IA]);
|
|
if (!Normal.Normalize())
|
|
{
|
|
continue;
|
|
}
|
|
AddEdge(IA, IB, Normal);
|
|
AddEdge(IB, IC, Normal);
|
|
AddEdge(IC, IA, Normal);
|
|
}
|
|
TArray<float> Angles;
|
|
Angles.Reserve(EdgeUses.Num());
|
|
int64 Over45 = 0;
|
|
for (const TPair<uint64, FStep2fEdgeUse>& Pair : EdgeUses)
|
|
{
|
|
const FStep2fEdgeUse& Use = Pair.Value;
|
|
if (Use.Count != 2)
|
|
{
|
|
continue;
|
|
}
|
|
const double Dot = FMath::Clamp(
|
|
FMath::Abs(FVector::DotProduct(Use.FirstNormal, Use.SecondNormal)), 0.0, 1.0);
|
|
const float AngleDegrees = FMath::RadiansToDegrees(
|
|
FMath::Acos(static_cast<float>(Dot)));
|
|
Angles.Add(AngleDegrees);
|
|
Over45 += AngleDegrees > 45.0f ? 1 : 0;
|
|
}
|
|
Result.ManifoldEdgeCount = Angles.Num();
|
|
if (!Angles.IsEmpty())
|
|
{
|
|
Angles.Sort();
|
|
Result.ShareDihedralsOver45Degrees =
|
|
static_cast<double>(Over45) / Angles.Num();
|
|
const int32 P95Index = FMath::Clamp(
|
|
FMath::CeilToInt(0.95f * Angles.Num()) - 1, 0, Angles.Num() - 1);
|
|
Result.P95DihedralDegrees = Angles[P95Index];
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
struct FStep2fStructureMetrics
|
|
{
|
|
int32 StratesChecked = 0;
|
|
int64 RoofSamples = 0;
|
|
int64 RoofOpenings = 0;
|
|
int64 FloorSamples = 0;
|
|
int64 FloorOpenings = 0;
|
|
};
|
|
|
|
struct FStep2fShapeSnapshot
|
|
{
|
|
FStep2eVarietyMetrics Variety;
|
|
FStep2fFragmentMetrics Fragments;
|
|
FStep2fGridSignatureMetrics Grid;
|
|
FStep2fDetailSpectrum Detail;
|
|
FStep2fMeshMetrics Mesh;
|
|
int32 Lod1VertexCount = 0;
|
|
double Lod1P95SurfaceErrorVoxels = 0.0;
|
|
FStep2fStructureMetrics Structure;
|
|
};
|
|
|
|
TSharedRef<FJsonObject> Step2eParametersJson(
|
|
const VoxelForgeRedefinedDensityField::FGenerationParameters& P)
|
|
{
|
|
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
|
Json->SetNumberField(TEXT("room_pitch_voxels"), P.RoomPitchVoxels);
|
|
Json->SetNumberField(TEXT("room_radius_fraction_min"), P.MinimumRoomRadiusFraction);
|
|
Json->SetNumberField(TEXT("room_radius_fraction_max"), P.MaximumRoomRadiusFraction);
|
|
Json->SetNumberField(TEXT("room_height_voxels"), P.RoomHeightVoxels);
|
|
Json->SetNumberField(TEXT("floor_relief_voxels"), P.FloorReliefVoxels);
|
|
Json->SetNumberField(TEXT("tunnel_radius_voxels"), P.TunnelRadiusVoxels);
|
|
Json->SetNumberField(TEXT("tunnel_half_height_voxels"), P.TunnelHalfHeightVoxels);
|
|
Json->SetNumberField(TEXT("loop_probability"), P.LoopProbability);
|
|
Json->SetNumberField(TEXT("vertical_shaft_probability"), P.VerticalShaftProbability);
|
|
Json->SetNumberField(TEXT("shaft_radius_voxels"), P.ShaftRadiusVoxels);
|
|
Json->SetNumberField(TEXT("pillar_probability"), P.PillarProbability);
|
|
Json->SetNumberField(TEXT("ledge_probability"), P.LedgeProbability);
|
|
Json->SetNumberField(TEXT("floating_mass_probability"), P.FloatingMassProbability);
|
|
Json->SetNumberField(TEXT("stalactite_stalagmite_probability"), P.SpireProbability);
|
|
Json->SetNumberField(TEXT("basin_probability"), P.BasinProbability);
|
|
Json->SetNumberField(TEXT("basin_depth_voxels"), P.BasinDepthVoxels);
|
|
Json->SetNumberField(TEXT("water_level_fraction"), P.WaterLevelFraction);
|
|
return Json;
|
|
}
|
|
|
|
TSharedRef<FJsonObject> Step2eChamberVolumeQuantilesJson(
|
|
const VoxelForgeRedefinedDensityField::FGenerationParameters& P)
|
|
{
|
|
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
|
constexpr double Pi = 3.14159265358979323846;
|
|
constexpr double VoxelMeters = static_cast<double>(VOXEL_SIZE) / 100.0;
|
|
constexpr double CubicMetersPerVoxel = VoxelMeters * VoxelMeters * VoxelMeters;
|
|
const double Quantiles[] = {0.10, 0.50, 0.90};
|
|
const TCHAR* Names[] = {TEXT("p10_m3"), TEXT("p50_m3"), TEXT("p90_m3")};
|
|
for (int32 Index = 0; Index < UE_ARRAY_COUNT(Quantiles); ++Index)
|
|
{
|
|
const double RadiusFraction = FMath::Lerp(
|
|
static_cast<double>(P.MinimumRoomRadiusFraction),
|
|
static_cast<double>(P.MaximumRoomRadiusFraction), Quantiles[Index]);
|
|
const double RadiusVoxels = RadiusFraction * P.RoomPitchVoxels;
|
|
const double VolumeCubicVoxels = Pi * RadiusVoxels * RadiusVoxels
|
|
* P.RoomHeightVoxels;
|
|
Json->SetNumberField(Names[Index], VolumeCubicVoxels * CubicMetersPerVoxel);
|
|
}
|
|
return Json;
|
|
}
|
|
|
|
TSharedRef<FJsonObject> Step2eParameterRangesJson()
|
|
{
|
|
using FParameters = VoxelForgeRedefinedDensityField::FGenerationParameters;
|
|
struct FParameterRange
|
|
{
|
|
const TCHAR* Name;
|
|
float FParameters::*Value;
|
|
};
|
|
static const FParameterRange Fields[] = {
|
|
{TEXT("room_pitch_voxels"), &FParameters::RoomPitchVoxels},
|
|
{TEXT("room_radius_fraction_min"), &FParameters::MinimumRoomRadiusFraction},
|
|
{TEXT("room_radius_fraction_max"), &FParameters::MaximumRoomRadiusFraction},
|
|
{TEXT("room_height_voxels"), &FParameters::RoomHeightVoxels},
|
|
{TEXT("floor_relief_voxels"), &FParameters::FloorReliefVoxels},
|
|
{TEXT("tunnel_radius_voxels"), &FParameters::TunnelRadiusVoxels},
|
|
{TEXT("tunnel_half_height_voxels"), &FParameters::TunnelHalfHeightVoxels},
|
|
{TEXT("loop_probability"), &FParameters::LoopProbability},
|
|
{TEXT("vertical_shaft_probability"), &FParameters::VerticalShaftProbability},
|
|
{TEXT("shaft_radius_voxels"), &FParameters::ShaftRadiusVoxels},
|
|
{TEXT("pillar_probability"), &FParameters::PillarProbability},
|
|
{TEXT("ledge_probability"), &FParameters::LedgeProbability},
|
|
{TEXT("floating_mass_probability"), &FParameters::FloatingMassProbability},
|
|
{TEXT("stalactite_stalagmite_probability"), &FParameters::SpireProbability},
|
|
{TEXT("basin_probability"), &FParameters::BasinProbability},
|
|
{TEXT("basin_depth_voxels"), &FParameters::BasinDepthVoxels},
|
|
{TEXT("water_level_fraction"), &FParameters::WaterLevelFraction},
|
|
};
|
|
|
|
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
|
for (const FParameterRange& Field : Fields)
|
|
{
|
|
float Minimum = FLT_MAX;
|
|
float Maximum = -FLT_MAX;
|
|
for (int32 ProfileIndex = 0;
|
|
ProfileIndex < VoxelForgeRedefinedDensityField::GetParameterSetCount();
|
|
++ProfileIndex)
|
|
{
|
|
const TCHAR* Name = nullptr;
|
|
FParameters Parameters;
|
|
if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo(
|
|
ProfileIndex, Name, Parameters))
|
|
{
|
|
continue;
|
|
}
|
|
const float Value = Parameters.*(Field.Value);
|
|
Minimum = FMath::Min(Minimum, Value);
|
|
Maximum = FMath::Max(Maximum, Value);
|
|
}
|
|
TSharedRef<FJsonObject> Bounds = MakeShared<FJsonObject>();
|
|
Bounds->SetNumberField(TEXT("minimum_across_profiles"), Minimum);
|
|
Bounds->SetNumberField(TEXT("maximum_across_profiles"), Maximum);
|
|
Result->SetObjectField(Field.Name, Bounds);
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
bool Step2eSameWorkerRun(
|
|
const VoxelForgeRegionEval::FTiming& A,
|
|
const VoxelForgeRegionEval::FTiming& B)
|
|
{
|
|
return A.QuantizedHash == B.QuantizedHash && A.RawFloatHash == B.RawFloatHash;
|
|
}
|
|
|
|
TSharedRef<FJsonObject> Step2dTimingJson(const VoxelForgeRegionEval::FTiming& Timing)
|
|
{
|
|
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
|
Json->SetNumberField(TEXT("workers_requested"), Timing.Workers);
|
|
Json->SetNumberField(TEXT("workers_observed"), Timing.ObservedWorkerThreads);
|
|
Json->SetNumberField(TEXT("lattice_samples"), static_cast<double>(Timing.SampleCount));
|
|
Json->SetNumberField(TEXT("density_samples_evaluated"), static_cast<double>(Timing.EvaluatedSamples));
|
|
Json->SetNumberField(TEXT("samples_skipped_by_brick_proof"), static_cast<double>(Timing.SkippedSamples));
|
|
Json->SetNumberField(TEXT("bricks_tested"), static_cast<double>(Timing.BrickCount));
|
|
Json->SetNumberField(TEXT("bricks_skipped"), static_cast<double>(Timing.SkippedBricks));
|
|
Json->SetNumberField(TEXT("share_of_bricks_skipped"), Timing.BrickCount > 0
|
|
? static_cast<double>(Timing.SkippedBricks) / static_cast<double>(Timing.BrickCount) : 0.0);
|
|
Json->SetNumberField(TEXT("share_of_lattice_samples_skipped"), Timing.SampleCount > 0
|
|
? static_cast<double>(Timing.SkippedSamples) / static_cast<double>(Timing.SampleCount) : 0.0);
|
|
Json->SetNumberField(TEXT("wall_ms"), Timing.WallSeconds * 1000.0);
|
|
Json->SetNumberField(TEXT("worker_cpu_seconds"), Timing.WorkerCpuSeconds);
|
|
Json->SetNumberField(TEXT("worker_cpu_ns_per_lattice_sample"), Timing.NanosecondsPerSample);
|
|
Json->SetNumberField(TEXT("brick_classification_worker_seconds"), Timing.BrickClassificationSeconds);
|
|
Json->SetNumberField(TEXT("density_evaluation_worker_seconds"), Timing.DensityEvaluationSeconds);
|
|
Json->SetNumberField(TEXT("uniform_fill_worker_seconds"), Timing.UniformFillSeconds);
|
|
Json->SetNumberField(TEXT("longest_worker_wall_ms"), Timing.LongestWorkerWallSeconds * 1000.0);
|
|
Json->SetNumberField(TEXT("parallel_overhead_wall_ms"), FMath::Max(
|
|
0.0, Timing.WallSeconds - Timing.LongestWorkerWallSeconds) * 1000.0);
|
|
Json->SetNumberField(TEXT("brick_classification_calls"), static_cast<double>(Timing.MacroBrickChecks));
|
|
Json->SetNumberField(TEXT("macro_center_evaluations"), static_cast<double>(Timing.MacroCentreEvaluations));
|
|
Json->SetNumberField(TEXT("unsupported_brick_proofs"), static_cast<double>(Timing.UnsupportedBrickProofs));
|
|
Json->SetNumberField(TEXT("chunk_or_strate_boundary_proofs"),
|
|
static_cast<double>(Timing.ChunkBoundaryBrickProofs));
|
|
Json->SetNumberField(TEXT("passage_bound_proofs"), static_cast<double>(Timing.PassageBoundBrickProofs));
|
|
Json->SetNumberField(TEXT("mixed_macro_interval_proofs"), static_cast<double>(Timing.MixedMacroBrickProofs));
|
|
Json->SetNumberField(TEXT("room_sdf_evaluations"), static_cast<double>(Timing.RoomEvaluations));
|
|
Json->SetNumberField(TEXT("tunnel_sdf_evaluations"), static_cast<double>(Timing.TunnelEvaluations));
|
|
Json->SetNumberField(TEXT("tunnel_core_evaluations"), static_cast<double>(Timing.TunnelCoreEvaluations));
|
|
Json->SetNumberField(TEXT("passage_sdf_evaluations"), static_cast<double>(Timing.PassageEvaluations));
|
|
Json->SetNumberField(TEXT("geometry_evaluations_per_lattice_sample"), Timing.CandidateEvaluationsPerSample);
|
|
Json->SetNumberField(TEXT("quantized_hash"), static_cast<double>(Timing.QuantizedHash));
|
|
Json->SetNumberField(TEXT("raw_float_hash"), static_cast<double>(Timing.RawFloatHash));
|
|
return Json;
|
|
}
|
|
|
|
int32 RunStep2d(const FString& Params)
|
|
{
|
|
FExploreArguments Arguments;
|
|
FString Error;
|
|
if (!ParseArguments(Params, Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] argument error: %s"), *Error);
|
|
return 2;
|
|
}
|
|
int32 Seed = 1337;
|
|
FParse::Value(*Params, TEXT("seed="), Seed);
|
|
int32 Workers = VoxelForgeRegionEval::WorkerCount;
|
|
FParse::Value(*Params, TEXT("regionworkers="), Workers);
|
|
Workers = FMath::Clamp(Workers, 1, 32);
|
|
FString ResearchOut;
|
|
FParse::Value(*Params, TEXT("researchout="), ResearchOut);
|
|
if (ResearchOut.IsEmpty())
|
|
{
|
|
ResearchOut = FPaths::Combine(Arguments.OutDirectory, TEXT("Research/step2d"));
|
|
}
|
|
ResearchOut = FPaths::ConvertRelativePathToFull(ResearchOut);
|
|
IFileManager::Get().MakeDirectory(*ResearchOut, true);
|
|
IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true);
|
|
|
|
FExploreWorld World;
|
|
int32 RecipeIndex = INDEX_NONE;
|
|
uint32 RecipeHash = 0;
|
|
int32 EditChunkCount = 0;
|
|
const double PrepareStartSeconds = FPlatformTime::Seconds();
|
|
if (!PrepareRegionEvalWorld(World, Arguments, Seed,
|
|
RecipeIndex, RecipeHash, EditChunkCount, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] world preparation failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
const double WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStartSeconds;
|
|
if (World.DiffLayer != nullptr)
|
|
{
|
|
World.DiffLayer->Clear();
|
|
}
|
|
|
|
const int32 Dimension = VoxelForgeRegionEval::RingDimension;
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
const FIntVector Origin(-129, -129, CenterZ - 129);
|
|
TArray<VoxelForgeRegionEval::FTiming> OldTrials;
|
|
TArray<VoxelForgeRegionEval::FTiming> NewTrials;
|
|
TArray<float> OldDensities;
|
|
TArray<float> NewDensities;
|
|
TArray<float> NewRepeatDensities;
|
|
TArray<float> NewSkippedDensities;
|
|
TArray<float> SmoothDensities;
|
|
TArray<float> SingleWorkerDensities;
|
|
VoxelForgeRedefinedDensityField::SetEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
|
|
for (int32 Trial = 0; Trial < 3; ++Trial)
|
|
{
|
|
VoxelForgeRegionEval::FTiming Timing;
|
|
if (!VoxelForgeRegionEval::Evaluate(
|
|
*World.Generator, Origin, Dimension, nullptr, true, false, 1.0f,
|
|
&OldDensities, Timing, false, Workers, false))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] old field timing pass %d failed."), Trial + 1);
|
|
return 2;
|
|
}
|
|
OldTrials.Add(Timing);
|
|
}
|
|
|
|
// This fourth old-field pass carries counters; its wall time is deliberately excluded from
|
|
// the performance row so instrumentation does not contaminate the measured baseline.
|
|
VoxelForgeRegionEval::FTiming OldCounters;
|
|
if (!VoxelForgeRegionEval::Evaluate(
|
|
*World.Generator, Origin, Dimension, nullptr, true, false, 1.0f,
|
|
nullptr, OldCounters, false, Workers, true))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] old field counter pass failed."));
|
|
return 2;
|
|
}
|
|
OldCounters.WallSeconds = OldTrials.Last().WallSeconds;
|
|
OldCounters.WorkerCpuSeconds = OldTrials.Last().WorkerCpuSeconds;
|
|
OldCounters.NanosecondsPerSample = OldTrials.Last().NanosecondsPerSample;
|
|
|
|
for (int32 Trial = 0; Trial < 3; ++Trial)
|
|
{
|
|
VoxelForgeRegionEval::FTiming Timing;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
NewSkippedDensities, Timing, true))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] redefined 6-worker pass %d failed."), Trial + 1);
|
|
return 2;
|
|
}
|
|
NewTrials.Add(Timing);
|
|
}
|
|
VoxelForgeRegionEval::FTiming OneWorkerTiming;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, 1, 1.0f,
|
|
SingleWorkerDensities, OneWorkerTiming, false))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] 1-worker determinism pass failed."));
|
|
return 2;
|
|
}
|
|
VoxelForgeRegionEval::FTiming NewFullSixWorkerTiming;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
NewDensities, NewFullSixWorkerTiming, false))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] full-detail six-worker evidence pass failed."));
|
|
return 2;
|
|
}
|
|
VoxelForgeRegionEval::FTiming NewRepeatTiming;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
NewRepeatDensities, NewRepeatTiming, false))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] repeated full-detail hash pass failed."));
|
|
return 2;
|
|
}
|
|
VoxelForgeRegionEval::FTiming SmoothTiming;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 0.0f,
|
|
SmoothDensities, SmoothTiming, false))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] deliberate smoothing control failed."));
|
|
return 2;
|
|
}
|
|
|
|
int64 DetailBatchParitySamples = 0;
|
|
double DetailBatchMaxAbsoluteError = 0.0;
|
|
const int64 TotalLatticeSamples = static_cast<int64>(Dimension) * Dimension * Dimension;
|
|
for (int64 Index = 0; Index < TotalLatticeSamples; Index += 2047)
|
|
{
|
|
const int32 X = static_cast<int32>(Index % Dimension);
|
|
const int32 Y = static_cast<int32>((Index / Dimension) % Dimension);
|
|
const int32 Z = static_cast<int32>(Index / (static_cast<int64>(Dimension) * Dimension));
|
|
const float RawDetailField = VoxelForgeRedefinedDensityField::Evaluate(
|
|
*World.Generator, static_cast<float>(Origin.X + X),
|
|
static_cast<float>(Origin.Y + Y), static_cast<float>(Origin.Z + Z), nullptr, 1.0f);
|
|
DetailBatchMaxAbsoluteError = FMath::Max(
|
|
DetailBatchMaxAbsoluteError,
|
|
static_cast<double>(FMath::Abs(RawDetailField - NewDensities[static_cast<int32>(Index)])));
|
|
++DetailBatchParitySamples;
|
|
}
|
|
const bool bDetailBatchMatchesRaw = DetailBatchMaxAbsoluteError <= 1.0e-5;
|
|
|
|
const FStep2dSurfaceSpectrum OldSpectrum = ComputeStep2dSpectrum(OldDensities, Dimension);
|
|
const FStep2dSurfaceSpectrum NewSpectrum = ComputeStep2dSpectrum(NewDensities, Dimension);
|
|
const FStep2dSurfaceSpectrum SmoothSpectrum = ComputeStep2dSpectrum(SmoothDensities, Dimension);
|
|
const FStep2dBrickProofAudit BrickProofAudit = AuditStep2dBrickProofs(
|
|
*World.Generator, Origin, Dimension);
|
|
const int32 FeatureCellSpacing = World.Definitions.IsValidIndex(Arguments.Slot)
|
|
&& World.Definitions[Arguments.Slot].IsValid()
|
|
&& World.Definitions[Arguments.Slot]->GeneratorType == ECaveGeneratorType::Maze
|
|
? 96 : 128;
|
|
const FStep2dBoundaryStats BoundaryStats = ComputeStep2dBoundaryStats(
|
|
NewDensities, Dimension, Origin, FeatureCellSpacing);
|
|
|
|
const bool bHashesAgree = OldDensities.Num() == Dimension * Dimension * Dimension
|
|
&& VoxelForgeRegionEval::HashQuantized(NewDensities)
|
|
== VoxelForgeRegionEval::HashQuantized(SingleWorkerDensities)
|
|
&& VoxelForgeRegionEval::HashQuantized(NewDensities)
|
|
== VoxelForgeRegionEval::HashQuantized(NewRepeatDensities)
|
|
&& NewFullSixWorkerTiming.RawFloatHash == OneWorkerTiming.RawFloatHash
|
|
&& NewFullSixWorkerTiming.RawFloatHash == NewRepeatTiming.RawFloatHash;
|
|
|
|
VoxelForgeRedefinedDensityField::SetEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
bool bMeshContainsBothIsoBands = false;
|
|
const FIntVector MeshOrigin = SelectStep2dMeshOrigin(
|
|
OldDensities, NewDensities, Origin, Dimension, bMeshContainsBothIsoBands);
|
|
const FVoxelMeshData OldMesh = World.Mesher->GenerateMesh(MeshOrigin, 1, 64);
|
|
const int32 OldTriangleCount = FMath::Max(0, OldMesh.Triangles.Num() / 3 - OldMesh.NumSkirtTriangles);
|
|
VoxelForgeRedefinedDensityField::SetEnabled(true);
|
|
const FVoxelMeshData NewMesh = World.Mesher->GenerateMesh(MeshOrigin, 1, 64);
|
|
const int32 NewTriangleCount = FMath::Max(0, NewMesh.Triangles.Num() / 3 - NewMesh.NumSkirtTriangles);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(0.0f);
|
|
const FVoxelMeshData SmoothMesh = World.Mesher->GenerateMesh(MeshOrigin, 1, 64);
|
|
const int32 SmoothTriangleCount = FMath::Max(0, SmoothMesh.Triangles.Num() / 3 - SmoothMesh.NumSkirtTriangles);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
VoxelForgeRedefinedDensityField::SetEnabled(false);
|
|
|
|
const FString ImageNames[] = {
|
|
TEXT("slice_xy_center.png"), TEXT("slice_xz_y0.png"),
|
|
TEXT("slice_yz_x0.png"), TEXT("slice_xz_passage.png")};
|
|
FString SliceError;
|
|
SaveStep2dSideBySideSlice(
|
|
FPaths::Combine(ResearchOut, ImageNames[0]), OldDensities, NewDensities,
|
|
Dimension, TEXT('h'), Dimension / 2, SliceError);
|
|
SaveStep2dSideBySideSlice(
|
|
FPaths::Combine(ResearchOut, ImageNames[1]), OldDensities, NewDensities,
|
|
Dimension, TEXT('x'), Dimension / 2, SliceError);
|
|
SaveStep2dSideBySideSlice(
|
|
FPaths::Combine(ResearchOut, ImageNames[2]), OldDensities, NewDensities,
|
|
Dimension, TEXT('y'), Dimension / 2, SliceError);
|
|
int32 PassageIndex = Dimension / 2;
|
|
if (World.Manager != nullptr && !World.Manager->GetPassages().IsEmpty())
|
|
{
|
|
const FVoxelPassage& Passage = World.Manager->GetPassages()[0];
|
|
PassageIndex = FMath::Clamp(FMath::RoundToInt(Passage.LowerLanding.StandingPoint.Y)
|
|
- Origin.Y, 0, Dimension - 1);
|
|
}
|
|
SaveStep2dSideBySideSlice(
|
|
FPaths::Combine(ResearchOut, ImageNames[3]), OldDensities, NewDensities,
|
|
Dimension, TEXT('x'), PassageIndex, SliceError);
|
|
if (!SliceError.IsEmpty())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] slice image write failed: %s"), *SliceError);
|
|
return 2;
|
|
}
|
|
|
|
TArray<double> OldWallTrials;
|
|
TArray<double> NewWallTrials;
|
|
for (const auto& Timing : OldTrials) OldWallTrials.Add(Timing.WallSeconds);
|
|
for (const auto& Timing : NewTrials) NewWallTrials.Add(Timing.WallSeconds);
|
|
OldWallTrials.Sort();
|
|
NewWallTrials.Sort();
|
|
const double OldMedianWall = OldWallTrials[OldWallTrials.Num() / 2];
|
|
const double NewMedianWall = NewWallTrials[NewWallTrials.Num() / 2];
|
|
const double OldGeometryEvaluations = static_cast<double>(OldCounters.RoomEvaluations
|
|
+ OldCounters.TunnelEvaluations + OldCounters.TunnelCoreEvaluations
|
|
+ OldCounters.PassageEvaluations);
|
|
const double OldGeometryPerSample = OldCounters.SampleCount > 0
|
|
? OldGeometryEvaluations / static_cast<double>(OldCounters.SampleCount) : 0.0;
|
|
const double NewMacroPerSample = NewTrials.Last().SampleCount > 0
|
|
? static_cast<double>(NewTrials.Last().MacroCentreEvaluations
|
|
+ NewTrials.Last().EvaluatedSamples)
|
|
/ static_cast<double>(NewTrials.Last().SampleCount) : 0.0;
|
|
const bool bDetailAtLeastOld = NewSpectrum.RMSLag1 >= OldSpectrum.RMSLag1
|
|
&& NewSpectrum.RMSLag2 >= OldSpectrum.RMSLag2;
|
|
const bool bSmoothControlDetected = SmoothSpectrum.RMSLag1 < NewSpectrum.RMSLag1 * 0.80
|
|
|| SmoothSpectrum.RMSLag2 < NewSpectrum.RMSLag2 * 0.80;
|
|
const bool bBoundaryStatsSound = BoundaryStats.BrickBoundarySamples > 0
|
|
&& BoundaryStats.InteriorSamples > 0
|
|
&& BoundaryStats.ChunkBoundarySamples > 0
|
|
&& BoundaryStats.FeatureCellBoundarySamples > 0
|
|
&& BoundaryStats.BrickJumpRms <= BoundaryStats.InteriorJumpRms * 2.0 + 0.25
|
|
&& BoundaryStats.ChunkBoundaryJumpRms <= BoundaryStats.InteriorJumpRms * 2.0 + 0.25
|
|
&& BoundaryStats.FeatureCellJumpRms
|
|
<= BoundaryStats.FeatureCellInteriorJumpRms * 2.0 + 0.25;
|
|
const bool bCorrectProofsSound = BrickProofAudit.CorrectlySkippedBricksAudited > 0
|
|
&& BrickProofAudit.CorrectProofSamplesChecked > 0
|
|
&& BrickProofAudit.CorrectProofViolations == 0;
|
|
const bool bUndersizedBoundCaught = BrickProofAudit.DeliberateBoundDisagreements > 0;
|
|
const bool bSmoothWouldFallBelowOldSpectrum = SmoothSpectrum.RMSLag1 < OldSpectrum.RMSLag1
|
|
|| SmoothSpectrum.RMSLag2 < OldSpectrum.RMSLag2;
|
|
const bool bSpeedPass = NewMedianWall <= 0.600;
|
|
const bool bPassed = bSpeedPass && bHashesAgree && bDetailAtLeastOld
|
|
&& bSmoothControlDetected
|
|
&& bBoundaryStatsSound && bCorrectProofsSound
|
|
&& bUndersizedBoundCaught
|
|
&& bDetailBatchMatchesRaw
|
|
&& NewTrials.Last().NonFiniteSamples == 0;
|
|
|
|
TSharedRef<FJsonObject> Report = MakeShared<FJsonObject>();
|
|
Report->SetStringField(TEXT("status"), bPassed ? TEXT("pass") : TEXT("fail"));
|
|
Report->SetBoolField(TEXT("pass"), bPassed);
|
|
Report->SetBoolField(TEXT("speed_pass_600ms"), bSpeedPass);
|
|
Report->SetNumberField(TEXT("seed"), Seed);
|
|
Report->SetNumberField(TEXT("ring_dimension"), Dimension);
|
|
Report->SetNumberField(TEXT("ring_samples"), static_cast<double>(Dimension) * Dimension * Dimension);
|
|
Report->SetNumberField(TEXT("workers_requested"), Workers);
|
|
Report->SetNumberField(TEXT("world_prepare_ms_excluded"), WorldPrepareSeconds * 1000.0);
|
|
Report->SetNumberField(TEXT("world_prepare_recipe_index"), RecipeIndex);
|
|
Report->SetNumberField(TEXT("world_prepare_recipe_hash"), RecipeHash);
|
|
Report->SetNumberField(TEXT("cleared_player_edit_chunks"), EditChunkCount);
|
|
TSharedRef<FJsonObject> Window = MakeShared<FJsonObject>();
|
|
Window->SetNumberField(TEXT("origin_x_voxels"), Origin.X);
|
|
Window->SetNumberField(TEXT("origin_y_voxels"), Origin.Y);
|
|
Window->SetNumberField(TEXT("origin_z_voxels"), Origin.Z);
|
|
Window->SetNumberField(TEXT("samples_per_axis"), Dimension);
|
|
Window->SetNumberField(TEXT("sample_stride_voxels"), 1);
|
|
Window->SetNumberField(TEXT("sample_spacing_cm"), VOXEL_SIZE);
|
|
Report->SetObjectField(TEXT("full_resolution_window"), Window);
|
|
Report->SetStringField(TEXT("field_definition"),
|
|
TEXT("A seed-offset periodic cross-corridor signed field per strate, joined with rounded "
|
|
"landing chambers and existing authored passage carving. Four continuous hash-value "
|
|
"noise bands (8, 4, 2, and 1 lattice point) with total bounded amplitude 5.25 are "
|
|
"evaluated at every full-resolution sample that can affect the zero set."));
|
|
Report->SetStringField(TEXT("resolution_contract"),
|
|
TEXT("The lattice remains one sample per 12.5 cm. No coarse density result is interpolated "
|
|
"onto it: every potentially surface-affecting point evaluates all four detail bands. "
|
|
"Brick skips require a final-field sign proof including the full noise supremum, a "
|
|
"floating-point safety margin, and the one-sample marching-cubes halo."));
|
|
Report->SetStringField(TEXT("finer_lattice_scaling"),
|
|
TEXT("At 12.5 cm, scale macro lengths and the broad/medium noise cell sizes in lattice "
|
|
"units by 2 to preserve their world-space shapes; keep or add a 1-2 sample fine octave "
|
|
"to retain and extend detail. With fixed 8^3 sample bricks, samples grow 8x but "
|
|
"surface-intersecting bricks grow approximately with area (about 4x); the rest are "
|
|
"sign-proven and require only density writes. Full-resolution detail remains per "
|
|
"potentially surface-affecting point."));
|
|
Report->SetNumberField(TEXT("old_macro_geometry_evaluations_per_point"), OldGeometryPerSample);
|
|
Report->SetNumberField(TEXT("new_macro_evaluations_per_point_including_brick_proofs"), NewMacroPerSample);
|
|
Report->SetNumberField(TEXT("new_noise_value_octaves_per_evaluated_point"), 4.0);
|
|
Report->SetNumberField(TEXT("new_noise_octave_evaluations_per_full_ring_point"),
|
|
static_cast<double>(NewTrials.Last().EvaluatedSamples) * 4.0
|
|
/ static_cast<double>(NewTrials.Last().SampleCount));
|
|
Report->SetNumberField(TEXT("old_reported_noise_share_percent_from_step2c"), 2.6);
|
|
Report->SetNumberField(TEXT("old_six_worker_median_ms"), OldMedianWall * 1000.0);
|
|
Report->SetNumberField(TEXT("new_six_worker_median_ms"), NewMedianWall * 1000.0);
|
|
const auto WorkerRemainderSeconds = [](const VoxelForgeRegionEval::FTiming& Timing)
|
|
{
|
|
return FMath::Max(0.0, Timing.WorkerCpuSeconds
|
|
- Timing.BrickClassificationSeconds - Timing.DensityEvaluationSeconds
|
|
- Timing.UniformFillSeconds);
|
|
};
|
|
Report->SetNumberField(TEXT("new_other_worker_cpu_seconds"),
|
|
WorkerRemainderSeconds(NewTrials.Last()));
|
|
Report->SetNumberField(TEXT("new_other_worker_cpu_ns_per_point"),
|
|
WorkerRemainderSeconds(NewTrials.Last()) * 1.0e9
|
|
/ static_cast<double>(NewTrials.Last().SampleCount));
|
|
Report->SetNumberField(TEXT("new_speedup_vs_old"), NewMedianWall > 0.0
|
|
? OldMedianWall / NewMedianWall : 0.0);
|
|
TArray<TSharedPtr<FJsonValue>> OldRows;
|
|
TArray<TSharedPtr<FJsonValue>> NewRows;
|
|
for (const auto& Timing : OldTrials)
|
|
{
|
|
OldRows.Add(MakeShared<FJsonValueObject>(Step2dTimingJson(Timing)));
|
|
}
|
|
for (const auto& Timing : NewTrials)
|
|
{
|
|
NewRows.Add(MakeShared<FJsonValueObject>(Step2dTimingJson(Timing)));
|
|
}
|
|
Report->SetArrayField(TEXT("old_field_trials"), OldRows);
|
|
Report->SetArrayField(TEXT("new_field_trials"), NewRows);
|
|
Report->SetObjectField(TEXT("old_full_ring_counters"), Step2dTimingJson(OldCounters));
|
|
Report->SetObjectField(TEXT("new_one_worker_determinism_pass"), Step2dTimingJson(OneWorkerTiming));
|
|
Report->SetObjectField(TEXT("new_full_detail_six_worker_evidence_pass"),
|
|
Step2dTimingJson(NewFullSixWorkerTiming));
|
|
Report->SetObjectField(TEXT("new_repeated_full_detail_six_worker_hash_pass"),
|
|
Step2dTimingJson(NewRepeatTiming));
|
|
Report->SetObjectField(TEXT("smooth_control_full_detail_pass"), Step2dTimingJson(SmoothTiming));
|
|
|
|
TSharedRef<FJsonObject> Hashes = MakeShared<FJsonObject>();
|
|
Hashes->SetStringField(TEXT("six_worker_run_1_quantized"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(NewFullSixWorkerTiming.QuantizedHash)));
|
|
Hashes->SetStringField(TEXT("six_worker_run_2_quantized"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(NewRepeatTiming.QuantizedHash)));
|
|
Hashes->SetStringField(TEXT("one_worker_quantized"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(OneWorkerTiming.QuantizedHash)));
|
|
Hashes->SetStringField(TEXT("six_worker_run_1_raw_float"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(NewFullSixWorkerTiming.RawFloatHash)));
|
|
Hashes->SetStringField(TEXT("six_worker_run_2_raw_float"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(NewRepeatTiming.RawFloatHash)));
|
|
Hashes->SetStringField(TEXT("one_worker_raw_float"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(OneWorkerTiming.RawFloatHash)));
|
|
Hashes->SetBoolField(TEXT("same_across_two_runs_and_1_vs_6_workers"), bHashesAgree);
|
|
Report->SetObjectField(TEXT("determinism"), Hashes);
|
|
|
|
TSharedRef<FJsonObject> Detail = MakeShared<FJsonObject>();
|
|
Detail->SetNumberField(TEXT("old_surface_band_samples"), static_cast<double>(OldSpectrum.SurfaceBandSamples));
|
|
Detail->SetNumberField(TEXT("new_surface_band_samples"), static_cast<double>(NewSpectrum.SurfaceBandSamples));
|
|
Detail->SetNumberField(TEXT("old_curvature_rms_lag1"), OldSpectrum.RMSLag1);
|
|
Detail->SetNumberField(TEXT("new_curvature_rms_lag1"), NewSpectrum.RMSLag1);
|
|
Detail->SetNumberField(TEXT("smoothed_control_curvature_rms_lag1"), SmoothSpectrum.RMSLag1);
|
|
Detail->SetNumberField(TEXT("old_curvature_rms_lag2"), OldSpectrum.RMSLag2);
|
|
Detail->SetNumberField(TEXT("new_curvature_rms_lag2"), NewSpectrum.RMSLag2);
|
|
Detail->SetNumberField(TEXT("smoothed_control_curvature_rms_lag2"), SmoothSpectrum.RMSLag2);
|
|
Detail->SetNumberField(TEXT("new_curvature_rms_lag4"), NewSpectrum.RMSLag4);
|
|
Detail->SetNumberField(TEXT("smoothed_control_curvature_rms_lag4"), SmoothSpectrum.RMSLag4);
|
|
Detail->SetNumberField(TEXT("old_surface_triangles_64cubed"), OldTriangleCount);
|
|
Detail->SetNumberField(TEXT("new_surface_triangles_64cubed"), NewTriangleCount);
|
|
Detail->SetNumberField(TEXT("smoothed_control_surface_triangles_64cubed"), SmoothTriangleCount);
|
|
Detail->SetNumberField(TEXT("mesh_region_origin_x_voxels"), MeshOrigin.X);
|
|
Detail->SetNumberField(TEXT("mesh_region_origin_y_voxels"), MeshOrigin.Y);
|
|
Detail->SetNumberField(TEXT("mesh_region_origin_z_voxels"), MeshOrigin.Z);
|
|
Detail->SetBoolField(TEXT("mesh_region_contains_old_and_new_iso_bands"),
|
|
bMeshContainsBothIsoBands);
|
|
Detail->SetBoolField(TEXT("new_detail_at_least_old_spectrum"), bDetailAtLeastOld);
|
|
Detail->SetNumberField(TEXT("detail_batch_raw_parity_samples"), DetailBatchParitySamples);
|
|
Detail->SetNumberField(TEXT("detail_batch_max_absolute_error"), DetailBatchMaxAbsoluteError);
|
|
Detail->SetBoolField(TEXT("detail_batch_matches_direct_field"), bDetailBatchMatchesRaw);
|
|
Detail->SetBoolField(TEXT("deliberate_smoothing_control_detected"), bSmoothControlDetected);
|
|
Detail->SetBoolField(TEXT("smoothed_control_below_old_spectrum"),
|
|
bSmoothWouldFallBelowOldSpectrum);
|
|
Report->SetObjectField(TEXT("detail_preservation"), Detail);
|
|
|
|
TSharedRef<FJsonObject> Boundaries = MakeShared<FJsonObject>();
|
|
Boundaries->SetNumberField(TEXT("brick_size_voxels"), 8);
|
|
Boundaries->SetNumberField(TEXT("feature_cell_spacing_voxels"), FeatureCellSpacing);
|
|
Boundaries->SetNumberField(TEXT("chunk_boundary_gradient_jump_rms_xy_near_surface"),
|
|
BoundaryStats.ChunkBoundaryJumpRms);
|
|
Boundaries->SetNumberField(TEXT("chunk_boundary_gradient_jump_max_xy_near_surface"),
|
|
BoundaryStats.ChunkBoundaryJumpMax);
|
|
Boundaries->SetNumberField(TEXT("brick_boundary_gradient_jump_rms"), BoundaryStats.BrickJumpRms);
|
|
Boundaries->SetNumberField(TEXT("interior_gradient_jump_rms"), BoundaryStats.InteriorJumpRms);
|
|
Boundaries->SetNumberField(TEXT("feature_cell_boundary_gradient_jump_rms"), BoundaryStats.FeatureCellJumpRms);
|
|
Boundaries->SetNumberField(TEXT("feature_cell_interior_gradient_jump_rms"),
|
|
BoundaryStats.FeatureCellInteriorJumpRms);
|
|
Boundaries->SetNumberField(TEXT("brick_boundary_samples_near_surface"),
|
|
static_cast<double>(BoundaryStats.BrickBoundarySamples));
|
|
Boundaries->SetNumberField(TEXT("chunk_boundary_samples_near_surface_xy"),
|
|
static_cast<double>(BoundaryStats.ChunkBoundarySamples));
|
|
Boundaries->SetNumberField(TEXT("macro_period_boundary_samples"),
|
|
static_cast<double>(BoundaryStats.FeatureCellBoundarySamples));
|
|
Boundaries->SetNumberField(TEXT("brick_boundary_gradient_jump_max"), BoundaryStats.BrickJumpMax);
|
|
Boundaries->SetNumberField(TEXT("feature_cell_boundary_gradient_jump_max"), BoundaryStats.FeatureCellJumpMax);
|
|
Boundaries->SetBoolField(TEXT("boundary_check_pass"), bBoundaryStatsSound);
|
|
Report->SetObjectField(TEXT("boundary_artifact_check"), Boundaries);
|
|
|
|
TSharedRef<FJsonObject> Proofs = MakeShared<FJsonObject>();
|
|
Proofs->SetNumberField(TEXT("correctly_skipped_bricks_audited"),
|
|
static_cast<double>(BrickProofAudit.CorrectlySkippedBricksAudited));
|
|
Proofs->SetNumberField(TEXT("final_field_samples_checked_including_halo"),
|
|
static_cast<double>(BrickProofAudit.CorrectProofSamplesChecked));
|
|
Proofs->SetNumberField(TEXT("correct_proof_sign_violations"),
|
|
static_cast<double>(BrickProofAudit.CorrectProofViolations));
|
|
Proofs->SetNumberField(TEXT("deliberately_undersized_detail_bound_claims_checked"),
|
|
static_cast<double>(BrickProofAudit.DeliberatelyUndersizedBoundClaimsChecked));
|
|
Proofs->SetNumberField(TEXT("wrong_sign_samples_from_undersized_bound"),
|
|
static_cast<double>(BrickProofAudit.DeliberateBoundDisagreements));
|
|
Proofs->SetNumberField(TEXT("deliberate_control_classifier_detail_scale"), 0.0);
|
|
Proofs->SetNumberField(TEXT("deliberate_control_actual_detail_scale"), 4.0);
|
|
Proofs->SetBoolField(TEXT("sound_bound_audit_pass"), bCorrectProofsSound);
|
|
Proofs->SetBoolField(TEXT("deliberate_undersized_bound_control_caught"), bUndersizedBoundCaught);
|
|
Report->SetObjectField(TEXT("brick_bound_audit"), Proofs);
|
|
|
|
TArray<TSharedPtr<FJsonValue>> Slices;
|
|
for (const FString& Name : ImageNames)
|
|
{
|
|
TSharedRef<FJsonObject> Slice = MakeShared<FJsonObject>();
|
|
Slice->SetStringField(TEXT("file"), FPaths::Combine(ResearchOut, Name));
|
|
Slice->SetStringField(TEXT("layout"), TEXT("old field on left; redefined field on right; light=air, dark=solid, cyan=near zero"));
|
|
Slices.Add(MakeShared<FJsonValueObject>(Slice));
|
|
}
|
|
Report->SetArrayField(TEXT("slices"), Slices);
|
|
FString JsonText;
|
|
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&JsonText);
|
|
const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step2d_report.json"));
|
|
if (!FJsonSerializer::Serialize(Report, Writer)
|
|
|| !FFileHelper::SaveStringToFile(
|
|
JsonText, *ReportPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)
|
|
|| !FFileHelper::SaveStringToFile(
|
|
JsonText, *FPaths::Combine(Arguments.OutDirectory, TEXT("step2d_report.json")),
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] could not write report '%s'."), *ReportPath);
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep2d] pass=%s speed600=%s old_ms=%.3f new_ms=%.3f skip=%.1f%% "
|
|
"roughness(old/new/smooth)=%.4f/%.4f/%.4f hashes=%s report=%s"),
|
|
bPassed ? TEXT("yes") : TEXT("no"), bSpeedPass ? TEXT("yes") : TEXT("no"),
|
|
OldMedianWall * 1000.0, NewMedianWall * 1000.0,
|
|
NewTrials.Last().SampleCount > 0
|
|
? 100.0 * static_cast<double>(NewTrials.Last().SkippedSamples)
|
|
/ static_cast<double>(NewTrials.Last().SampleCount) : 0.0,
|
|
OldSpectrum.RMSLag1, NewSpectrum.RMSLag1, SmoothSpectrum.RMSLag1,
|
|
bHashesAgree ? TEXT("same") : TEXT("DIFFERENT"), *ReportPath);
|
|
return bPassed ? 0 : 2;
|
|
}
|
|
|
|
int32 RunStep2e(const FString& Params)
|
|
{
|
|
FExploreArguments Arguments;
|
|
FString Error;
|
|
if (!ParseArguments(Params, Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] argument error: %s"), *Error);
|
|
return 2;
|
|
}
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(true);
|
|
int32 Seed = 1337;
|
|
FParse::Value(*Params, TEXT("seed="), Seed);
|
|
int32 Workers = VoxelForgeRegionEval::WorkerCount;
|
|
FParse::Value(*Params, TEXT("regionworkers="), Workers);
|
|
Workers = FMath::Clamp(Workers, 1, 32);
|
|
FString ResearchOut;
|
|
FParse::Value(*Params, TEXT("researchout="), ResearchOut);
|
|
if (ResearchOut.IsEmpty())
|
|
{
|
|
ResearchOut = FPaths::Combine(Arguments.OutDirectory, TEXT("Research/step2e"));
|
|
}
|
|
ResearchOut = FPaths::ConvertRelativePathToFull(ResearchOut);
|
|
IFileManager::Get().MakeDirectory(*ResearchOut, true);
|
|
IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true);
|
|
|
|
FExploreWorld World;
|
|
int32 RecipeIndex = INDEX_NONE;
|
|
uint32 RecipeHash = 0;
|
|
int32 EditChunkCount = 0;
|
|
const double PrepareStart = FPlatformTime::Seconds();
|
|
if (!PrepareRegionEvalWorld(World, Arguments, Seed,
|
|
RecipeIndex, RecipeHash, EditChunkCount, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] world preparation failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
const double WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStart;
|
|
if (World.DiffLayer != nullptr)
|
|
{
|
|
World.DiffLayer->Clear();
|
|
}
|
|
|
|
const int32 Dimension = VoxelForgeRegionEval::RingDimension;
|
|
const int64 TotalSamples = static_cast<int64>(Dimension) * Dimension * Dimension;
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
const FIntVector Origin(-129, -129, CenterZ - 129);
|
|
VoxelForgeRedefinedDensityField::SetEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(-1);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
|
|
TArray<float> OldDensities;
|
|
VoxelForgeRegionEval::FTiming OldTiming;
|
|
if (!VoxelForgeRegionEval::Evaluate(*World.Generator, Origin, Dimension,
|
|
nullptr, true, false, 1.0f, &OldDensities, OldTiming, false, Workers, false))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] live comparison ring failed."));
|
|
return 2;
|
|
}
|
|
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(true);
|
|
TArray<TSharedPtr<FJsonValue>> ProfileRows;
|
|
TArray<TSharedPtr<FJsonValue>> SliceRows;
|
|
TArray<double> ProfileMedianMs;
|
|
bool bProfilePasses = true;
|
|
VoxelForgeRedefinedDensityField::FGenerationParameters HardestParameters;
|
|
const TCHAR* HardestName = TEXT("HyperComplex");
|
|
|
|
for (int32 Profile = 0;
|
|
Profile < VoxelForgeRedefinedDensityField::GetParameterSetCount(); ++Profile)
|
|
{
|
|
const TCHAR* ProfileName = nullptr;
|
|
VoxelForgeRedefinedDensityField::FGenerationParameters ProfileParameters;
|
|
if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo(
|
|
Profile, ProfileName, ProfileParameters))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] missing profile %d."), Profile);
|
|
return 2;
|
|
}
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(Profile);
|
|
TArray<double> Times;
|
|
TArray<float> ProfileDensities;
|
|
VoxelForgeRegionEval::FTiming LastTiming;
|
|
for (int32 Trial = 0; Trial < 3; ++Trial)
|
|
{
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
ProfileDensities, LastTiming, true, true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep2e] profile %d six-worker ring trial %d failed."),
|
|
Profile, Trial + 1);
|
|
return 2;
|
|
}
|
|
Times.Add(LastTiming.WallSeconds * 1000.0);
|
|
}
|
|
Times.Sort();
|
|
const double MedianMs = Times[Times.Num() / 2];
|
|
ProfileMedianMs.Add(MedianMs);
|
|
bProfilePasses &= MedianMs <= 600.0;
|
|
|
|
const FStep2eVarietyMetrics Variety = ComputeStep2eVarietyMetrics(
|
|
ProfileDensities, Dimension);
|
|
TSharedRef<FJsonObject> Row = MakeShared<FJsonObject>();
|
|
Row->SetNumberField(TEXT("profile_index"), Profile);
|
|
Row->SetStringField(TEXT("name"), ProfileName);
|
|
Row->SetObjectField(TEXT("parameters"), Step2eParametersJson(ProfileParameters));
|
|
Row->SetObjectField(TEXT("nominal_chamber_volume_quantiles_m3"),
|
|
Step2eChamberVolumeQuantilesJson(ProfileParameters));
|
|
Row->SetNumberField(TEXT("six_worker_ring_median_ms"), MedianMs);
|
|
TArray<TSharedPtr<FJsonValue>> TrialValues;
|
|
TrialValues.Add(MakeShared<FJsonValueNumber>(Times[0]));
|
|
TrialValues.Add(MakeShared<FJsonValueNumber>(Times[1]));
|
|
TrialValues.Add(MakeShared<FJsonValueNumber>(Times[2]));
|
|
Row->SetArrayField(TEXT("six_worker_ring_trials_ms"), TrialValues);
|
|
Row->SetNumberField(TEXT("open_air_fraction"), Variety.OpenFraction);
|
|
Row->SetNumberField(TEXT("estimated_surface_area_m2"), Variety.SurfaceAreaSquareMeters);
|
|
Row->SetNumberField(TEXT("estimated_surface_area_per_volume_m_inv"),
|
|
Variety.SurfaceAreaPerVolume);
|
|
Row->SetNumberField(TEXT("vertical_air_adjacency_share"), Variety.VerticalAdjacencyShare);
|
|
Row->SetNumberField(TEXT("vertical_air_column_share"), Variety.VerticalAirColumnShare);
|
|
Row->SetNumberField(TEXT("mean_longest_vertical_air_run_voxels"),
|
|
Variety.MeanLongestVerticalAirRunVoxels);
|
|
Row->SetNumberField(TEXT("longest_vertical_air_run_voxels"),
|
|
Variety.LongestVerticalAirRunVoxels);
|
|
Row->SetNumberField(TEXT("one_meter_tunnel_neck_share"), Variety.TunnelNeckShare);
|
|
Row->SetNumberField(TEXT("one_meter_connected_components"),
|
|
Variety.ConnectedComponentsAtOneMeter);
|
|
Row->SetNumberField(TEXT("largest_component_coarse_m3"), Variety.LargestComponentCubicMeters);
|
|
Row->SetNumberField(TEXT("components_1_to_8_coarse_m3"), Variety.SmallComponentsUpToEightCubicMeters);
|
|
Row->SetNumberField(TEXT("components_9_to_64_coarse_m3"), Variety.MediumComponentsNineToSixtyFourCubicMeters);
|
|
Row->SetNumberField(TEXT("components_65_to_512_coarse_m3"), Variety.LargeComponentsSixtyFiveToFiveHundredTwelveCubicMeters);
|
|
Row->SetNumberField(TEXT("components_over_512_coarse_m3"), Variety.VeryLargeComponentsOverFiveHundredTwelveCubicMeters);
|
|
Row->SetNumberField(TEXT("skipped_bricks"), static_cast<double>(LastTiming.SkippedBricks));
|
|
Row->SetNumberField(TEXT("skipped_sample_fraction"), LastTiming.SampleCount > 0
|
|
? static_cast<double>(LastTiming.SkippedSamples) / LastTiming.SampleCount : 0.0);
|
|
Row->SetNumberField(TEXT("evaluated_samples"),
|
|
static_cast<double>(LastTiming.EvaluatedSamples));
|
|
Row->SetNumberField(TEXT("worker_cpu_seconds"), LastTiming.WorkerCpuSeconds);
|
|
Row->SetNumberField(TEXT("brick_classification_worker_ms"),
|
|
LastTiming.BrickClassificationSeconds * 1000.0);
|
|
Row->SetNumberField(TEXT("density_evaluation_worker_ms"),
|
|
LastTiming.DensityEvaluationSeconds * 1000.0);
|
|
Row->SetNumberField(TEXT("uniform_fill_worker_ms"),
|
|
LastTiming.UniformFillSeconds * 1000.0);
|
|
Row->SetNumberField(TEXT("longest_worker_ms"),
|
|
LastTiming.LongestWorkerWallSeconds * 1000.0);
|
|
|
|
FString SafeName(ProfileName);
|
|
SafeName.ToLowerInline();
|
|
const FString SetName = FString::Printf(TEXT("set_%02d_%s"), Profile, *SafeName);
|
|
const FString SetDirectory = FPaths::Combine(ResearchOut, SetName);
|
|
IFileManager::Get().MakeDirectory(*SetDirectory, true);
|
|
const FString ImageNames[] = {
|
|
TEXT("slice_xy_center.png"), TEXT("slice_xz_y0.png"),
|
|
TEXT("slice_yz_x0.png"), TEXT("slice_xz_passage.png")};
|
|
FString SliceError;
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[0]),
|
|
OldDensities, ProfileDensities, Dimension, TEXT('h'), Dimension / 2, SliceError);
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[1]),
|
|
OldDensities, ProfileDensities, Dimension, TEXT('x'), Dimension / 2, SliceError);
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[2]),
|
|
OldDensities, ProfileDensities, Dimension, TEXT('y'), Dimension / 2, SliceError);
|
|
int32 PassageIndex = Dimension / 2;
|
|
if (World.Manager != nullptr && !World.Manager->GetPassages().IsEmpty())
|
|
{
|
|
PassageIndex = FMath::Clamp(FMath::RoundToInt(
|
|
World.Manager->GetPassages()[0].LowerLanding.StandingPoint.Y) - Origin.Y,
|
|
0, Dimension - 1);
|
|
}
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[3]),
|
|
OldDensities, ProfileDensities, Dimension, TEXT('x'), PassageIndex, SliceError);
|
|
if (!SliceError.IsEmpty())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] slice write failed for %s: %s"),
|
|
ProfileName, *SliceError);
|
|
return 2;
|
|
}
|
|
Row->SetStringField(TEXT("slice_directory"), SetDirectory);
|
|
ProfileRows.Add(MakeShared<FJsonValueObject>(Row));
|
|
for (const FString& ImageName : ImageNames)
|
|
{
|
|
TSharedRef<FJsonObject> Slice = MakeShared<FJsonObject>();
|
|
Slice->SetStringField(TEXT("profile"), ProfileName);
|
|
Slice->SetStringField(TEXT("file"), FPaths::Combine(SetDirectory, ImageName));
|
|
Slice->SetStringField(TEXT("layout"),
|
|
TEXT("today's live field on left; step 2e parameterized field on right; light=air, dark=solid, cyan=near zero"));
|
|
SliceRows.Add(MakeShared<FJsonValueObject>(Slice));
|
|
}
|
|
if (Profile == VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1)
|
|
{
|
|
HardestParameters = ProfileParameters;
|
|
HardestName = ProfileName;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep2e] profile=%d/%s ring_ms=%.3f open=%.3f area=%.1f m2 components=%d"),
|
|
Profile, ProfileName, MedianMs, Variety.OpenFraction,
|
|
Variety.SurfaceAreaSquareMeters, Variety.ConnectedComponentsAtOneMeter);
|
|
}
|
|
|
|
const int32 HardestProfile = VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1;
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(HardestProfile);
|
|
TArray<float> HardestFullDensity;
|
|
TArray<float> RepeatFullDensity;
|
|
TArray<float> OneWorkerDensity;
|
|
TArray<float> SmoothDensity;
|
|
VoxelForgeRegionEval::FTiming HardestFullTiming;
|
|
VoxelForgeRegionEval::FTiming RepeatTiming;
|
|
VoxelForgeRegionEval::FTiming OneWorkerTiming;
|
|
VoxelForgeRegionEval::FTiming SmoothTiming;
|
|
const bool bHardestFullOk = VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
HardestFullDensity, HardestFullTiming, false, true);
|
|
const bool bRepeatOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
RepeatFullDensity, RepeatTiming, false, true);
|
|
const bool bOneWorkerOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, 1, 1.0f,
|
|
OneWorkerDensity, OneWorkerTiming, false, true);
|
|
const bool bSmoothOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 0.0f,
|
|
SmoothDensity, SmoothTiming, false, true);
|
|
if (!bHardestFullOk || !bRepeatOk || !bOneWorkerOk || !bSmoothOk)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] hardest-set control pass failed."));
|
|
return 2;
|
|
}
|
|
|
|
int64 DetailParitySamples = 0;
|
|
double DetailParityMaxError = 0.0;
|
|
for (int64 Index = 0; Index < TotalSamples; Index += 2047)
|
|
{
|
|
const int32 X = static_cast<int32>(Index % Dimension);
|
|
const int32 Y = static_cast<int32>((Index / Dimension) % Dimension);
|
|
const int32 Z = static_cast<int32>(Index
|
|
/ (static_cast<int64>(Dimension) * Dimension));
|
|
const float Direct = VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
*World.Generator, static_cast<float>(Origin.X + X),
|
|
static_cast<float>(Origin.Y + Y), static_cast<float>(Origin.Z + Z), nullptr, 1.0f);
|
|
DetailParityMaxError = FMath::Max(DetailParityMaxError,
|
|
static_cast<double>(FMath::Abs(Direct - HardestFullDensity[static_cast<int32>(Index)])));
|
|
++DetailParitySamples;
|
|
}
|
|
const bool bDetailParity = DetailParityMaxError <= 1.0e-5;
|
|
const FStep2dSurfaceSpectrum OldSpectrum = ComputeStep2dSpectrum(OldDensities, Dimension);
|
|
const FStep2dSurfaceSpectrum NewSpectrum = ComputeStep2dSpectrum(HardestFullDensity, Dimension);
|
|
const FStep2dSurfaceSpectrum SmoothSpectrum = ComputeStep2dSpectrum(SmoothDensity, Dimension);
|
|
const FStep2dBoundaryStats BoundaryStats = ComputeStep2dBoundaryStats(
|
|
HardestFullDensity, Dimension, Origin,
|
|
FMath::RoundToInt(HardestParameters.RoomPitchVoxels));
|
|
TArray<float> SeamFailureDensity = HardestFullDensity;
|
|
int64 DeliberateSeamSamplesMutated = 0;
|
|
for (int32 Z = 1; Z < Dimension - 1; ++Z)
|
|
for (int32 Y = 1; Y < Dimension - 1; ++Y)
|
|
for (int32 X = 1; X < Dimension - 1; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
if (((Origin.X + X) % 8) == 0 && FMath::Abs(HardestFullDensity[Index]) <= 12.0f)
|
|
{
|
|
SeamFailureDensity[Index + 1] += 64.0f;
|
|
++DeliberateSeamSamplesMutated;
|
|
}
|
|
}
|
|
const FStep2dBoundaryStats SeamFailureStats = ComputeStep2dBoundaryStats(
|
|
SeamFailureDensity, Dimension, Origin,
|
|
FMath::RoundToInt(HardestParameters.RoomPitchVoxels));
|
|
const FStep2dBrickProofAudit ProofAudit = AuditStep2dBrickProofs(
|
|
*World.Generator, Origin, Dimension, true);
|
|
const uint64 HardestHash = VoxelForgeRegionEval::HashQuantized(HardestFullDensity);
|
|
const uint64 RepeatHash = VoxelForgeRegionEval::HashQuantized(RepeatFullDensity);
|
|
const uint64 OneWorkerHash = VoxelForgeRegionEval::HashQuantized(OneWorkerDensity);
|
|
const bool bDeterminism = HardestHash == RepeatHash && HardestHash == OneWorkerHash
|
|
&& Step2eSameWorkerRun(HardestFullTiming, RepeatTiming)
|
|
&& Step2eSameWorkerRun(HardestFullTiming, OneWorkerTiming);
|
|
VoxelForgeRegionEval::FTiming DeliberateMismatch = HardestFullTiming;
|
|
DeliberateMismatch.QuantizedHash ^= 1u;
|
|
const bool bDeliberateDeterminismMismatchCaught =
|
|
!Step2eSameWorkerRun(HardestFullTiming, DeliberateMismatch);
|
|
const bool bDetailAtLeastLive = NewSpectrum.RMSLag1 >= OldSpectrum.RMSLag1
|
|
&& NewSpectrum.RMSLag2 >= OldSpectrum.RMSLag2;
|
|
const bool bSmoothingControlCaught = SmoothSpectrum.RMSLag1 < NewSpectrum.RMSLag1 * 0.80
|
|
|| SmoothSpectrum.RMSLag2 < NewSpectrum.RMSLag2 * 0.80;
|
|
const bool bBoundaryPass = Step2eBoundaryStatsPass(BoundaryStats);
|
|
const bool bDeliberateSeamFailureCaught = DeliberateSeamSamplesMutated > 0
|
|
&& !Step2eBoundaryStatsPass(SeamFailureStats);
|
|
const bool bBoundAudit = ProofAudit.CorrectlySkippedBricksAudited > 0
|
|
&& ProofAudit.CorrectProofSamplesChecked > 0
|
|
&& ProofAudit.CorrectProofViolations == 0;
|
|
const bool bUndersizedBoundCaught = ProofAudit.DeliberateBoundDisagreements > 0;
|
|
double WorstMedianMs = 0.0;
|
|
for (const double MedianMs : ProfileMedianMs)
|
|
{
|
|
WorstMedianMs = FMath::Max(WorstMedianMs, MedianMs);
|
|
}
|
|
const bool bSpeedPass = Workers == 6 && bProfilePasses && WorstMedianMs <= 600.0;
|
|
const bool bPassed = bSpeedPass && bDeterminism && bDetailAtLeastLive
|
|
&& bSmoothingControlCaught && bDetailParity && bBoundaryPass
|
|
&& bDeliberateSeamFailureCaught && bDeliberateDeterminismMismatchCaught
|
|
&& bBoundAudit && bUndersizedBoundCaught
|
|
&& HardestFullTiming.NonFiniteSamples == 0;
|
|
|
|
TSharedRef<FJsonObject> Report = MakeShared<FJsonObject>();
|
|
Report->SetStringField(TEXT("status"), bPassed ? TEXT("pass") : TEXT("fail"));
|
|
Report->SetBoolField(TEXT("pass"), bPassed);
|
|
Report->SetBoolField(TEXT("worst_profile_meets_600ms"), bSpeedPass);
|
|
Report->SetNumberField(TEXT("seed"), Seed);
|
|
Report->SetNumberField(TEXT("ring_dimension"), Dimension);
|
|
Report->SetNumberField(TEXT("ring_samples"), static_cast<double>(TotalSamples));
|
|
Report->SetNumberField(TEXT("workers_requested"), Workers);
|
|
Report->SetNumberField(TEXT("world_prepare_ms_excluded"), WorldPrepareSeconds * 1000.0);
|
|
Report->SetNumberField(TEXT("old_live_ring_ms"), OldTiming.WallSeconds * 1000.0);
|
|
Report->SetStringField(TEXT("old_live_ring_raw_float_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(OldTiming.RawFloatHash)));
|
|
Report->SetStringField(TEXT("field_definition"),
|
|
TEXT("Seeded jittered XY chamber cells form a deterministic origin-rooted spanning tree; "
|
|
"selected loops, horizontal tunnels and full-strate shafts open the network. Each "
|
|
"chamber can add pillars, annular ledges, paired stalactites/stalagmites, floating "
|
|
"rock masses and a bowl floor with an explicit water level. Passage pads and anchor "
|
|
"tunnels join the authored inter-strate route to the generated network."));
|
|
Report->SetStringField(TEXT("parameter_space"),
|
|
TEXT("Eight named profiles can be assigned per strate from a stable seed hash (-1 mode), "
|
|
"or forced for measurement. Each profile supplies nominal room, tunnel, shaft, "
|
|
"feature-probability and basin values. Per-cell hashes deterministically roll room "
|
|
"jitter and feature presence. Profile minima and maxima are recorded below; room "
|
|
"height is clamped to the strate span, room radii to 0.20-0.72/0.80 of pitch, "
|
|
"loop probability to 0-0.8, probabilities and water level to 0-1, and basin depth "
|
|
"to 0-48 voxels."));
|
|
Report->SetObjectField(TEXT("parameter_ranges_across_eight_profiles"),
|
|
Step2eParameterRangesJson());
|
|
Report->SetStringField(TEXT("resolution_contract"),
|
|
TEXT("One exact field sample per 12.5 cm lattice point. The four existing 16/8/4/2-sample "
|
|
"detail bands are evaluated for every potentially surface-affecting point; no coarse "
|
|
"field is interpolated onto the lattice. 4-cubed step 2e skips require the final macro field "
|
|
"Lipschitz interval plus the full 10.5-unit detail supremum and marching-cubes halo."));
|
|
Report->SetStringField(TEXT("finer_lattice_scaling"),
|
|
TEXT("At 12.5 cm, metre-valued macro and noise dimensions convert to twice as many samples; "
|
|
"the four detail bands retain their metre wavelengths. The lattice grows 8x for a fixed "
|
|
"world volume; surface bricks grow roughly with area while certified interior bricks "
|
|
"remain cheap."));
|
|
Report->SetStringField(TEXT("variety_metric_method"),
|
|
TEXT("Open fraction uses every ring sample. Surface area estimates sign-changing lattice "
|
|
"edges times 0.125 m squared. Nominal chamber-volume p10/p50/p90 values use the "
|
|
"uniform per-cell radius roll and room height as circular-cylinder volumes. Tunnel-neck "
|
|
"share and connected-component sizes use a 1 m downsampled six-neighbor occupancy grid. "
|
|
"Verticality is the mean longest contiguous open run per XY column, with open-column "
|
|
"coverage reported separately."));
|
|
Report->SetStringField(TEXT("water_geometry_limit"),
|
|
TEXT("Basin profiles generate the bowl floor and water-level parameter; this density field "
|
|
"does not render or simulate water."));
|
|
Report->SetNumberField(TEXT("worst_six_worker_ring_median_ms"), WorstMedianMs);
|
|
Report->SetNumberField(TEXT("target_aim_ms"), 350.0);
|
|
Report->SetStringField(TEXT("hardest_profile"), HardestName);
|
|
Report->SetArrayField(TEXT("profiles"), ProfileRows);
|
|
Report->SetArrayField(TEXT("slices"), SliceRows);
|
|
Report->SetStringField(TEXT("slice_directory"), ResearchOut);
|
|
|
|
TSharedRef<FJsonObject> Detail = MakeShared<FJsonObject>();
|
|
Detail->SetNumberField(TEXT("live_surface_band_samples"), static_cast<double>(OldSpectrum.SurfaceBandSamples));
|
|
Detail->SetNumberField(TEXT("new_surface_band_samples"), static_cast<double>(NewSpectrum.SurfaceBandSamples));
|
|
Detail->SetNumberField(TEXT("live_curvature_rms_lag1"), OldSpectrum.RMSLag1);
|
|
Detail->SetNumberField(TEXT("new_curvature_rms_lag1"), NewSpectrum.RMSLag1);
|
|
Detail->SetNumberField(TEXT("smooth_control_curvature_rms_lag1"), SmoothSpectrum.RMSLag1);
|
|
Detail->SetNumberField(TEXT("live_curvature_rms_lag2"), OldSpectrum.RMSLag2);
|
|
Detail->SetNumberField(TEXT("new_curvature_rms_lag2"), NewSpectrum.RMSLag2);
|
|
Detail->SetNumberField(TEXT("smooth_control_curvature_rms_lag2"), SmoothSpectrum.RMSLag2);
|
|
Detail->SetNumberField(TEXT("detail_raw_parity_samples"), DetailParitySamples);
|
|
Detail->SetNumberField(TEXT("detail_raw_parity_max_error"), DetailParityMaxError);
|
|
Detail->SetBoolField(TEXT("detail_at_least_live_spectrum"), bDetailAtLeastLive);
|
|
Detail->SetBoolField(TEXT("deliberate_zero_detail_smoothing_detected"), bSmoothingControlCaught);
|
|
Detail->SetBoolField(TEXT("direct_vs_brick_detail_parity"), bDetailParity);
|
|
Report->SetObjectField(TEXT("detail_check"), Detail);
|
|
|
|
TSharedRef<FJsonObject> Boundary = MakeShared<FJsonObject>();
|
|
Boundary->SetNumberField(TEXT("brick_boundary_gradient_jump_rms"), BoundaryStats.BrickJumpRms);
|
|
Boundary->SetNumberField(TEXT("interior_gradient_jump_rms"), BoundaryStats.InteriorJumpRms);
|
|
Boundary->SetNumberField(TEXT("chunk_boundary_gradient_jump_rms"), BoundaryStats.ChunkBoundaryJumpRms);
|
|
Boundary->SetNumberField(TEXT("feature_cell_boundary_gradient_jump_rms"), BoundaryStats.FeatureCellJumpRms);
|
|
Boundary->SetNumberField(TEXT("feature_cell_interior_gradient_jump_rms"), BoundaryStats.FeatureCellInteriorJumpRms);
|
|
Boundary->SetBoolField(TEXT("boundary_artifact_check_pass"), bBoundaryPass);
|
|
Boundary->SetNumberField(TEXT("deliberate_seam_samples_mutated"),
|
|
static_cast<double>(DeliberateSeamSamplesMutated));
|
|
Boundary->SetBoolField(TEXT("deliberate_seam_failure_caught"),
|
|
bDeliberateSeamFailureCaught);
|
|
Report->SetObjectField(TEXT("boundary_artifact_check"), Boundary);
|
|
|
|
TSharedRef<FJsonObject> Proofs = MakeShared<FJsonObject>();
|
|
Proofs->SetNumberField(TEXT("correctly_skipped_bricks_audited"),
|
|
static_cast<double>(ProofAudit.CorrectlySkippedBricksAudited));
|
|
Proofs->SetNumberField(TEXT("final_field_samples_checked_including_halo"),
|
|
static_cast<double>(ProofAudit.CorrectProofSamplesChecked));
|
|
Proofs->SetNumberField(TEXT("correct_proof_sign_violations"),
|
|
static_cast<double>(ProofAudit.CorrectProofViolations));
|
|
Proofs->SetNumberField(TEXT("first_violation_brick_x"), ProofAudit.FirstViolationBrickOrigin.X);
|
|
Proofs->SetNumberField(TEXT("first_violation_brick_y"), ProofAudit.FirstViolationBrickOrigin.Y);
|
|
Proofs->SetNumberField(TEXT("first_violation_brick_z"), ProofAudit.FirstViolationBrickOrigin.Z);
|
|
Proofs->SetNumberField(TEXT("first_violation_brick_size_x"), ProofAudit.FirstViolationBrickSize.X);
|
|
Proofs->SetNumberField(TEXT("first_violation_brick_size_y"), ProofAudit.FirstViolationBrickSize.Y);
|
|
Proofs->SetNumberField(TEXT("first_violation_brick_size_z"), ProofAudit.FirstViolationBrickSize.Z);
|
|
Proofs->SetNumberField(TEXT("first_violation_sample_x"), ProofAudit.FirstViolationSample.X);
|
|
Proofs->SetNumberField(TEXT("first_violation_sample_y"), ProofAudit.FirstViolationSample.Y);
|
|
Proofs->SetNumberField(TEXT("first_violation_sample_z"), ProofAudit.FirstViolationSample.Z);
|
|
Proofs->SetNumberField(TEXT("first_violation_density"), ProofAudit.FirstViolationDensity);
|
|
Proofs->SetNumberField(TEXT("first_violation_center_macro"),
|
|
ProofAudit.FirstViolationCenterMacro);
|
|
Proofs->SetNumberField(TEXT("first_violation_sample_macro"),
|
|
ProofAudit.FirstViolationSampleMacro);
|
|
Proofs->SetNumberField(TEXT("first_violation_sample_detail"),
|
|
ProofAudit.FirstViolationSampleDetail);
|
|
Proofs->SetNumberField(TEXT("first_violation_macro_delta_per_voxel"),
|
|
ProofAudit.FirstViolationMacroDeltaPerVoxel);
|
|
Proofs->SetNumberField(TEXT("first_violation_path_max_gradient"),
|
|
ProofAudit.FirstViolationPathMaxGradient);
|
|
Proofs->SetNumberField(TEXT("first_violation_tiny_step_max_gradient"),
|
|
ProofAudit.FirstViolationTinyStepMaxGradient);
|
|
Proofs->SetBoolField(TEXT("first_violation_inside_proven_brick"),
|
|
ProofAudit.bFirstViolationWasInsideBrick);
|
|
Proofs->SetBoolField(TEXT("first_violation_claimed_air"),
|
|
ProofAudit.bFirstViolationClaimedAir);
|
|
Proofs->SetNumberField(TEXT("undersized_bound_claims_checked"),
|
|
static_cast<double>(ProofAudit.DeliberatelyUndersizedBoundClaimsChecked));
|
|
Proofs->SetNumberField(TEXT("wrong_sign_samples_caught_by_undersized_bound"),
|
|
static_cast<double>(ProofAudit.DeliberateBoundDisagreements));
|
|
Proofs->SetBoolField(TEXT("sound_supremum_audit_pass"), bBoundAudit);
|
|
Proofs->SetBoolField(TEXT("deliberate_undersized_bound_failure_caught"), bUndersizedBoundCaught);
|
|
Report->SetObjectField(TEXT("brick_bound_audit"), Proofs);
|
|
|
|
TSharedRef<FJsonObject> Determinism = MakeShared<FJsonObject>();
|
|
Determinism->SetStringField(TEXT("six_worker_run_1_quantized"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(HardestHash)));
|
|
Determinism->SetStringField(TEXT("six_worker_run_2_quantized"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(RepeatHash)));
|
|
Determinism->SetStringField(TEXT("one_worker_quantized"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(OneWorkerHash)));
|
|
Determinism->SetStringField(TEXT("six_worker_run_1_raw_float"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(HardestFullTiming.RawFloatHash)));
|
|
Determinism->SetStringField(TEXT("six_worker_run_2_raw_float"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(RepeatTiming.RawFloatHash)));
|
|
Determinism->SetStringField(TEXT("one_worker_raw_float"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(OneWorkerTiming.RawFloatHash)));
|
|
Determinism->SetBoolField(TEXT("same_across_two_runs_and_1_vs_6_workers"), bDeterminism);
|
|
Determinism->SetBoolField(TEXT("deliberate_quantized_hash_mismatch_rejected"),
|
|
bDeliberateDeterminismMismatchCaught);
|
|
Report->SetObjectField(TEXT("determinism"), Determinism);
|
|
Report->SetBoolField(TEXT("entrance_exit_walkability_test_added"), true);
|
|
Report->SetBoolField(TEXT("inter_strate_passage_open_space_test_added"), true);
|
|
Report->SetStringField(TEXT("functional_test_filter"),
|
|
TEXT("VoxelForge.ParameterizedGeneration.TraversalSeeds"));
|
|
|
|
FString JsonText;
|
|
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&JsonText);
|
|
const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step2e_report.json"));
|
|
const FString LaunchReportPath = FPaths::Combine(Arguments.OutDirectory, TEXT("step2e_report.json"));
|
|
if (!FJsonSerializer::Serialize(Report, Writer)
|
|
|| !FFileHelper::SaveStringToFile(JsonText, *ReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)
|
|
|| !FFileHelper::SaveStringToFile(JsonText, *LaunchReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] report write failed: %s"), *ReportPath);
|
|
return 2;
|
|
}
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep2e] pass=%s speed600=%s worst_ms=%.3f detail=%s boundary=%s "
|
|
"bound=%s deterministic=%s report=%s"),
|
|
bPassed ? TEXT("yes") : TEXT("no"), bSpeedPass ? TEXT("yes") : TEXT("no"),
|
|
WorstMedianMs, bDetailAtLeastLive ? TEXT("pass") : TEXT("fail"),
|
|
bBoundaryPass ? TEXT("pass") : TEXT("fail"), bBoundAudit ? TEXT("pass") : TEXT("fail"),
|
|
bDeterminism ? TEXT("pass") : TEXT("fail"), *ReportPath);
|
|
return bPassed ? 0 : 2;
|
|
}
|
|
|
|
int32 RunStep3ScaleBenchmark(const FString& Params)
|
|
{
|
|
FExploreArguments Arguments;
|
|
FString Error;
|
|
if (!ParseArguments(Params, Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep3Scale] argument error: %s"), *Error);
|
|
return 2;
|
|
}
|
|
|
|
int32 Seed = 1337;
|
|
int32 Workers = VoxelForgeRegionEval::WorkerCount;
|
|
int32 Profile = 0;
|
|
FParse::Value(*Params, TEXT("seed="), Seed);
|
|
FParse::Value(*Params, TEXT("regionworkers="), Workers);
|
|
FParse::Value(*Params, TEXT("profile="), Profile);
|
|
Workers = FMath::Clamp(Workers, 1, 32);
|
|
Profile = FMath::Clamp(Profile, 0,
|
|
VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1);
|
|
|
|
FString ResearchOut;
|
|
FParse::Value(*Params, TEXT("researchout="), ResearchOut);
|
|
if (ResearchOut.IsEmpty())
|
|
{
|
|
ResearchOut = FPaths::Combine(Arguments.OutDirectory, TEXT("Research/step3-scale"));
|
|
}
|
|
ResearchOut = FPaths::ConvertRelativePathToFull(ResearchOut);
|
|
IFileManager::Get().MakeDirectory(*ResearchOut, true);
|
|
IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true);
|
|
|
|
FExploreWorld World;
|
|
int32 RecipeIndex = INDEX_NONE;
|
|
uint32 RecipeHash = 0;
|
|
int32 EditChunkCount = 0;
|
|
FString WorldError;
|
|
const double PrepareStart = FPlatformTime::Seconds();
|
|
if (!PrepareRegionEvalWorld(World, Arguments, Seed,
|
|
RecipeIndex, RecipeHash, EditChunkCount, WorldError))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep3Scale] world preparation failed: %s"),
|
|
*WorldError);
|
|
return 2;
|
|
}
|
|
const double WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStart;
|
|
if (World.DiffLayer != nullptr)
|
|
{
|
|
World.DiffLayer->Clear();
|
|
}
|
|
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
const TCHAR* ProfileName = TEXT("unknown");
|
|
VoxelForgeRedefinedDensityField::FGenerationParameters ProfileParameters;
|
|
if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo(
|
|
Profile, ProfileName, ProfileParameters))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep3Scale] profile %d is unavailable."), Profile);
|
|
return 2;
|
|
}
|
|
VoxelForgeRedefinedDensityField::SetEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(true);
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(Profile);
|
|
|
|
const int32 Dimensions[] = { 519, 259 };
|
|
TArray<TSharedPtr<FJsonValue>> CubeRows;
|
|
bool bPass = Workers == 6;
|
|
for (const int32 Dimension : Dimensions)
|
|
{
|
|
const int32 HalfSpan = (Dimension - 1) / 2;
|
|
const FIntVector Origin(-HalfSpan, -HalfSpan, CenterZ - HalfSpan);
|
|
const int64 ExpectedSamples = static_cast<int64>(Dimension)
|
|
* Dimension * Dimension;
|
|
TArray<TSharedPtr<FJsonValue>> TrialRows;
|
|
TArray<double> WallTimes;
|
|
TArray<double> CpuTimes;
|
|
uint64 FirstQuantizedHash = 0;
|
|
uint64 FirstRawHash = 0;
|
|
bool bDeterministic = true;
|
|
int32 ObservedWorkersMin = Workers;
|
|
int64 EvaluatedSamples = -1;
|
|
int64 SkippedSamples = -1;
|
|
uint64 BrickCount = 0;
|
|
uint64 SkippedBricks = 0;
|
|
|
|
for (int32 Trial = 0; Trial < 3; ++Trial)
|
|
{
|
|
VoxelForgeRegionEval::FTiming Timing;
|
|
TArray<float> Densities;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
Densities, Timing, /*bEnableBrickSkips=*/true,
|
|
/*bParameterized=*/true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep3Scale] dimension=%d trial=%d evaluation failed."),
|
|
Dimension, Trial + 1);
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
return 2;
|
|
}
|
|
if (Densities.Num() != ExpectedSamples || Timing.SampleCount != ExpectedSamples
|
|
|| Timing.NonFiniteSamples != 0)
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep3Scale] dimension=%d trial=%d sample/finite invariant failed."),
|
|
Dimension, Trial + 1);
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
return 2;
|
|
}
|
|
|
|
if (Trial == 0)
|
|
{
|
|
FirstQuantizedHash = Timing.QuantizedHash;
|
|
FirstRawHash = Timing.RawFloatHash;
|
|
EvaluatedSamples = Timing.EvaluatedSamples;
|
|
SkippedSamples = static_cast<int64>(Timing.SkippedSamples);
|
|
BrickCount = Timing.BrickCount;
|
|
SkippedBricks = Timing.SkippedBricks;
|
|
}
|
|
else
|
|
{
|
|
bDeterministic &= Timing.QuantizedHash == FirstQuantizedHash
|
|
&& Timing.RawFloatHash == FirstRawHash;
|
|
}
|
|
ObservedWorkersMin = FMath::Min(ObservedWorkersMin,
|
|
Timing.ObservedWorkerThreads);
|
|
WallTimes.Add(Timing.WallSeconds);
|
|
CpuTimes.Add(Timing.WorkerCpuSeconds);
|
|
|
|
TSharedRef<FJsonObject> TrialJson = MakeShared<FJsonObject>();
|
|
TrialJson->SetNumberField(TEXT("trial"), Trial + 1);
|
|
TrialJson->SetNumberField(TEXT("wall_seconds"), Timing.WallSeconds);
|
|
TrialJson->SetNumberField(TEXT("worker_cpu_seconds"), Timing.WorkerCpuSeconds);
|
|
TrialJson->SetNumberField(TEXT("workers_requested"), Timing.Workers);
|
|
TrialJson->SetNumberField(TEXT("workers_observed"), Timing.ObservedWorkerThreads);
|
|
TrialJson->SetNumberField(TEXT("evaluated_samples"),
|
|
static_cast<double>(Timing.EvaluatedSamples));
|
|
TrialJson->SetNumberField(TEXT("skipped_samples"),
|
|
static_cast<double>(Timing.SkippedSamples));
|
|
TrialJson->SetNumberField(TEXT("bricks"), static_cast<double>(Timing.BrickCount));
|
|
TrialJson->SetNumberField(TEXT("skipped_bricks"),
|
|
static_cast<double>(Timing.SkippedBricks));
|
|
TrialJson->SetStringField(TEXT("quantized_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(Timing.QuantizedHash)));
|
|
TrialJson->SetStringField(TEXT("raw_float_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(Timing.RawFloatHash)));
|
|
TrialRows.Add(MakeShared<FJsonValueObject>(TrialJson));
|
|
}
|
|
|
|
WallTimes.Sort();
|
|
CpuTimes.Sort();
|
|
TSharedRef<FJsonObject> Cube = MakeShared<FJsonObject>();
|
|
Cube->SetNumberField(TEXT("dimension"), Dimension);
|
|
Cube->SetNumberField(TEXT("sample_count"), static_cast<double>(ExpectedSamples));
|
|
Cube->SetNumberField(TEXT("intervals_per_axis"), Dimension - 1);
|
|
Cube->SetNumberField(TEXT("sample_spacing_cm"), VOXEL_SIZE);
|
|
Cube->SetNumberField(TEXT("extent_m_per_axis"),
|
|
static_cast<double>(Dimension - 1) * VOXEL_SIZE / 100.0);
|
|
Cube->SetNumberField(TEXT("density_output_mib"),
|
|
static_cast<double>(ExpectedSamples) * sizeof(float) / (1024.0 * 1024.0));
|
|
Cube->SetNumberField(TEXT("workers_requested"), Workers);
|
|
Cube->SetNumberField(TEXT("minimum_workers_observed"), ObservedWorkersMin);
|
|
Cube->SetNumberField(TEXT("evaluated_samples"), static_cast<double>(EvaluatedSamples));
|
|
Cube->SetNumberField(TEXT("skipped_samples"), static_cast<double>(SkippedSamples));
|
|
Cube->SetNumberField(TEXT("bricks"), static_cast<double>(BrickCount));
|
|
Cube->SetNumberField(TEXT("skipped_bricks"), static_cast<double>(SkippedBricks));
|
|
Cube->SetNumberField(TEXT("median_wall_seconds"), WallTimes[WallTimes.Num() / 2]);
|
|
Cube->SetNumberField(TEXT("min_wall_seconds"), WallTimes[0]);
|
|
Cube->SetNumberField(TEXT("max_wall_seconds"), WallTimes.Last());
|
|
Cube->SetNumberField(TEXT("median_worker_cpu_seconds"), CpuTimes[CpuTimes.Num() / 2]);
|
|
Cube->SetBoolField(TEXT("deterministic_three_trials"), bDeterministic);
|
|
Cube->SetStringField(TEXT("quantized_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(FirstQuantizedHash)));
|
|
Cube->SetStringField(TEXT("raw_float_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(FirstRawHash)));
|
|
Cube->SetArrayField(TEXT("trials"), TrialRows);
|
|
CubeRows.Add(MakeShared<FJsonValueObject>(Cube));
|
|
bPass &= bDeterministic && ObservedWorkersMin == 6;
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep3Scale] dimension=%d samples=%lld extent_m=%.3f workers=%d "
|
|
"median_wall_s=%.6f min=%.6f max=%.6f evaluated=%lld skipped=%lld "
|
|
"deterministic=%d hash=%016llx"),
|
|
Dimension, static_cast<long long>(ExpectedSamples),
|
|
static_cast<double>(Dimension - 1) * VOXEL_SIZE / 100.0,
|
|
Workers, WallTimes[WallTimes.Num() / 2], WallTimes[0], WallTimes.Last(),
|
|
static_cast<long long>(EvaluatedSamples), static_cast<long long>(SkippedSamples),
|
|
bDeterministic ? 1 : 0,
|
|
static_cast<unsigned long long>(FirstQuantizedHash));
|
|
}
|
|
|
|
TSharedRef<FJsonObject> Report = MakeShared<FJsonObject>();
|
|
Report->SetNumberField(TEXT("schema_version"), 1);
|
|
Report->SetStringField(TEXT("tool"), TEXT("VoxelForgeStep3Scale"));
|
|
Report->SetBoolField(TEXT("pass"), bPass);
|
|
Report->SetNumberField(TEXT("seed"), Seed);
|
|
Report->SetNumberField(TEXT("profile"), Profile);
|
|
Report->SetStringField(TEXT("profile_name"), ProfileName);
|
|
Report->SetNumberField(TEXT("worker_count"), Workers);
|
|
Report->SetNumberField(TEXT("voxel_size_cm"), VOXEL_SIZE);
|
|
Report->SetNumberField(TEXT("chunk_size_voxels"), CHUNK_SIZE);
|
|
Report->SetNumberField(TEXT("chunk_size_m"),
|
|
static_cast<double>(CHUNK_SIZE) * VOXEL_SIZE / 100.0);
|
|
Report->SetNumberField(TEXT("detail_scale"), 1.0);
|
|
Report->SetBoolField(TEXT("brick_skips_enabled"), true);
|
|
Report->SetNumberField(TEXT("world_prepare_seconds"), WorldPrepareSeconds);
|
|
Report->SetNumberField(TEXT("recipe_index"), RecipeIndex);
|
|
Report->SetNumberField(TEXT("recipe_hash"), RecipeHash);
|
|
Report->SetNumberField(TEXT("edit_chunk_count"), EditChunkCount);
|
|
Report->SetArrayField(TEXT("cubes"), CubeRows);
|
|
|
|
FString JsonText;
|
|
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&JsonText);
|
|
const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step3_scale_benchmark.json"));
|
|
const FString LaunchReportPath = FPaths::Combine(
|
|
Arguments.OutDirectory, TEXT("step3_scale_benchmark.json"));
|
|
if (!FJsonSerializer::Serialize(Report, Writer)
|
|
|| !FFileHelper::SaveStringToFile(JsonText, *ReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)
|
|
|| !FFileHelper::SaveStringToFile(JsonText, *LaunchReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep3Scale] report write failed: %s"), *ReportPath);
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
return 2;
|
|
}
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep3Scale] pass=%d report=%s"), bPass ? 1 : 0, *ReportPath);
|
|
return bPass ? 0 : 2;
|
|
}
|
|
|
|
int32 RunStep2f(const FString& Params)
|
|
{
|
|
FExploreArguments Arguments;
|
|
FString Error;
|
|
if (!ParseArguments(Params, Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] argument error: %s"), *Error);
|
|
return 2;
|
|
}
|
|
int32 Seed = 1337;
|
|
int32 Workers = VoxelForgeRegionEval::WorkerCount;
|
|
FParse::Value(*Params, TEXT("seed="), Seed);
|
|
FParse::Value(*Params, TEXT("regionworkers="), Workers);
|
|
Workers = FMath::Clamp(Workers, 1, 32);
|
|
FString ResearchOut;
|
|
FParse::Value(*Params, TEXT("researchout="), ResearchOut);
|
|
if (ResearchOut.IsEmpty())
|
|
{
|
|
ResearchOut = FPaths::Combine(Arguments.OutDirectory, TEXT("Research/step2f"));
|
|
}
|
|
ResearchOut = FPaths::ConvertRelativePathToFull(ResearchOut);
|
|
IFileManager::Get().MakeDirectory(*ResearchOut, true);
|
|
IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true);
|
|
|
|
FExploreWorld World;
|
|
int32 RecipeIndex = INDEX_NONE;
|
|
uint32 RecipeHash = 0;
|
|
int32 EditChunkCount = 0;
|
|
const double PrepareStart = FPlatformTime::Seconds();
|
|
if (!PrepareRegionEvalWorld(World, Arguments, Seed,
|
|
RecipeIndex, RecipeHash, EditChunkCount, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] world preparation failed: %s"), *Error);
|
|
return 2;
|
|
}
|
|
const double WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStart;
|
|
if (World.DiffLayer != nullptr)
|
|
{
|
|
World.DiffLayer->Clear();
|
|
}
|
|
|
|
const int32 Dimension = VoxelForgeRegionEval::RingDimension;
|
|
const int64 TotalSamples = static_cast<int64>(Dimension) * Dimension * Dimension;
|
|
const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2;
|
|
const FIntVector Origin(-129, -129, CenterZ - 129);
|
|
VoxelForgeRedefinedDensityField::SetEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(true);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(-1);
|
|
|
|
TArray<TSharedPtr<FJsonValue>> ProfileRows;
|
|
TArray<TSharedPtr<FJsonValue>> SliceRows;
|
|
double WorstStep2eMs = 0.0;
|
|
double WorstStep2fMs = 0.0;
|
|
bool bStep2eCrumbsRejected = true;
|
|
bool bStep2fCrumbGatePassed = true;
|
|
bool bStep2eFlakesRejected = true;
|
|
bool bStep2fFlakeGatePassed = true;
|
|
bool bStep2eBandLimitRejected = true;
|
|
bool bStep2fBandLimitPassed = true;
|
|
bool bStep2eLodRejected = true;
|
|
bool bStep2fLodPassed = true;
|
|
bool bStep2eGridRejected = true;
|
|
bool bStep2fGridPassed = true;
|
|
bool bStep2eStructureRejected = true;
|
|
bool bStep2fStructurePassed = true;
|
|
double WorstStep2fSolidFraction = 0.0;
|
|
double BestStep2fSolidFraction = 1.0;
|
|
int64 WorstStep2eCrumbs = 0;
|
|
int64 WorstStep2fCrumbs = 0;
|
|
double WorstStep2eDihedralShare = 0.0;
|
|
double WorstStep2fDihedralShare = 0.0;
|
|
double WorstStep2eDihedralP95 = 0.0;
|
|
double WorstStep2fDihedralP95 = 0.0;
|
|
double WorstStep2eDetailEnergy = 0.0;
|
|
double WorstStep2fDetailEnergy = 0.0;
|
|
double BestStep2eDetailRms = MAX_dbl;
|
|
double BestStep2fDetailRms = MAX_dbl;
|
|
double WorstStep2eLodDistance = 0.0;
|
|
double WorstStep2fLodDistance = 0.0;
|
|
double WorstStep2ePitchCorrelation = 0.0;
|
|
double WorstStep2fPitchCorrelation = 0.0;
|
|
double WorstStep2ePitchExcess = -1.0;
|
|
double WorstStep2fPitchExcess = -1.0;
|
|
double LowestStep2eSolidFraction = 1.0;
|
|
double LowestStep2fSolidFraction = 1.0;
|
|
int64 WorstStep2eRoofOpenings = 0;
|
|
int64 WorstStep2eFloorOpenings = 0;
|
|
int64 WorstStep2fRoofOpenings = 0;
|
|
int64 WorstStep2fFloorOpenings = 0;
|
|
|
|
const auto MeasureShape = [&](const TArray<float>& Density,
|
|
const VoxelForgeRedefinedDensityField::FGenerationParameters& P)
|
|
{
|
|
FStep2fShapeSnapshot Result;
|
|
Result.Variety = ComputeStep2eVarietyMetrics(Density, Dimension);
|
|
Result.Fragments = ComputeStep2fFragmentMetrics(Density, Dimension);
|
|
Result.Grid = ComputeStep2fGridSignature(
|
|
*World.Generator, Origin, Dimension, P.RoomPitchVoxels);
|
|
|
|
constexpr int32 DetailDimension = 8 * static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
const FIntVector DetailOrigin = Origin + FIntVector(
|
|
(Dimension - DetailDimension) / 2,
|
|
(Dimension - DetailDimension) / 2,
|
|
(Dimension - DetailDimension) / 2);
|
|
TArray<float> DetailSamples;
|
|
DetailSamples.SetNumUninitialized(DetailDimension * DetailDimension * DetailDimension);
|
|
for (int32 Z = 0; Z < DetailDimension; ++Z)
|
|
for (int32 Y = 0; Y < DetailDimension; ++Y)
|
|
for (int32 X = 0; X < DetailDimension; ++X)
|
|
{
|
|
const int32 Index = (Z * DetailDimension + Y) * DetailDimension + X;
|
|
DetailSamples[Index] = VoxelForgeRedefinedDensityField::EvaluateParameterizedDetail(
|
|
*World.Generator,
|
|
static_cast<float>(DetailOrigin.X + X),
|
|
static_cast<float>(DetailOrigin.Y + Y),
|
|
static_cast<float>(DetailOrigin.Z + Z));
|
|
}
|
|
Result.Detail = ComputeStep2fDetailSpectrum(DetailSamples, DetailDimension);
|
|
|
|
const FIntVector MeshOrigin = SelectStep2fMeshOrigin(
|
|
Density, Origin, Dimension);
|
|
const bool bPreviousSkirts = World.Mesher->bGenerateSkirts;
|
|
World.Mesher->bGenerateSkirts = false;
|
|
FVoxelMeshData FineMesh;
|
|
// GenerateMesh clamps cells per tile to CHUNK_SIZE. At 12.5 cm, a 16 m
|
|
// full-detail cube is four level-0 chunks across and two level-1 chunks across.
|
|
constexpr int32 Level0TilesPerAxis = static_cast<int32>(
|
|
16.0f / (CHUNK_SIZE * VOXEL_SIZE / 100.0f));
|
|
for (int32 TileZ = 0; TileZ < Level0TilesPerAxis; ++TileZ)
|
|
for (int32 TileY = 0; TileY < Level0TilesPerAxis; ++TileY)
|
|
for (int32 TileX = 0; TileX < Level0TilesPerAxis; ++TileX)
|
|
{
|
|
const FIntVector TileOrigin = MeshOrigin + FIntVector(
|
|
TileX * CHUNK_SIZE, TileY * CHUNK_SIZE, TileZ * CHUNK_SIZE);
|
|
const FVoxelMeshData TileMesh = World.Mesher->GenerateMesh(
|
|
TileOrigin, 1, CHUNK_SIZE);
|
|
const int32 VertexOffset = FineMesh.Vertices.Num();
|
|
FineMesh.Vertices.Append(TileMesh.Vertices);
|
|
for (const int32 Index : TileMesh.Triangles)
|
|
{
|
|
FineMesh.Triangles.Add(VertexOffset + Index);
|
|
}
|
|
}
|
|
FVoxelMeshData CoarseMesh;
|
|
constexpr int32 Level1TilesPerAxis = static_cast<int32>(
|
|
16.0f / (CHUNK_SIZE * 2 * VOXEL_SIZE / 100.0f));
|
|
for (int32 TileZ = 0; TileZ < Level1TilesPerAxis; ++TileZ)
|
|
for (int32 TileY = 0; TileY < Level1TilesPerAxis; ++TileY)
|
|
for (int32 TileX = 0; TileX < Level1TilesPerAxis; ++TileX)
|
|
{
|
|
const FIntVector TileOrigin = MeshOrigin + FIntVector(
|
|
TileX * CHUNK_SIZE * 2, TileY * CHUNK_SIZE * 2, TileZ * CHUNK_SIZE * 2);
|
|
const FVoxelMeshData TileMesh = World.Mesher->GenerateMesh(
|
|
TileOrigin, 2, CHUNK_SIZE);
|
|
const int32 VertexOffset = CoarseMesh.Vertices.Num();
|
|
CoarseMesh.Vertices.Append(TileMesh.Vertices);
|
|
for (const int32 Index : TileMesh.Triangles)
|
|
{
|
|
CoarseMesh.Triangles.Add(VertexOffset + Index);
|
|
}
|
|
}
|
|
World.Mesher->bGenerateSkirts = bPreviousSkirts;
|
|
Result.Mesh = ComputeStep2fMeshMetrics(FineMesh);
|
|
Result.Lod1VertexCount = CoarseMesh.Vertices.Num();
|
|
TArray<float> LodErrors;
|
|
LodErrors.Reserve(CoarseMesh.Vertices.Num());
|
|
|
|
// L0 triangle bounds are inserted into a one-meter spatial grid. A
|
|
// coarse vertex only needs nearby cells unless its distance is already
|
|
// large enough to fail the LOD gate; expanding the search makes the
|
|
// reported value exact even for badly diverged surfaces.
|
|
constexpr float LodBucketSizeCm = 100.0f;
|
|
TMap<FIntVector, TArray<int32>> FineTriangleBuckets;
|
|
FineTriangleBuckets.Reserve(FineMesh.Triangles.Num());
|
|
for (int32 Triangle = 0; Triangle + 2 < FineMesh.Triangles.Num(); Triangle += 3)
|
|
{
|
|
const int32 IA = FineMesh.Triangles[Triangle];
|
|
const int32 IB = FineMesh.Triangles[Triangle + 1];
|
|
const int32 IC = FineMesh.Triangles[Triangle + 2];
|
|
if (!FineMesh.Vertices.IsValidIndex(IA)
|
|
|| !FineMesh.Vertices.IsValidIndex(IB)
|
|
|| !FineMesh.Vertices.IsValidIndex(IC))
|
|
{
|
|
continue;
|
|
}
|
|
const FVector& A = FineMesh.Vertices[IA];
|
|
const FVector& B = FineMesh.Vertices[IB];
|
|
const FVector& C = FineMesh.Vertices[IC];
|
|
const FVector Min = A.ComponentMin(B).ComponentMin(C);
|
|
const FVector Max = A.ComponentMax(B).ComponentMax(C);
|
|
const FIntVector MinBucket(
|
|
FMath::FloorToInt(Min.X / LodBucketSizeCm),
|
|
FMath::FloorToInt(Min.Y / LodBucketSizeCm),
|
|
FMath::FloorToInt(Min.Z / LodBucketSizeCm));
|
|
const FIntVector MaxBucket(
|
|
FMath::FloorToInt(Max.X / LodBucketSizeCm),
|
|
FMath::FloorToInt(Max.Y / LodBucketSizeCm),
|
|
FMath::FloorToInt(Max.Z / LodBucketSizeCm));
|
|
const int32 TriangleIndex = Triangle / 3;
|
|
for (int32 Z = MinBucket.Z; Z <= MaxBucket.Z; ++Z)
|
|
for (int32 Y = MinBucket.Y; Y <= MaxBucket.Y; ++Y)
|
|
for (int32 X = MinBucket.X; X <= MaxBucket.X; ++X)
|
|
{
|
|
FineTriangleBuckets.FindOrAdd(FIntVector(X, Y, Z)).Add(TriangleIndex);
|
|
}
|
|
}
|
|
for (const FVector& VertexWorldCm : CoarseMesh.Vertices)
|
|
{
|
|
const FIntVector CentreBucket(
|
|
FMath::FloorToInt(VertexWorldCm.X / LodBucketSizeCm),
|
|
FMath::FloorToInt(VertexWorldCm.Y / LodBucketSizeCm),
|
|
FMath::FloorToInt(VertexWorldCm.Z / LodBucketSizeCm));
|
|
float NearestDistanceSquared = MAX_flt;
|
|
TSet<int32> TestedTriangles;
|
|
for (int32 Radius = 1; Radius <= 64; ++Radius)
|
|
{
|
|
for (int32 Z = CentreBucket.Z - Radius; Z <= CentreBucket.Z + Radius; ++Z)
|
|
for (int32 Y = CentreBucket.Y - Radius; Y <= CentreBucket.Y + Radius; ++Y)
|
|
for (int32 X = CentreBucket.X - Radius; X <= CentreBucket.X + Radius; ++X)
|
|
{
|
|
const TArray<int32>* Candidates = FineTriangleBuckets.Find(FIntVector(X, Y, Z));
|
|
if (Candidates == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
for (const int32 TriangleIndex : *Candidates)
|
|
{
|
|
if (TestedTriangles.Contains(TriangleIndex))
|
|
{
|
|
continue;
|
|
}
|
|
TestedTriangles.Add(TriangleIndex);
|
|
const int32 Triangle = TriangleIndex * 3;
|
|
const FVector& A = FineMesh.Vertices[FineMesh.Triangles[Triangle]];
|
|
const FVector& B = FineMesh.Vertices[FineMesh.Triangles[Triangle + 1]];
|
|
const FVector& C = FineMesh.Vertices[FineMesh.Triangles[Triangle + 2]];
|
|
NearestDistanceSquared = FMath::Min(
|
|
NearestDistanceSquared,
|
|
Step2fPointTriangleDistanceSquared(VertexWorldCm, A, B, C));
|
|
}
|
|
}
|
|
const float SearchBoundCm = Radius * LodBucketSizeCm;
|
|
if (NearestDistanceSquared <= FMath::Square(SearchBoundCm))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
const float SurfaceDistanceVoxels = FMath::IsFinite(NearestDistanceSquared)
|
|
? FMath::Sqrt(NearestDistanceSquared) / VOXEL_SIZE
|
|
: MAX_flt;
|
|
LodErrors.Add(SurfaceDistanceVoxels);
|
|
}
|
|
if (!LodErrors.IsEmpty())
|
|
{
|
|
LodErrors.Sort();
|
|
const int32 P95Index = FMath::Clamp(
|
|
FMath::CeilToInt(0.95f * LodErrors.Num()) - 1,
|
|
0, LodErrors.Num() - 1);
|
|
Result.Lod1P95SurfaceErrorVoxels = LodErrors[P95Index];
|
|
}
|
|
|
|
if (World.Manager != nullptr)
|
|
{
|
|
const TArray<FStrateSlot>& Layout = World.Manager->GetLayout();
|
|
constexpr int32 XYStep = static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
constexpr int32 ShellOffsets[] = {
|
|
static_cast<int32>(25.0f / VOXEL_SIZE + 0.5f),
|
|
static_cast<int32>(125.0f / VOXEL_SIZE + 0.5f),
|
|
static_cast<int32>(225.0f / VOXEL_SIZE + 0.5f)};
|
|
for (const FStrateSlot& Slot : Layout)
|
|
{
|
|
if (Slot.Definition == nullptr)
|
|
{
|
|
continue;
|
|
}
|
|
++Result.Structure.StratesChecked;
|
|
const int32 Bottom = FMath::Min(Slot.BottomChunkZ, Slot.TopChunkZ) * CHUNK_SIZE;
|
|
const int32 Top = (FMath::Max(Slot.BottomChunkZ, Slot.TopChunkZ) + 1) * CHUNK_SIZE;
|
|
for (int32 Y = Origin.Y; Y < Origin.Y + Dimension; Y += XYStep)
|
|
for (int32 X = Origin.X; X < Origin.X + Dimension; X += XYStep)
|
|
for (const int32 Offset : ShellOffsets)
|
|
{
|
|
const float FloorDensity = VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
*World.Generator, static_cast<float>(X), static_cast<float>(Y),
|
|
static_cast<float>(Bottom + Offset), nullptr, 1.0f);
|
|
const float RoofDensity = VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
*World.Generator, static_cast<float>(X), static_cast<float>(Y),
|
|
static_cast<float>(Top - 1 - Offset), nullptr, 1.0f);
|
|
++Result.Structure.FloorSamples;
|
|
++Result.Structure.RoofSamples;
|
|
Result.Structure.FloorOpenings += FloorDensity > 0.0f ? 1 : 0;
|
|
Result.Structure.RoofOpenings += RoofDensity > 0.0f ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
return Result;
|
|
};
|
|
|
|
const auto ShapeJson = [](const FStep2fShapeSnapshot& Shape, double MedianMs,
|
|
const FStep2eVarietyMetrics& Variety)
|
|
{
|
|
TSharedRef<FJsonObject> Json = MakeShared<FJsonObject>();
|
|
Json->SetNumberField(TEXT("six_worker_ring_median_ms"), MedianMs);
|
|
Json->SetNumberField(TEXT("solid_fraction"), 1.0 - Variety.OpenFraction);
|
|
Json->SetNumberField(TEXT("air_fraction"), Variety.OpenFraction);
|
|
Json->SetNumberField(TEXT("air_components_12_5cm_grid"),
|
|
Shape.Fragments.AirComponents);
|
|
Json->SetNumberField(TEXT("solid_components_12_5cm_grid"),
|
|
Shape.Fragments.SolidComponents);
|
|
Json->SetNumberField(TEXT("air_crumbs_below_1m3"),
|
|
static_cast<double>(Shape.Fragments.AirCrumbsBelowOneCubicMeter));
|
|
Json->SetNumberField(TEXT("solid_crumbs_below_1m3"),
|
|
static_cast<double>(Shape.Fragments.SolidCrumbsBelowOneCubicMeter));
|
|
Json->SetNumberField(TEXT("air_crumbs_below_1m3_after_boundary_padding"),
|
|
static_cast<double>(Shape.Fragments.AirCrumbsAfterBoundaryPadding >= 0
|
|
? Shape.Fragments.AirCrumbsAfterBoundaryPadding
|
|
: Shape.Fragments.AirCrumbsBelowOneCubicMeter));
|
|
Json->SetNumberField(TEXT("solid_crumbs_below_1m3_after_boundary_padding"),
|
|
static_cast<double>(Shape.Fragments.SolidCrumbsAfterBoundaryPadding >= 0
|
|
? Shape.Fragments.SolidCrumbsAfterBoundaryPadding
|
|
: Shape.Fragments.SolidCrumbsBelowOneCubicMeter));
|
|
Json->SetNumberField(TEXT("air_crumb_voxel_samples"),
|
|
static_cast<double>(Shape.Fragments.AirCrumbVoxelSamples));
|
|
Json->SetNumberField(TEXT("solid_crumb_voxel_samples"),
|
|
static_cast<double>(Shape.Fragments.SolidCrumbVoxelSamples));
|
|
TArray<TSharedPtr<FJsonValue>> CrumbComponents;
|
|
for (const FStep2fFragmentMetrics::FCrumbComponent& Crumb
|
|
: Shape.Fragments.CrumbComponents)
|
|
{
|
|
TSharedRef<FJsonObject> CrumbJson = MakeShared<FJsonObject>();
|
|
CrumbJson->SetStringField(TEXT("sign"), Crumb.bAir ? TEXT("air") : TEXT("solid"));
|
|
CrumbJson->SetNumberField(TEXT("samples"), Crumb.Samples);
|
|
CrumbJson->SetNumberField(TEXT("density_at_min"), Crumb.DensityAtMin);
|
|
CrumbJson->SetArrayField(TEXT("root_ring_voxel"), {
|
|
MakeShared<FJsonValueNumber>(Crumb.Root.X),
|
|
MakeShared<FJsonValueNumber>(Crumb.Root.Y),
|
|
MakeShared<FJsonValueNumber>(Crumb.Root.Z)});
|
|
CrumbJson->SetNumberField(TEXT("density_at_root"), Crumb.DensityAtRoot);
|
|
CrumbJson->SetNumberField(TEXT("sign_mismatch_samples"),
|
|
Crumb.SignMismatchSamples);
|
|
CrumbJson->SetArrayField(TEXT("min_ring_voxel"), {
|
|
MakeShared<FJsonValueNumber>(Crumb.Min.X),
|
|
MakeShared<FJsonValueNumber>(Crumb.Min.Y),
|
|
MakeShared<FJsonValueNumber>(Crumb.Min.Z)});
|
|
CrumbJson->SetArrayField(TEXT("max_ring_voxel"), {
|
|
MakeShared<FJsonValueNumber>(Crumb.Max.X),
|
|
MakeShared<FJsonValueNumber>(Crumb.Max.Y),
|
|
MakeShared<FJsonValueNumber>(Crumb.Max.Z)});
|
|
CrumbComponents.Add(MakeShared<FJsonValueObject>(CrumbJson));
|
|
}
|
|
Json->SetArrayField(TEXT("crumb_components_first_32"), CrumbComponents);
|
|
Json->SetNumberField(TEXT("largest_air_component_voxels"),
|
|
Shape.Fragments.LargestAirComponentVoxels);
|
|
Json->SetNumberField(TEXT("largest_solid_component_voxels"),
|
|
Shape.Fragments.LargestSolidComponentVoxels);
|
|
Json->SetNumberField(TEXT("one_meter_downsampled_air_components"),
|
|
Variety.ConnectedComponentsAtOneMeter);
|
|
Json->SetNumberField(TEXT("pitch_autocorrelation_peak"), Shape.Grid.PitchCorrelationPeak);
|
|
Json->SetNumberField(TEXT("nearby_autocorrelation_peak"), Shape.Grid.NeighborCorrelationPeak);
|
|
Json->SetNumberField(TEXT("pitch_autocorrelation_excess"), Shape.Grid.PitchPeakExcess);
|
|
Json->SetNumberField(TEXT("grid_signature_probe_extent_m"), 64.0);
|
|
Json->SetNumberField(TEXT("grid_signature_probe_spacing_m"), 1.0);
|
|
Json->SetNumberField(TEXT("grid_signature_probe_height_layers"), 9);
|
|
Json->SetNumberField(TEXT("detail_rms"), Shape.Detail.Rms);
|
|
Json->SetNumberField(TEXT("detail_lattice_scale_energy_ratio"),
|
|
Shape.Detail.LatticeScaleEnergyRatio);
|
|
Json->SetNumberField(TEXT("mesh_triangles_l0"), Shape.Mesh.TriangleCount);
|
|
Json->SetNumberField(TEXT("mesh_manifold_edges_l0"),
|
|
static_cast<double>(Shape.Mesh.ManifoldEdgeCount));
|
|
Json->SetNumberField(TEXT("mesh_dihedral_share_over_45deg"),
|
|
Shape.Mesh.ShareDihedralsOver45Degrees);
|
|
Json->SetNumberField(TEXT("mesh_dihedral_p95_deg"), Shape.Mesh.P95DihedralDegrees);
|
|
Json->SetNumberField(TEXT("mesh_vertices_l1"), Shape.Lod1VertexCount);
|
|
Json->SetNumberField(TEXT("lod1_p95_surface_distance_voxels"),
|
|
Shape.Lod1P95SurfaceErrorVoxels);
|
|
Json->SetNumberField(TEXT("strates_checked"), Shape.Structure.StratesChecked);
|
|
Json->SetNumberField(TEXT("roof_samples"), static_cast<double>(Shape.Structure.RoofSamples));
|
|
Json->SetNumberField(TEXT("roof_openings"), static_cast<double>(Shape.Structure.RoofOpenings));
|
|
Json->SetNumberField(TEXT("floor_samples"), static_cast<double>(Shape.Structure.FloorSamples));
|
|
Json->SetNumberField(TEXT("floor_openings"), static_cast<double>(Shape.Structure.FloorOpenings));
|
|
Json->SetNumberField(TEXT("estimated_surface_area_m2"), Variety.SurfaceAreaSquareMeters);
|
|
Json->SetNumberField(TEXT("open_air_fraction"), Variety.OpenFraction);
|
|
return Json;
|
|
};
|
|
|
|
constexpr int32 CrumbBoundaryPadding = 16 * static_cast<int32>(100.0f / VOXEL_SIZE + 0.5f);
|
|
const int32 PaddedCrumbDimension = Dimension + 2 * CrumbBoundaryPadding;
|
|
const FIntVector PaddedCrumbOrigin = Origin
|
|
- FIntVector(CrumbBoundaryPadding, CrumbBoundaryPadding, CrumbBoundaryPadding);
|
|
const auto CountBoundaryPaddedCrumbs = [&](bool bLegacyField,
|
|
int64& OutAirCrumbs, int64& OutSolidCrumbs,
|
|
TArray<FStep2fFragmentMetrics::FCrumbComponent>* OutCrumbComponents = nullptr)
|
|
{
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(bLegacyField);
|
|
TArray<float> PaddedDensity;
|
|
VoxelForgeRegionEval::FTiming PaddedTiming;
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, PaddedCrumbOrigin, PaddedCrumbDimension,
|
|
Workers, 1.0f, PaddedDensity, PaddedTiming, false, true))
|
|
{
|
|
return false;
|
|
}
|
|
const FStep2fFragmentMetrics PaddedFragments = ComputeStep2fFragmentMetrics(
|
|
PaddedDensity, PaddedCrumbDimension,
|
|
CrumbBoundaryPadding, Dimension);
|
|
OutAirCrumbs = PaddedFragments.AirCrumbsBelowOneCubicMeter;
|
|
OutSolidCrumbs = PaddedFragments.SolidCrumbsBelowOneCubicMeter;
|
|
if (OutCrumbComponents != nullptr)
|
|
{
|
|
*OutCrumbComponents = PaddedFragments.CrumbComponents;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
for (int32 Profile = 0;
|
|
Profile < VoxelForgeRedefinedDensityField::GetParameterSetCount(); ++Profile)
|
|
{
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(Profile);
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(true);
|
|
const TCHAR* ProfileName = nullptr;
|
|
VoxelForgeRedefinedDensityField::FGenerationParameters LegacyParameters;
|
|
if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo(
|
|
Profile, ProfileName, LegacyParameters))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] missing legacy profile %d."), Profile);
|
|
return 2;
|
|
}
|
|
TArray<float> LegacyDensity;
|
|
TArray<double> LegacyTimes;
|
|
VoxelForgeRegionEval::FTiming LegacyLastTiming;
|
|
for (int32 Trial = 0; Trial < 3; ++Trial)
|
|
{
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
LegacyDensity, LegacyLastTiming, true, true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep2f] profile %d step 2e ring trial %d failed."),
|
|
Profile, Trial + 1);
|
|
return 2;
|
|
}
|
|
LegacyTimes.Add(LegacyLastTiming.WallSeconds * 1000.0);
|
|
}
|
|
LegacyTimes.Sort();
|
|
const double LegacyMedianMs = LegacyTimes[LegacyTimes.Num() / 2];
|
|
FStep2fShapeSnapshot LegacyShape = MeasureShape(
|
|
LegacyDensity, LegacyParameters);
|
|
const FStep2eVarietyMetrics LegacyVariety = LegacyShape.Variety;
|
|
if (!CountBoundaryPaddedCrumbs(true,
|
|
LegacyShape.Fragments.AirCrumbsAfterBoundaryPadding,
|
|
LegacyShape.Fragments.SolidCrumbsAfterBoundaryPadding))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep2f] profile %d padded step 2e crumb pass failed."), Profile);
|
|
return 2;
|
|
}
|
|
WorstStep2eMs = FMath::Max(WorstStep2eMs, LegacyMedianMs);
|
|
const int64 LegacyCrumbs = LegacyShape.Fragments.AirCrumbsAfterBoundaryPadding
|
|
+ LegacyShape.Fragments.SolidCrumbsAfterBoundaryPadding;
|
|
WorstStep2eCrumbs = FMath::Max(WorstStep2eCrumbs, LegacyCrumbs);
|
|
bStep2eCrumbsRejected &= LegacyCrumbs > 0;
|
|
WorstStep2eDihedralShare = FMath::Max(
|
|
WorstStep2eDihedralShare, LegacyShape.Mesh.ShareDihedralsOver45Degrees);
|
|
WorstStep2eDihedralP95 = FMath::Max(
|
|
WorstStep2eDihedralP95, LegacyShape.Mesh.P95DihedralDegrees);
|
|
bStep2eFlakesRejected |= LegacyShape.Mesh.ShareDihedralsOver45Degrees > 0.25
|
|
|| LegacyShape.Mesh.P95DihedralDegrees > 70.0;
|
|
WorstStep2eDetailEnergy = FMath::Max(
|
|
WorstStep2eDetailEnergy, LegacyShape.Detail.LatticeScaleEnergyRatio);
|
|
BestStep2eDetailRms = FMath::Min(BestStep2eDetailRms, LegacyShape.Detail.Rms);
|
|
bStep2eBandLimitRejected &= LegacyShape.Detail.LatticeScaleEnergyRatio > 0.20;
|
|
WorstStep2eLodDistance = FMath::Max(
|
|
WorstStep2eLodDistance, LegacyShape.Lod1P95SurfaceErrorVoxels);
|
|
bStep2eLodRejected |= LegacyShape.Lod1P95SurfaceErrorVoxels > 1.0;
|
|
WorstStep2ePitchCorrelation = FMath::Max(
|
|
WorstStep2ePitchCorrelation, LegacyShape.Grid.PitchCorrelationPeak);
|
|
WorstStep2ePitchExcess = FMath::Max(
|
|
WorstStep2ePitchExcess, LegacyShape.Grid.PitchPeakExcess);
|
|
bStep2eGridRejected |= LegacyShape.Grid.PitchCorrelationPeak > 0.22
|
|
|| LegacyShape.Grid.PitchPeakExcess > 0.08;
|
|
const double LegacySolidFraction = 1.0 - LegacyVariety.OpenFraction;
|
|
LowestStep2eSolidFraction = FMath::Min(LowestStep2eSolidFraction, LegacySolidFraction);
|
|
WorstStep2eRoofOpenings = FMath::Max(
|
|
WorstStep2eRoofOpenings, LegacyShape.Structure.RoofOpenings);
|
|
WorstStep2eFloorOpenings = FMath::Max(
|
|
WorstStep2eFloorOpenings, LegacyShape.Structure.FloorOpenings);
|
|
bStep2eStructureRejected &=
|
|
LegacySolidFraction < 0.25
|
|
|| LegacyShape.Structure.RoofOpenings > 0
|
|
|| LegacyShape.Structure.FloorOpenings > 0;
|
|
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false);
|
|
VoxelForgeRedefinedDensityField::FGenerationParameters NewParameters;
|
|
if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo(
|
|
Profile, ProfileName, NewParameters))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] missing new profile %d."), Profile);
|
|
return 2;
|
|
}
|
|
TArray<float> NewDensity;
|
|
TArray<double> NewTimes;
|
|
VoxelForgeRegionEval::FTiming NewLastTiming;
|
|
for (int32 Trial = 0; Trial < 3; ++Trial)
|
|
{
|
|
if (!VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
NewDensity, NewLastTiming, true, true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep2f] profile %d step 2f ring trial %d failed."),
|
|
Profile, Trial + 1);
|
|
return 2;
|
|
}
|
|
NewTimes.Add(NewLastTiming.WallSeconds * 1000.0);
|
|
}
|
|
NewTimes.Sort();
|
|
const double NewMedianMs = NewTimes[NewTimes.Num() / 2];
|
|
FStep2fShapeSnapshot NewShape = MeasureShape(NewDensity, NewParameters);
|
|
const FStep2eVarietyMetrics NewVariety = NewShape.Variety;
|
|
TArray<FStep2fFragmentMetrics::FCrumbComponent> NewPaddedCrumbComponents;
|
|
if (!CountBoundaryPaddedCrumbs(false,
|
|
NewShape.Fragments.AirCrumbsAfterBoundaryPadding,
|
|
NewShape.Fragments.SolidCrumbsAfterBoundaryPadding,
|
|
&NewPaddedCrumbComponents))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeStep2f] profile %d padded step 2f crumb pass failed."), Profile);
|
|
return 2;
|
|
}
|
|
WorstStep2fMs = FMath::Max(WorstStep2fMs, NewMedianMs);
|
|
const double NewSolidFraction = 1.0 - NewVariety.OpenFraction;
|
|
const int64 NewCrumbs = NewShape.Fragments.AirCrumbsAfterBoundaryPadding
|
|
+ NewShape.Fragments.SolidCrumbsAfterBoundaryPadding;
|
|
WorstStep2fCrumbs = FMath::Max(WorstStep2fCrumbs, NewCrumbs);
|
|
WorstStep2fDihedralShare = FMath::Max(
|
|
WorstStep2fDihedralShare, NewShape.Mesh.ShareDihedralsOver45Degrees);
|
|
WorstStep2fDihedralP95 = FMath::Max(
|
|
WorstStep2fDihedralP95, NewShape.Mesh.P95DihedralDegrees);
|
|
WorstStep2fDetailEnergy = FMath::Max(
|
|
WorstStep2fDetailEnergy, NewShape.Detail.LatticeScaleEnergyRatio);
|
|
BestStep2fDetailRms = FMath::Min(BestStep2fDetailRms, NewShape.Detail.Rms);
|
|
WorstStep2fLodDistance = FMath::Max(
|
|
WorstStep2fLodDistance, NewShape.Lod1P95SurfaceErrorVoxels);
|
|
WorstStep2fPitchCorrelation = FMath::Max(
|
|
WorstStep2fPitchCorrelation, NewShape.Grid.PitchCorrelationPeak);
|
|
WorstStep2fPitchExcess = FMath::Max(
|
|
WorstStep2fPitchExcess, NewShape.Grid.PitchPeakExcess);
|
|
LowestStep2fSolidFraction = FMath::Min(LowestStep2fSolidFraction, NewSolidFraction);
|
|
WorstStep2fRoofOpenings = FMath::Max(
|
|
WorstStep2fRoofOpenings, NewShape.Structure.RoofOpenings);
|
|
WorstStep2fFloorOpenings = FMath::Max(
|
|
WorstStep2fFloorOpenings, NewShape.Structure.FloorOpenings);
|
|
WorstStep2fSolidFraction = FMath::Max(WorstStep2fSolidFraction, NewSolidFraction);
|
|
BestStep2fSolidFraction = FMath::Min(BestStep2fSolidFraction, NewSolidFraction);
|
|
bStep2fCrumbGatePassed &= NewShape.Fragments.AirCrumbsAfterBoundaryPadding == 0
|
|
&& NewShape.Fragments.SolidCrumbsAfterBoundaryPadding == 0;
|
|
bStep2fFlakeGatePassed &= NewShape.Mesh.TriangleCount > 0
|
|
&& NewShape.Mesh.ManifoldEdgeCount > 0
|
|
&& NewShape.Mesh.ShareDihedralsOver45Degrees <= 0.25
|
|
&& NewShape.Mesh.P95DihedralDegrees <= 70.0;
|
|
bStep2fBandLimitPassed &= NewShape.Detail.LatticeScaleEnergyRatio <= 0.20
|
|
&& NewShape.Detail.Rms >= 0.80;
|
|
bStep2fLodPassed &= NewShape.Lod1VertexCount > 0
|
|
&& NewShape.Lod1P95SurfaceErrorVoxels <= 1.0;
|
|
bStep2fGridPassed &= NewShape.Grid.PitchCorrelationPeak <= 0.40
|
|
&& NewShape.Grid.PitchPeakExcess <= 0.08;
|
|
bStep2fStructurePassed &= NewSolidFraction >= 0.25 && NewSolidFraction <= 0.88
|
|
&& NewShape.Structure.StratesChecked > 0
|
|
&& NewShape.Structure.RoofSamples > 0
|
|
&& NewShape.Structure.FloorSamples > 0
|
|
&& NewShape.Structure.RoofOpenings == 0
|
|
&& NewShape.Structure.FloorOpenings == 0;
|
|
|
|
TSharedRef<FJsonObject> Row = MakeShared<FJsonObject>();
|
|
Row->SetNumberField(TEXT("profile_index"), Profile);
|
|
Row->SetStringField(TEXT("name"), ProfileName);
|
|
Row->SetObjectField(TEXT("step2e"), ShapeJson(
|
|
LegacyShape, LegacyMedianMs, LegacyVariety));
|
|
Row->SetObjectField(TEXT("step2f"), ShapeJson(
|
|
NewShape, NewMedianMs, NewVariety));
|
|
TArray<TSharedPtr<FJsonValue>> PaddedCrumbDiagnostics;
|
|
for (const FStep2fFragmentMetrics::FCrumbComponent& Crumb
|
|
: NewPaddedCrumbComponents)
|
|
{
|
|
// Root is an actual sample in the connected component. The min/max coordinates are
|
|
// an axis-aligned bound and their corner may not belong to the component.
|
|
const FIntVector SampleVoxel = PaddedCrumbOrigin + Crumb.Root;
|
|
const float SampleX = static_cast<float>(SampleVoxel.X);
|
|
const float SampleY = static_cast<float>(SampleVoxel.Y);
|
|
const float SampleZ = static_cast<float>(SampleVoxel.Z);
|
|
TSharedRef<FJsonObject> CrumbJson = MakeShared<FJsonObject>();
|
|
CrumbJson->SetStringField(TEXT("sign"), Crumb.bAir ? TEXT("air") : TEXT("solid"));
|
|
CrumbJson->SetNumberField(TEXT("samples"), Crumb.Samples);
|
|
CrumbJson->SetNumberField(TEXT("density_from_padded_array_at_min"),
|
|
Crumb.DensityAtMin);
|
|
CrumbJson->SetNumberField(TEXT("density_at_root"), Crumb.DensityAtRoot);
|
|
CrumbJson->SetNumberField(TEXT("sign_mismatch_samples"),
|
|
Crumb.SignMismatchSamples);
|
|
CrumbJson->SetArrayField(TEXT("root_world_voxel"), {
|
|
MakeShared<FJsonValueNumber>(PaddedCrumbOrigin.X + Crumb.Root.X),
|
|
MakeShared<FJsonValueNumber>(PaddedCrumbOrigin.Y + Crumb.Root.Y),
|
|
MakeShared<FJsonValueNumber>(PaddedCrumbOrigin.Z + Crumb.Root.Z)});
|
|
CrumbJson->SetArrayField(TEXT("min_world_voxel"), {
|
|
MakeShared<FJsonValueNumber>(SampleVoxel.X),
|
|
MakeShared<FJsonValueNumber>(SampleVoxel.Y),
|
|
MakeShared<FJsonValueNumber>(SampleVoxel.Z)});
|
|
CrumbJson->SetArrayField(TEXT("max_world_voxel"), {
|
|
MakeShared<FJsonValueNumber>(PaddedCrumbOrigin.X + Crumb.Max.X),
|
|
MakeShared<FJsonValueNumber>(PaddedCrumbOrigin.Y + Crumb.Max.Y),
|
|
MakeShared<FJsonValueNumber>(PaddedCrumbOrigin.Z + Crumb.Max.Z)});
|
|
CrumbJson->SetNumberField(TEXT("final_density_at_min"),
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
*World.Generator, SampleX, SampleY, SampleZ));
|
|
CrumbJson->SetNumberField(TEXT("macro_at_min"),
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedMacroOnly(
|
|
*World.Generator, SampleX, SampleY, SampleZ));
|
|
CrumbJson->SetNumberField(TEXT("detail_at_min"),
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedDetail(
|
|
*World.Generator, SampleX, SampleY, SampleZ));
|
|
CrumbJson->SetStringField(TEXT("macro_description_at_min"),
|
|
VoxelForgeRedefinedDensityField::DescribeParameterizedMacroAt(
|
|
*World.Generator, SampleX, SampleY, SampleZ));
|
|
PaddedCrumbDiagnostics.Add(MakeShared<FJsonValueObject>(CrumbJson));
|
|
}
|
|
Row->SetArrayField(TEXT("step2f_padded_crumb_diagnostics"), PaddedCrumbDiagnostics);
|
|
Row->SetObjectField(TEXT("step2e_parameters"), Step2eParametersJson(LegacyParameters));
|
|
Row->SetObjectField(TEXT("step2f_parameters"), Step2eParametersJson(NewParameters));
|
|
TArray<TSharedPtr<FJsonValue>> LegacyTrialValues;
|
|
TArray<TSharedPtr<FJsonValue>> NewTrialValues;
|
|
for (const double Time : LegacyTimes)
|
|
{
|
|
LegacyTrialValues.Add(MakeShared<FJsonValueNumber>(Time));
|
|
}
|
|
for (const double Time : NewTimes)
|
|
{
|
|
NewTrialValues.Add(MakeShared<FJsonValueNumber>(Time));
|
|
}
|
|
Row->SetArrayField(TEXT("step2e_six_worker_ring_trials_ms"), LegacyTrialValues);
|
|
Row->SetArrayField(TEXT("step2f_six_worker_ring_trials_ms"), NewTrialValues);
|
|
TSharedRef<FJsonObject> NewTimingBreakdown = MakeShared<FJsonObject>();
|
|
NewTimingBreakdown->SetNumberField(TEXT("last_trial_wall_ms"),
|
|
NewLastTiming.WallSeconds * 1000.0);
|
|
NewTimingBreakdown->SetNumberField(TEXT("last_trial_worker_cpu_seconds"),
|
|
NewLastTiming.WorkerCpuSeconds);
|
|
NewTimingBreakdown->SetNumberField(TEXT("sum_worker_brick_classification_seconds"),
|
|
NewLastTiming.BrickClassificationSeconds);
|
|
NewTimingBreakdown->SetNumberField(TEXT("sum_worker_density_evaluation_seconds"),
|
|
NewLastTiming.DensityEvaluationSeconds);
|
|
NewTimingBreakdown->SetNumberField(TEXT("sum_worker_uniform_fill_seconds"),
|
|
NewLastTiming.UniformFillSeconds);
|
|
NewTimingBreakdown->SetNumberField(TEXT("longest_worker_wall_seconds"),
|
|
NewLastTiming.LongestWorkerWallSeconds);
|
|
NewTimingBreakdown->SetNumberField(TEXT("evaluated_samples"),
|
|
static_cast<double>(NewLastTiming.EvaluatedSamples));
|
|
NewTimingBreakdown->SetNumberField(TEXT("skipped_samples"),
|
|
static_cast<double>(NewLastTiming.SkippedSamples));
|
|
NewTimingBreakdown->SetNumberField(TEXT("macro_centre_evaluations"),
|
|
static_cast<double>(NewLastTiming.MacroCentreEvaluations));
|
|
Row->SetObjectField(TEXT("step2f_last_trial_timing_breakdown"), NewTimingBreakdown);
|
|
FString SafeName(ProfileName);
|
|
SafeName.ToLowerInline();
|
|
const FString SetName = FString::Printf(TEXT("set_%02d_%s"), Profile, *SafeName);
|
|
const FString SetDirectory = FPaths::Combine(ResearchOut, SetName);
|
|
IFileManager::Get().MakeDirectory(*SetDirectory, true);
|
|
const FString ImageNames[] = {
|
|
TEXT("slice_xy_2e_vs_2f.png"), TEXT("slice_xz_2e_vs_2f.png"),
|
|
TEXT("slice_yz_2e_vs_2f.png"), TEXT("slice_xz_passage_2e_vs_2f.png")};
|
|
FString SliceError;
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[0]),
|
|
LegacyDensity, NewDensity, Dimension, TEXT('h'), Dimension / 2, SliceError);
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[1]),
|
|
LegacyDensity, NewDensity, Dimension, TEXT('x'), Dimension / 2, SliceError);
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[2]),
|
|
LegacyDensity, NewDensity, Dimension, TEXT('y'), Dimension / 2, SliceError);
|
|
int32 PassageIndex = Dimension / 2;
|
|
if (World.Manager != nullptr && !World.Manager->GetPassages().IsEmpty())
|
|
{
|
|
PassageIndex = FMath::Clamp(FMath::RoundToInt(
|
|
World.Manager->GetPassages()[0].LowerLanding.StandingPoint.Y) - Origin.Y,
|
|
0, Dimension - 1);
|
|
}
|
|
SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[3]),
|
|
LegacyDensity, NewDensity, Dimension, TEXT('x'), PassageIndex, SliceError);
|
|
if (!SliceError.IsEmpty())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] slice write failed for %s: %s"),
|
|
ProfileName, *SliceError);
|
|
return 2;
|
|
}
|
|
Row->SetStringField(TEXT("slice_directory"), SetDirectory);
|
|
ProfileRows.Add(MakeShared<FJsonValueObject>(Row));
|
|
for (const FString& ImageName : ImageNames)
|
|
{
|
|
TSharedRef<FJsonObject> Slice = MakeShared<FJsonObject>();
|
|
Slice->SetStringField(TEXT("profile"), ProfileName);
|
|
Slice->SetStringField(TEXT("file"), FPaths::Combine(SetDirectory, ImageName));
|
|
Slice->SetStringField(TEXT("layout"),
|
|
TEXT("step 2e density on left; step 2f density on right; light=air, dark=solid, cyan=near zero"));
|
|
SliceRows.Add(MakeShared<FJsonValueObject>(Slice));
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep2f] profile=%d/%s old_ms=%.2f new_ms=%.2f "
|
|
"solid=%.3f crumbs=%lld/%lld flakes=%.3f/%.3f grid=%.3f/%.3f "
|
|
"detail=%.3f lod95=%.2f roof=%lld floor=%lld"),
|
|
Profile, ProfileName, LegacyMedianMs, NewMedianMs, NewSolidFraction,
|
|
static_cast<long long>(NewShape.Fragments.AirCrumbsAfterBoundaryPadding),
|
|
static_cast<long long>(NewShape.Fragments.SolidCrumbsAfterBoundaryPadding),
|
|
NewShape.Mesh.ShareDihedralsOver45Degrees, NewShape.Detail.LatticeScaleEnergyRatio,
|
|
NewShape.Grid.PitchCorrelationPeak, NewShape.Grid.PitchPeakExcess,
|
|
NewShape.Detail.Rms, NewShape.Lod1P95SurfaceErrorVoxels,
|
|
static_cast<long long>(NewShape.Structure.RoofOpenings),
|
|
static_cast<long long>(NewShape.Structure.FloorOpenings));
|
|
}
|
|
|
|
const int32 HardestProfile = VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1;
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false);
|
|
VoxelForgeRedefinedDensityField::SetParameterSet(HardestProfile);
|
|
TArray<float> HardestFullDensity;
|
|
TArray<float> RepeatDensity;
|
|
TArray<float> OneWorkerDensity;
|
|
VoxelForgeRegionEval::FTiming HardestTiming;
|
|
VoxelForgeRegionEval::FTiming RepeatTiming;
|
|
VoxelForgeRegionEval::FTiming OneWorkerTiming;
|
|
const bool bHardestFullOk = VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
HardestFullDensity, HardestTiming, false, true);
|
|
const bool bRepeatOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, Workers, 1.0f,
|
|
RepeatDensity, RepeatTiming, false, true);
|
|
const bool bOneWorkerOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined(
|
|
*World.Generator, Origin, Dimension, 1, 1.0f,
|
|
OneWorkerDensity, OneWorkerTiming, false, true);
|
|
if (!bHardestFullOk || !bRepeatOk || !bOneWorkerOk)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] determinism ring failed."));
|
|
return 2;
|
|
}
|
|
const uint64 HardestHash = VoxelForgeRegionEval::HashQuantized(HardestFullDensity);
|
|
const uint64 RepeatHash = VoxelForgeRegionEval::HashQuantized(RepeatDensity);
|
|
const uint64 OneWorkerHash = VoxelForgeRegionEval::HashQuantized(OneWorkerDensity);
|
|
const bool bDeterminism = HardestHash == RepeatHash && HardestHash == OneWorkerHash
|
|
&& Step2eSameWorkerRun(HardestTiming, RepeatTiming)
|
|
&& Step2eSameWorkerRun(HardestTiming, OneWorkerTiming);
|
|
|
|
FIntVector BrickParityOrigin(-28, -28,
|
|
FMath::FloorToInt(static_cast<float>(CenterZ) / CHUNK_SIZE) * CHUNK_SIZE + 4);
|
|
const FIntVector BrickParitySize(
|
|
VoxelForgeRedefinedDensityField::ParameterizedBrickSamples,
|
|
VoxelForgeRedefinedDensityField::ParameterizedBrickSamples, 16);
|
|
VoxelForgeRedefinedDensityField::FParameterizedBrickContext BrickParityContext;
|
|
TArray<float> BrickParityDetail;
|
|
TArray<float> BrickParityColumn;
|
|
TArray<float> BrickParityValues;
|
|
VoxelForgeRedefinedDensityField::EvaluateParameterizedDensityBrick(
|
|
*World.Generator, BrickParityOrigin, BrickParitySize, 1.0f,
|
|
BrickParityContext, BrickParityDetail, BrickParityColumn, BrickParityValues);
|
|
int64 BrickScalarMismatchCount = 0;
|
|
for (int32 Z = 0; Z < BrickParitySize.Z; ++Z)
|
|
for (int32 Y = 0; Y < BrickParitySize.Y; ++Y)
|
|
for (int32 X = 0; X < BrickParitySize.X; ++X)
|
|
{
|
|
const float ScalarValue = VoxelForgeRedefinedDensityField::EvaluateParameterized(
|
|
*World.Generator, static_cast<float>(BrickParityOrigin.X + X),
|
|
static_cast<float>(BrickParityOrigin.Y + Y),
|
|
static_cast<float>(BrickParityOrigin.Z + Z));
|
|
const float BrickValue = BrickParityValues.IsValidIndex(
|
|
(Z * BrickParitySize.Y + Y) * BrickParitySize.X + X)
|
|
? BrickParityValues[(Z * BrickParitySize.Y + Y) * BrickParitySize.X + X]
|
|
: FLT_MAX;
|
|
BrickScalarMismatchCount += FMemory::Memcmp(
|
|
&ScalarValue, &BrickValue, sizeof(float)) == 0 ? 0 : 1;
|
|
}
|
|
const bool bParameterizedBrickScalarParity =
|
|
BrickParityValues.Num() == BrickParitySize.X * BrickParitySize.Y * BrickParitySize.Z
|
|
&& BrickScalarMismatchCount == 0;
|
|
|
|
const FStep2dBrickProofAudit ProofAudit = AuditStep2dBrickProofs(
|
|
*World.Generator, Origin, Dimension, true);
|
|
const bool bBoundAudit = ProofAudit.CorrectlySkippedBricksAudited > 0
|
|
&& ProofAudit.CorrectProofSamplesChecked > 0
|
|
&& ProofAudit.CorrectProofViolations == 0;
|
|
const bool bUndersizedBoundCaught = ProofAudit.DeliberateBoundDisagreements > 0;
|
|
const FStep2dBoundaryStats BoundaryStats = ComputeStep2dBoundaryStats(
|
|
HardestFullDensity, Dimension, Origin, 128);
|
|
TArray<float> SeamFailureDensity = HardestFullDensity;
|
|
int64 DeliberateSeamSamplesMutated = 0;
|
|
for (int32 Z = 1; Z < Dimension - 1; ++Z)
|
|
for (int32 Y = 1; Y < Dimension - 1; ++Y)
|
|
for (int32 X = 1; X < Dimension - 1; ++X)
|
|
{
|
|
const int32 Index = (Z * Dimension + Y) * Dimension + X;
|
|
if (((Origin.X + X) % 8) == 0 && FMath::Abs(HardestFullDensity[Index]) <= 12.0f)
|
|
{
|
|
SeamFailureDensity[Index + 1] += 64.0f;
|
|
++DeliberateSeamSamplesMutated;
|
|
}
|
|
}
|
|
const FStep2dBoundaryStats SeamFailureStats = ComputeStep2dBoundaryStats(
|
|
SeamFailureDensity, Dimension, Origin, 128);
|
|
const bool bBoundaryPass = Step2eBoundaryStatsPass(BoundaryStats);
|
|
const bool bDeliberateSeamCaught = DeliberateSeamSamplesMutated > 0
|
|
&& !Step2eBoundaryStatsPass(SeamFailureStats);
|
|
|
|
const bool bAllGeometryGatesRejectStep2e = bStep2eCrumbsRejected
|
|
&& bStep2eFlakesRejected && bStep2eBandLimitRejected && bStep2eLodRejected
|
|
&& bStep2eGridRejected && bStep2eStructureRejected;
|
|
const bool bAllGeometryGatesPassStep2f = bStep2fCrumbGatePassed
|
|
&& bStep2fFlakeGatePassed && bStep2fBandLimitPassed && bStep2fLodPassed
|
|
&& bStep2fGridPassed && bStep2fStructurePassed;
|
|
const bool bSpeedPass = Workers == 6 && WorstStep2fMs <= 600.0;
|
|
const bool bPassed = bAllGeometryGatesRejectStep2e && bAllGeometryGatesPassStep2f
|
|
&& bSpeedPass && bDeterminism && bParameterizedBrickScalarParity
|
|
&& bBoundAudit && bUndersizedBoundCaught
|
|
&& bBoundaryPass && bDeliberateSeamCaught && HardestTiming.NonFiniteSamples == 0;
|
|
|
|
TSharedRef<FJsonObject> Gates = MakeShared<FJsonObject>();
|
|
const auto AddGate = [&Gates](const TCHAR* Name, const TCHAR* Threshold,
|
|
bool bLegacyRejected, bool bNewPassed,
|
|
const TSharedRef<FJsonObject>& LegacyNumbers,
|
|
const TSharedRef<FJsonObject>& NewNumbers)
|
|
{
|
|
TSharedRef<FJsonObject> Gate = MakeShared<FJsonObject>();
|
|
Gate->SetStringField(TEXT("threshold"), Threshold);
|
|
Gate->SetBoolField(TEXT("step2e_rejected"), bLegacyRejected);
|
|
Gate->SetBoolField(TEXT("step2f_passed"), bNewPassed);
|
|
Gate->SetObjectField(TEXT("step2e_worst_profile_numbers"), LegacyNumbers);
|
|
Gate->SetObjectField(TEXT("step2f_worst_profile_numbers"), NewNumbers);
|
|
Gates->SetObjectField(Name, Gate);
|
|
};
|
|
TSharedRef<FJsonObject> Step2eCrumbNumbers = MakeShared<FJsonObject>();
|
|
Step2eCrumbNumbers->SetNumberField(TEXT("crumbs_below_1m3_max"), WorstStep2eCrumbs);
|
|
TSharedRef<FJsonObject> Step2fCrumbNumbers = MakeShared<FJsonObject>();
|
|
Step2fCrumbNumbers->SetNumberField(TEXT("crumbs_below_1m3_max"), WorstStep2fCrumbs);
|
|
AddGate(TEXT("crumbs"), TEXT("zero 12.5 cm-grid components smaller than 512 voxels (1 m3), measured in a 128-sample-padded volume and restricted to components intersecting the 259-cube"),
|
|
bStep2eCrumbsRejected, bStep2fCrumbGatePassed, Step2eCrumbNumbers, Step2fCrumbNumbers);
|
|
TSharedRef<FJsonObject> Step2eFlakeNumbers = MakeShared<FJsonObject>();
|
|
Step2eFlakeNumbers->SetNumberField(TEXT("max_dihedral_share_over_45deg"), WorstStep2eDihedralShare);
|
|
Step2eFlakeNumbers->SetNumberField(TEXT("max_dihedral_p95_deg"), WorstStep2eDihedralP95);
|
|
TSharedRef<FJsonObject> Step2fFlakeNumbers = MakeShared<FJsonObject>();
|
|
Step2fFlakeNumbers->SetNumberField(TEXT("max_dihedral_share_over_45deg"), WorstStep2fDihedralShare);
|
|
Step2fFlakeNumbers->SetNumberField(TEXT("max_dihedral_p95_deg"), WorstStep2fDihedralP95);
|
|
AddGate(TEXT("flakes"), TEXT("L0 mesh dihedral share over 45 degrees <= 0.25; p95 <= 70 degrees"),
|
|
bStep2eFlakesRejected, bStep2fFlakeGatePassed, Step2eFlakeNumbers, Step2fFlakeNumbers);
|
|
TSharedRef<FJsonObject> Step2eDetailNumbers = MakeShared<FJsonObject>();
|
|
Step2eDetailNumbers->SetNumberField(TEXT("max_lattice_scale_energy_ratio"), WorstStep2eDetailEnergy);
|
|
Step2eDetailNumbers->SetNumberField(TEXT("min_detail_rms"), BestStep2eDetailRms);
|
|
TSharedRef<FJsonObject> Step2fDetailNumbers = MakeShared<FJsonObject>();
|
|
Step2fDetailNumbers->SetNumberField(TEXT("max_lattice_scale_energy_ratio"), WorstStep2fDetailEnergy);
|
|
Step2fDetailNumbers->SetNumberField(TEXT("min_detail_rms"), BestStep2fDetailRms);
|
|
AddGate(TEXT("band_limited_detail"), TEXT("detail RMS >= 0.80 voxel units; normalized 25 cm difference energy <= 0.20"),
|
|
bStep2eBandLimitRejected, bStep2fBandLimitPassed, Step2eDetailNumbers, Step2fDetailNumbers);
|
|
TSharedRef<FJsonObject> Step2eLodNumbers = MakeShared<FJsonObject>();
|
|
Step2eLodNumbers->SetNumberField(TEXT("max_l1_p95_surface_distance_voxels"), WorstStep2eLodDistance);
|
|
TSharedRef<FJsonObject> Step2fLodNumbers = MakeShared<FJsonObject>();
|
|
Step2fLodNumbers->SetNumberField(TEXT("max_l1_p95_surface_distance_voxels"), WorstStep2fLodDistance);
|
|
AddGate(TEXT("lod_stability"), TEXT("L1 mesh p95 estimated distance from the L0 isosurface <= 1 lattice sample (12.5 cm)"),
|
|
bStep2eLodRejected, bStep2fLodPassed, Step2eLodNumbers, Step2fLodNumbers);
|
|
TSharedRef<FJsonObject> Step2eGridNumbers = MakeShared<FJsonObject>();
|
|
Step2eGridNumbers->SetNumberField(TEXT("max_pitch_autocorrelation"), WorstStep2ePitchCorrelation);
|
|
Step2eGridNumbers->SetNumberField(TEXT("max_pitch_peak_excess"), WorstStep2ePitchExcess);
|
|
TSharedRef<FJsonObject> Step2fGridNumbers = MakeShared<FJsonObject>();
|
|
Step2fGridNumbers->SetNumberField(TEXT("max_pitch_autocorrelation"), WorstStep2fPitchCorrelation);
|
|
Step2fGridNumbers->SetNumberField(TEXT("max_pitch_peak_excess"), WorstStep2fPitchExcess);
|
|
AddGate(TEXT("grid_signature"), TEXT("pitch autocorrelation <= 0.40 and pitch excess over nearby lags <= 0.08"),
|
|
bStep2eGridRejected, bStep2fGridPassed, Step2eGridNumbers, Step2fGridNumbers);
|
|
TSharedRef<FJsonObject> Step2eStructureNumbers = MakeShared<FJsonObject>();
|
|
Step2eStructureNumbers->SetNumberField(TEXT("minimum_solid_fraction"), LowestStep2eSolidFraction);
|
|
Step2eStructureNumbers->SetNumberField(TEXT("max_roof_openings"), WorstStep2eRoofOpenings);
|
|
Step2eStructureNumbers->SetNumberField(TEXT("max_floor_openings"), WorstStep2eFloorOpenings);
|
|
TSharedRef<FJsonObject> Step2fStructureNumbers = MakeShared<FJsonObject>();
|
|
Step2fStructureNumbers->SetNumberField(TEXT("minimum_solid_fraction"), LowestStep2fSolidFraction);
|
|
Step2fStructureNumbers->SetNumberField(TEXT("max_roof_openings"), WorstStep2fRoofOpenings);
|
|
Step2fStructureNumbers->SetNumberField(TEXT("max_floor_openings"), WorstStep2fFloorOpenings);
|
|
AddGate(TEXT("roof_floor_and_solid_share"), TEXT("solid fraction 0.25..0.88; zero air samples in 1 m roof/floor shells"),
|
|
bStep2eStructureRejected, bStep2fStructurePassed,
|
|
Step2eStructureNumbers, Step2fStructureNumbers);
|
|
|
|
TSharedRef<FJsonObject> Report = MakeShared<FJsonObject>();
|
|
Report->SetStringField(TEXT("status"), bPassed ? TEXT("pass") : TEXT("fail"));
|
|
Report->SetBoolField(TEXT("pass"), bPassed);
|
|
Report->SetBoolField(TEXT("geometry_gates_reject_step2e"), bAllGeometryGatesRejectStep2e);
|
|
Report->SetBoolField(TEXT("geometry_gates_pass_step2f"), bAllGeometryGatesPassStep2f);
|
|
Report->SetBoolField(TEXT("worst_profile_meets_600ms"), bSpeedPass);
|
|
Report->SetNumberField(TEXT("step2e_worst_six_worker_ring_ms"), WorstStep2eMs);
|
|
Report->SetNumberField(TEXT("step2f_worst_six_worker_ring_ms"), WorstStep2fMs);
|
|
Report->SetNumberField(TEXT("step2f_solid_fraction_min"), BestStep2fSolidFraction);
|
|
Report->SetNumberField(TEXT("step2f_solid_fraction_max"), WorstStep2fSolidFraction);
|
|
Report->SetNumberField(TEXT("seed"), Seed);
|
|
Report->SetNumberField(TEXT("ring_dimension"), Dimension);
|
|
Report->SetArrayField(TEXT("ring_origin_voxels"), {
|
|
MakeShared<FJsonValueNumber>(Origin.X),
|
|
MakeShared<FJsonValueNumber>(Origin.Y),
|
|
MakeShared<FJsonValueNumber>(Origin.Z)});
|
|
Report->SetNumberField(TEXT("ring_samples"), static_cast<double>(TotalSamples));
|
|
Report->SetNumberField(TEXT("workers_requested"), Workers);
|
|
Report->SetNumberField(TEXT("world_prepare_ms_excluded"), WorldPrepareSeconds * 1000.0);
|
|
Report->SetStringField(TEXT("field_definition"),
|
|
TEXT("Rooms use strongly jittered cells with per-room radii, axis ratios and heights. "
|
|
"A conservative nearest-neighbor cap leaves a solid web between chambers; a rooted "
|
|
"tree of winding, rounded tubes with hash-varied widths and capped shafts joins them. Sparse ledges, pillars, "
|
|
"stalactites and large hanging masses remain per-cell rolls. The point and "
|
|
"brick detail samplers share independently rotated 96/48/24-sample bands with a "
|
|
"24-sample shortest period and a smooth ridge response; their final-field "
|
|
"detail supremum is 7.644 voxel units."));
|
|
Report->SetStringField(TEXT("component_method"),
|
|
TEXT("26-neighbor connected components are counted on the exact 12.5 cm sign lattice. "
|
|
"Raw components below 512 samples (< 1 m3) are reported, then a 128-sample (16 m) pad is "
|
|
"evaluated around each 259-cube and only components intersecting the original cube "
|
|
"are counted again; this prevents a clipped large component at the ring edge from "
|
|
"being called a crumb. The one-meter downsampled air component count is also reported."));
|
|
Report->SetStringField(TEXT("mesh_method"),
|
|
TEXT("Sixty-four level-0 tiles and eight level-1 tiles cover the same 128-sample (16 m) cube selected "
|
|
"for maximum coarse-sampled surface crossings. "
|
|
"The share of manifold triangle edges whose geometric dihedral exceeds 45 degrees "
|
|
"and its p95 are measured. Each level-1 vertex is compared with nearby level-0 "
|
|
"triangles using exact point-to-triangle distance; the p95 is reported in lattice samples."));
|
|
Report->SetObjectField(TEXT("gates"), Gates);
|
|
Report->SetArrayField(TEXT("profiles"), ProfileRows);
|
|
Report->SetArrayField(TEXT("slices"), SliceRows);
|
|
Report->SetStringField(TEXT("slice_directory"), ResearchOut);
|
|
|
|
TSharedRef<FJsonObject> Existing = MakeShared<FJsonObject>();
|
|
Existing->SetBoolField(TEXT("sound_brick_proofs"), bBoundAudit);
|
|
Existing->SetNumberField(TEXT("correctly_skipped_bricks_audited"),
|
|
static_cast<double>(ProofAudit.CorrectlySkippedBricksAudited));
|
|
Existing->SetNumberField(TEXT("final_field_samples_checked"),
|
|
static_cast<double>(ProofAudit.CorrectProofSamplesChecked));
|
|
Existing->SetNumberField(TEXT("correct_proof_sign_violations"),
|
|
static_cast<double>(ProofAudit.CorrectProofViolations));
|
|
TSharedRef<FJsonObject> FirstProofViolation = MakeShared<FJsonObject>();
|
|
FirstProofViolation->SetBoolField(TEXT("present"), ProofAudit.CorrectProofViolations > 0);
|
|
FirstProofViolation->SetArrayField(TEXT("brick_origin_voxels"), {
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationBrickOrigin.X),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationBrickOrigin.Y),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationBrickOrigin.Z)});
|
|
FirstProofViolation->SetArrayField(TEXT("brick_size"), {
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationBrickSize.X),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationBrickSize.Y),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationBrickSize.Z)});
|
|
FirstProofViolation->SetArrayField(TEXT("sample_voxels"), {
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationSample.X),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationSample.Y),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationSample.Z)});
|
|
FirstProofViolation->SetBoolField(TEXT("inside_brick"),
|
|
ProofAudit.bFirstViolationWasInsideBrick);
|
|
FirstProofViolation->SetBoolField(TEXT("claimed_air"),
|
|
ProofAudit.bFirstViolationClaimedAir);
|
|
FirstProofViolation->SetNumberField(TEXT("final_density"),
|
|
ProofAudit.FirstViolationDensity);
|
|
FirstProofViolation->SetNumberField(TEXT("center_macro"),
|
|
ProofAudit.FirstViolationCenterMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("sample_macro"),
|
|
ProofAudit.FirstViolationSampleMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("center_room_macro"),
|
|
ProofAudit.FirstViolationCenterRoomMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("sample_room_macro"),
|
|
ProofAudit.FirstViolationSampleRoomMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("sample_detail"),
|
|
ProofAudit.FirstViolationSampleDetail);
|
|
FirstProofViolation->SetNumberField(TEXT("center_to_sample_macro_delta_per_voxel"),
|
|
ProofAudit.FirstViolationMacroDeltaPerVoxel);
|
|
FirstProofViolation->SetNumberField(TEXT("path_max_macro_gradient"),
|
|
ProofAudit.FirstViolationPathMaxGradient);
|
|
FirstProofViolation->SetArrayField(TEXT("path_max_from_voxels"), {
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationPathMaxFrom.X),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationPathMaxFrom.Y),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationPathMaxFrom.Z)});
|
|
FirstProofViolation->SetArrayField(TEXT("path_max_to_voxels"), {
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationPathMaxTo.X),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationPathMaxTo.Y),
|
|
MakeShared<FJsonValueNumber>(ProofAudit.FirstViolationPathMaxTo.Z)});
|
|
FirstProofViolation->SetNumberField(TEXT("path_max_from_macro"),
|
|
ProofAudit.FirstViolationPathMaxFromMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("path_max_to_macro"),
|
|
ProofAudit.FirstViolationPathMaxToMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("path_max_from_room_macro"),
|
|
ProofAudit.FirstViolationPathMaxFromRoomMacro);
|
|
FirstProofViolation->SetNumberField(TEXT("path_max_to_room_macro"),
|
|
ProofAudit.FirstViolationPathMaxToRoomMacro);
|
|
FirstProofViolation->SetStringField(TEXT("path_max_from_room_diagnostic"),
|
|
ProofAudit.FirstViolationPathMaxFromRoomDiagnostic);
|
|
FirstProofViolation->SetStringField(TEXT("path_max_to_room_diagnostic"),
|
|
ProofAudit.FirstViolationPathMaxToRoomDiagnostic);
|
|
FirstProofViolation->SetNumberField(TEXT("tiny_step_max_macro_gradient"),
|
|
ProofAudit.FirstViolationTinyStepMaxGradient);
|
|
Existing->SetObjectField(TEXT("first_proof_violation"), FirstProofViolation);
|
|
Existing->SetNumberField(TEXT("undersized_bound_claims_checked"),
|
|
static_cast<double>(ProofAudit.DeliberatelyUndersizedBoundClaimsChecked));
|
|
Existing->SetNumberField(TEXT("wrong_sign_samples_caught_by_undersized_bound"),
|
|
static_cast<double>(ProofAudit.DeliberateBoundDisagreements));
|
|
Existing->SetBoolField(TEXT("deliberate_undersized_bound_failure_caught"),
|
|
bUndersizedBoundCaught);
|
|
Existing->SetBoolField(TEXT("seam_gradient_gate"), bBoundaryPass);
|
|
Existing->SetBoolField(TEXT("deliberate_seam_failure_caught"), bDeliberateSeamCaught);
|
|
Existing->SetNumberField(TEXT("seam_failure_samples_mutated"),
|
|
static_cast<double>(DeliberateSeamSamplesMutated));
|
|
Existing->SetNumberField(TEXT("brick_boundary_gradient_jump_rms"), BoundaryStats.BrickJumpRms);
|
|
Existing->SetNumberField(TEXT("brick_interior_gradient_jump_rms"), BoundaryStats.InteriorJumpRms);
|
|
Existing->SetBoolField(TEXT("one_and_six_worker_determinism"), bDeterminism);
|
|
Existing->SetBoolField(TEXT("brick_path_matches_scalar_bit_exact"),
|
|
bParameterizedBrickScalarParity);
|
|
Existing->SetNumberField(TEXT("brick_scalar_parity_samples"),
|
|
BrickParitySize.X * BrickParitySize.Y * BrickParitySize.Z);
|
|
Existing->SetNumberField(TEXT("brick_scalar_parity_mismatches"),
|
|
static_cast<double>(BrickScalarMismatchCount));
|
|
Existing->SetStringField(TEXT("six_worker_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(HardestHash)));
|
|
Existing->SetStringField(TEXT("repeat_six_worker_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(RepeatHash)));
|
|
Existing->SetStringField(TEXT("one_worker_hash"), FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(OneWorkerHash)));
|
|
Existing->SetStringField(TEXT("functional_test_filter"),
|
|
TEXT("VoxelForge.ParameterizedGeneration.TraversalSeeds"));
|
|
Report->SetObjectField(TEXT("existing_checks"), Existing);
|
|
|
|
FString JsonText;
|
|
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&JsonText);
|
|
const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step2f_report.json"));
|
|
const FString LaunchReportPath = FPaths::Combine(Arguments.OutDirectory, TEXT("step2f_report.json"));
|
|
if (!FJsonSerializer::Serialize(Report, Writer)
|
|
|| !FFileHelper::SaveStringToFile(JsonText, *ReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)
|
|
|| !FFileHelper::SaveStringToFile(JsonText, *LaunchReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] report write failed: %s"), *ReportPath);
|
|
return 2;
|
|
}
|
|
VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false);
|
|
VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false);
|
|
VoxelForgeRedefinedDensityField::SetDetailScale(1.0f);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStep2f] pass=%s gates_2e_rejected=%s gates_2f_passed=%s "
|
|
"speed600=%s old_worst_ms=%.2f new_worst_ms=%.2f report=%s"),
|
|
bPassed ? TEXT("yes") : TEXT("no"),
|
|
bAllGeometryGatesRejectStep2e ? TEXT("yes") : TEXT("no"),
|
|
bAllGeometryGatesPassStep2f ? TEXT("yes") : TEXT("no"),
|
|
bSpeedPass ? TEXT("yes") : TEXT("no"),
|
|
WorstStep2eMs, WorstStep2fMs, *ReportPath);
|
|
return bPassed ? 0 : 2;
|
|
}
|
|
|
|
class FGeneratorDensitySampler final : public IVoxelStrateDensitySampler
|
|
{
|
|
public:
|
|
explicit FGeneratorDensitySampler(const UVoxelGenerator& InGenerator)
|
|
: Generator(InGenerator)
|
|
{
|
|
}
|
|
|
|
virtual float SampleDensity(float WorldX, float WorldY, float WorldZ) const override
|
|
{
|
|
return Generator.GetDensityAt(WorldX, WorldY, WorldZ);
|
|
}
|
|
|
|
private:
|
|
const UVoxelGenerator& Generator;
|
|
};
|
|
|
|
struct FExploreRenderFrame
|
|
{
|
|
FString FileName;
|
|
FString Purpose = TEXT("overview");
|
|
FString CameraSeedSource = TEXT("player_fit");
|
|
FVector CameraSeedPoseVoxels = FVector::ZeroVector;
|
|
FVector CameraVoxels = FVector::ZeroVector;
|
|
FVector TargetVoxels = FVector::ZeroVector;
|
|
int32 HitPixels = 0;
|
|
bool bScaleMarkerProjected = false;
|
|
bool bFailureMarkersProjected = false;
|
|
bool bPartial = false;
|
|
};
|
|
|
|
struct FExploreRenderOutput
|
|
{
|
|
FString Status;
|
|
FString RefusalReason;
|
|
int32 Width = 0;
|
|
int32 Height = 0;
|
|
float FixedStepVoxels = 0.0f;
|
|
float FixedStepMeters = 0.0f;
|
|
int32 BisectionIterations = 0;
|
|
float MaxDistanceVoxels = 0.0f;
|
|
int64 EstimatedFixedDensitySamples = 0;
|
|
int64 LegacyTwoViewEstimatedDensitySamples = 0;
|
|
FString StepRationale;
|
|
FString CameraSeedPolicy;
|
|
int32 CameraSeedCount = 0;
|
|
bool bAllCamerasPlayerFit = false;
|
|
double MeshSeconds = 0.0;
|
|
double RasterSeconds = 0.0;
|
|
double FirstViewRasterSeconds = 0.0;
|
|
double AdditionalViewRasterSeconds = 0.0;
|
|
int32 RequestedViewpointCount = 8;
|
|
bool bMeshComplete = false;
|
|
double MeshAccelerationSeconds = 0.0;
|
|
bool bTruncated = false;
|
|
TArray<FExploreRenderFrame> Frames;
|
|
};
|
|
|
|
struct FExploreWalkOutput
|
|
{
|
|
FString Status;
|
|
FString RefusalReason;
|
|
bool bHasArrival = false;
|
|
bool bHasDeparture = false;
|
|
int32 ArrivalPassageCount = 0;
|
|
int32 DeparturePassageCount = 0;
|
|
FVector ArrivalVoxels = FVector::ZeroVector;
|
|
FVector DepartureVoxels = FVector::ZeroVector;
|
|
FString WindowPolicy;
|
|
FVoxelPlayerFitWalkReport Report;
|
|
bool bOriginCheckAttempted = false;
|
|
bool bOriginCheckAvailable = false;
|
|
FString OriginCheckRefusalReason;
|
|
FVoxelPlayerFitWalkReport OriginCheckReport;
|
|
int32 ArrivalLandingRoomProbeCount = 0;
|
|
int32 ArrivalLandingRoomPlayerFitProbeCount = 0;
|
|
int32 ArrivalLandingRoomArrivalComponentProbeCount = 0;
|
|
int32 DepartureLandingRoomProbeCount = 0;
|
|
int32 DepartureLandingRoomPlayerFitProbeCount = 0;
|
|
int32 DepartureLandingRoomArrivalComponentProbeCount = 0;
|
|
// Captured by the one walk pass and consumed by render camera selection. These are internal
|
|
// hand-off buffers, not a second source of truth for the field.
|
|
FVoxelStrateSampleGrid SharedSampleGrid;
|
|
TArray<FVector> CameraSeedPoses;
|
|
TArray<FString> CameraSeedSources;
|
|
};
|
|
|
|
struct FExploreMeshMetrics
|
|
{
|
|
int32 VertexCount = 0;
|
|
int32 TriangleCount = 0;
|
|
int32 SharedEdgeCount = 0;
|
|
int32 DihedralSampleCount = 0;
|
|
int32 NormalComparisonTriangleCount = 0;
|
|
float EdgeLengthP50Meters = 0.0f;
|
|
float EdgeLengthP99Meters = 0.0f;
|
|
float EdgeLengthMaxMeters = 0.0f;
|
|
float DihedralP50Degrees = 0.0f;
|
|
float DihedralP75Degrees = 0.0f;
|
|
float DihedralP90Degrees = 0.0f;
|
|
float DihedralP95Degrees = 0.0f;
|
|
float DihedralP99Degrees = 0.0f;
|
|
float DihedralFractionOver20Degrees = 0.0f;
|
|
float DihedralFractionOver40Degrees = 0.0f;
|
|
float FaceNormalVsMeanVertexNormalP50Degrees = 0.0f;
|
|
float FaceNormalVsMeanVertexNormalP90Degrees = 0.0f;
|
|
};
|
|
|
|
struct FExploreMeshEdgeKey
|
|
{
|
|
FIntVector First = FIntVector::ZeroValue;
|
|
FIntVector Second = FIntVector::ZeroValue;
|
|
|
|
bool operator==(const FExploreMeshEdgeKey& Other) const
|
|
{
|
|
return First == Other.First && Second == Other.Second;
|
|
}
|
|
};
|
|
|
|
FORCEINLINE uint32 GetTypeHash(const FExploreMeshEdgeKey& Key)
|
|
{
|
|
return HashCombine(GetTypeHash(Key.First), GetTypeHash(Key.Second));
|
|
}
|
|
|
|
struct FExploreMeshEdgeIncident
|
|
{
|
|
int32 IncidentCount = 0;
|
|
int32 FirstTriangle = INDEX_NONE;
|
|
int32 SecondTriangle = INDEX_NONE;
|
|
};
|
|
|
|
struct FExploreExportOutput
|
|
{
|
|
FString Status;
|
|
FString RefusalReason;
|
|
FString MeshFileName;
|
|
FString ManifestFileName;
|
|
FIntVector RegionOrigin = FIntVector::ZeroValue;
|
|
int32 RegionSize = 0;
|
|
int32 MesherTileCount = 0;
|
|
int32 MesherTilesCompleted = 0;
|
|
int64 EstimatedWorkingBytes = 0;
|
|
int64 WorkingMemoryCapBytes = MaxExportWorkingBytes;
|
|
int64 MeshArrayBytes = 0;
|
|
int64 MeshFileSizeBytes = 0;
|
|
int32 VertexCount = 0;
|
|
int32 TriangleCount = 0;
|
|
FExploreMeshMetrics SurfaceMetrics;
|
|
double MeshSeconds = 0.0;
|
|
double MeshUsPerVoxel = 0.0;
|
|
double MeasureSeconds = 0.0;
|
|
double ObjWriteSeconds = 0.0;
|
|
double ManifestSeconds = 0.0;
|
|
double ExportWallSeconds = 0.0;
|
|
double ExportAccountingRemainderSeconds = 0.0;
|
|
FString GeometryHash;
|
|
bool bTruncated = false;
|
|
};
|
|
|
|
struct FExploreDensitySliceOutput
|
|
{
|
|
FString Status;
|
|
FString RefusalReason;
|
|
FString FileName;
|
|
int32 SampleCount = 0;
|
|
int32 FiniteSampleCount = 0;
|
|
int32 NonFiniteSampleCount = 0;
|
|
int32 ZeroCrossingCount = 0;
|
|
int32 SaturatedSampleCount = 0;
|
|
float MinDensity = 0.0f;
|
|
float MaxDensity = 0.0f;
|
|
float MaxAbsDensity = 0.0f;
|
|
double Seconds = 0.0;
|
|
};
|
|
|
|
struct FExploreTunnelCoreProbeAxis
|
|
{
|
|
FString Name;
|
|
int32 ValidCoreCrossings = 0;
|
|
int32 ValidFinalCrossings = 0;
|
|
float CoreMeanCrossingVoxels = 0.0f;
|
|
float FinalMeanCrossingVoxels = 0.0f;
|
|
float CoreMinCrossingVoxels = 0.0f;
|
|
float CoreMaxCrossingVoxels = 0.0f;
|
|
float FinalMinCrossingVoxels = 0.0f;
|
|
float FinalMaxCrossingVoxels = 0.0f;
|
|
float CorePeriodicAmplitudeVoxels = 0.0f;
|
|
float CorePeriodicPeriodVoxels = 0.0f;
|
|
float FinalPeriodicAmplitudeVoxels = 0.0f;
|
|
float FinalPeriodicPeriodVoxels = 0.0f;
|
|
int32 CorePeriodicMeasuredSamples = 0;
|
|
float CorePeriodicMeasuredLengthVoxels = 0.0f;
|
|
int32 FinalPeriodicMeasuredSamples = 0;
|
|
float FinalPeriodicMeasuredLengthVoxels = 0.0f;
|
|
TArray<float> CoreCrossingsVoxels;
|
|
TArray<float> FinalCrossingsVoxels;
|
|
};
|
|
|
|
struct FExploreTunnelMouthProbeAxis
|
|
{
|
|
FString Name;
|
|
int32 ValidCrossings = 0;
|
|
float MeanCrossingVoxels = 0.0f;
|
|
float MinCrossingVoxels = 0.0f;
|
|
float MaxCrossingVoxels = 0.0f;
|
|
float PeriodicAmplitudeVoxels = 0.0f;
|
|
float PeriodicPeriodVoxels = 0.0f;
|
|
int32 PeriodicMeasuredSamples = 0;
|
|
float PeriodicMeasuredLengthVoxels = 0.0f;
|
|
// Audit value from the core probe's local-median detrend. Mouth surfaces can carry a broad
|
|
// room-floor bowl over the +/-2R window, so the accepted mouth value below uses a degree-six
|
|
// slow trend while retaining this legacy value for before/after diagnosis.
|
|
float LocalMedianPeriodicAmplitudeVoxels = 0.0f;
|
|
float LocalMedianPeriodicPeriodVoxels = 0.0f;
|
|
TArray<float> CrossingsVoxels;
|
|
};
|
|
|
|
struct FExploreTunnelMouthProbeEndpoint
|
|
{
|
|
FString Name;
|
|
uint32 RoomHash = 0;
|
|
FVector Mouth = FVector::ZeroVector;
|
|
float RadiusVoxels = 0.0f;
|
|
float TunnelFloorZ = -FLT_MAX;
|
|
float RoomFloorCrossingZ = -FLT_MAX;
|
|
float FloorJoinDeltaVoxels = 0.0f;
|
|
FExploreTunnelMouthProbeAxis Wall;
|
|
FExploreTunnelMouthProbeAxis Floor;
|
|
};
|
|
|
|
struct FExploreTunnelMouthProbeOutput
|
|
{
|
|
FString Status;
|
|
FString RefusalReason;
|
|
FString FileName;
|
|
int32 EndpointCount = 0;
|
|
int32 LateralSampleCount = 0;
|
|
float LateralStepVoxels = 0.25f;
|
|
float LateralWindowVoxels = 0.0f;
|
|
int32 RoomSampleStepVoxels = 2;
|
|
int64 RoomVolumeSampleCount = 0;
|
|
int64 RoomAirSampleCount = 0;
|
|
int64 CoreBottomSampleCount = 0;
|
|
int64 FinalSolidSampleCount = 0;
|
|
int64 CoreInducedSolidSampleCount = 0;
|
|
float MaxWallPeriodicAmplitudeVoxels = 0.0f;
|
|
float MaxFloorPeriodicAmplitudeVoxels = 0.0f;
|
|
float MaxLocalMedianWallPeriodicAmplitudeVoxels = 0.0f;
|
|
float MaxLocalMedianFloorPeriodicAmplitudeVoxels = 0.0f;
|
|
double Seconds = 0.0;
|
|
TArray<FExploreTunnelMouthProbeEndpoint> Endpoints;
|
|
};
|
|
|
|
struct FExploreTunnelCoreProbeOutput
|
|
{
|
|
FString Status;
|
|
FString RefusalReason;
|
|
FString FileName;
|
|
int32 TunnelIndex = INDEX_NONE;
|
|
int32 SampleCount = 0;
|
|
int32 CoreCenterFiniteSampleCount = 0;
|
|
int32 CoreCenterZeroCrossingCount = 0;
|
|
int32 FinalCenterFiniteSampleCount = 0;
|
|
int32 FinalCenterZeroCrossingCount = 0;
|
|
int32 FinalCenterSaturatedSampleCount = 0;
|
|
float CoreCenterMin = 0.0f;
|
|
float CoreCenterMax = 0.0f;
|
|
float FinalCenterMin = 0.0f;
|
|
float FinalCenterMax = 0.0f;
|
|
float FinalCenterMaxAbs = 0.0f;
|
|
float StepVoxels = 0.25f;
|
|
float LengthVoxels = 0.0f;
|
|
FVector AxisStart = FVector::ZeroVector;
|
|
FVector AxisEnd = FVector::ZeroVector;
|
|
FVector AxisDirection = FVector::ForwardVector;
|
|
FVector WallDirection = FVector::RightVector;
|
|
FExploreTunnelCoreProbeAxis Floor;
|
|
FExploreTunnelCoreProbeAxis Wall;
|
|
FExploreTunnelCoreProbeAxis Ceiling;
|
|
FExploreTunnelMouthProbeOutput Mouth;
|
|
double Seconds = 0.0;
|
|
};
|
|
|
|
struct FExploreProfilerComparison
|
|
{
|
|
bool bAvailable = false;
|
|
FString Scope;
|
|
double ProfiledSeconds = 0.0;
|
|
double UnprofiledSeconds = 0.0;
|
|
double DiscrepancySeconds = 0.0;
|
|
double DiscrepancyPercent = 0.0;
|
|
double Ratio = 0.0;
|
|
bool bGeometryEqual = false;
|
|
FString ProfiledGeometryHash;
|
|
FString UnprofiledGeometryHash;
|
|
FString SelfCheck = TEXT("not_run");
|
|
};
|
|
|
|
struct FExploreRunOutput
|
|
{
|
|
FExploreRenderOutput Render;
|
|
FExploreWalkOutput Walk;
|
|
FExploreExportOutput Export;
|
|
FExploreDensitySliceOutput DensitySlice;
|
|
FExploreTunnelCoreProbeOutput TunnelCoreProbe;
|
|
double BudgetSeconds = 0.0;
|
|
double ElapsedSeconds = 0.0;
|
|
double WalkSeconds = 0.0;
|
|
bool bTruncated = false;
|
|
FString TruncatedDuring;
|
|
TArray<FString> CompletedModes;
|
|
VoxelDensityProfile::FSnapshot Profile;
|
|
bool bHasProfile = false;
|
|
FExploreProfilerComparison ProfilerComparison;
|
|
};
|
|
|
|
struct FExploreStackSamplerSession
|
|
{
|
|
bool bStarted = false;
|
|
|
|
~FExploreStackSamplerSession()
|
|
{
|
|
Stop();
|
|
}
|
|
|
|
bool Start(uint32 IntervalUs, const FString& OutputDirectory)
|
|
{
|
|
if (bStarted)
|
|
{
|
|
return true;
|
|
}
|
|
bStarted = FVoxelStackSampler::Get().Start(
|
|
IntervalUs, OutputDirectory, TEXT("commandlet"));
|
|
return bStarted;
|
|
}
|
|
|
|
void Stop()
|
|
{
|
|
if (bStarted)
|
|
{
|
|
FVoxelStackSampler::Get().StopAndWrite();
|
|
bStarted = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
bool ValidateCanonicalTile(const FVoxelMeshData& MeshData, FString& OutError);
|
|
bool MeasureExploreMesh(
|
|
const FVoxelMeshData& MeshData,
|
|
FExploreMeshMetrics& OutMetrics,
|
|
FString& OutError);
|
|
|
|
bool FindMouths(
|
|
const UVoxelStrateManager& Manager,
|
|
int32 TargetSlot,
|
|
FVector& OutArrival,
|
|
FVector& OutDeparture,
|
|
int32& OutArrivalCount,
|
|
int32& OutDepartureCount,
|
|
const FVoxelPassage*& OutArrivalPassage,
|
|
const FVoxelPassage*& OutDeparturePassage)
|
|
{
|
|
OutArrival = FVector::ZeroVector;
|
|
OutDeparture = FVector::ZeroVector;
|
|
OutArrivalCount = 0;
|
|
OutDepartureCount = 0;
|
|
OutArrivalPassage = nullptr;
|
|
OutDeparturePassage = nullptr;
|
|
for (const FVoxelPassage& Passage : Manager.GetPassages())
|
|
{
|
|
if (Passage.LowerStrateIndex == TargetSlot
|
|
&& Passage.UpperStrateIndex + 1 == TargetSlot)
|
|
{
|
|
if (OutArrivalCount == 0)
|
|
{
|
|
OutArrival = Passage.LowerPoint;
|
|
OutArrivalPassage = &Passage;
|
|
}
|
|
++OutArrivalCount;
|
|
}
|
|
if (Passage.UpperStrateIndex == TargetSlot
|
|
&& Passage.LowerStrateIndex == TargetSlot + 1)
|
|
{
|
|
if (OutDepartureCount == 0)
|
|
{
|
|
OutDeparture = Passage.UpperPoint;
|
|
OutDeparturePassage = &Passage;
|
|
}
|
|
++OutDepartureCount;
|
|
}
|
|
}
|
|
return OutArrivalCount > 0 && OutDepartureCount > 0;
|
|
}
|
|
|
|
struct FExploreCameraBasis
|
|
{
|
|
FVector Forward = FVector::ForwardVector;
|
|
FVector Right = FVector::RightVector;
|
|
FVector Up = FVector::UpVector;
|
|
float Aspect = 1.0f;
|
|
float TanHalfFov = 1.0f;
|
|
};
|
|
|
|
bool BuildCameraBasis(
|
|
const FVector& Position,
|
|
const FVector& Target,
|
|
float FieldOfViewDegrees,
|
|
int32 Width,
|
|
int32 Height,
|
|
FExploreCameraBasis& OutBasis)
|
|
{
|
|
OutBasis.Forward = (Target - Position).GetSafeNormal();
|
|
if (OutBasis.Forward.IsNearlyZero())
|
|
{
|
|
return false;
|
|
}
|
|
OutBasis.Right = FVector::CrossProduct(OutBasis.Forward, FVector::UpVector).GetSafeNormal();
|
|
if (OutBasis.Right.IsNearlyZero())
|
|
{
|
|
OutBasis.Right = FVector::CrossProduct(OutBasis.Forward, FVector::RightVector).GetSafeNormal();
|
|
}
|
|
OutBasis.Up = FVector::CrossProduct(OutBasis.Right, OutBasis.Forward).GetSafeNormal();
|
|
OutBasis.Aspect = static_cast<float>(Width) / static_cast<float>(Height);
|
|
OutBasis.TanHalfFov = FMath::Tan(FMath::DegreesToRadians(FieldOfViewDegrees * 0.5f));
|
|
return OutBasis.Aspect > 0.0f && OutBasis.TanHalfFov > 0.0f;
|
|
}
|
|
|
|
void PutPixel(TArray<FColor>& Pixels, int32 Width, int32 Height, int32 X, int32 Y, const FColor& Color)
|
|
{
|
|
if (X >= 0 && X < Width && Y >= 0 && Y < Height)
|
|
{
|
|
Pixels[X + Width * Y] = Color;
|
|
}
|
|
}
|
|
|
|
void DrawLine(
|
|
TArray<FColor>& Pixels,
|
|
int32 Width,
|
|
int32 Height,
|
|
int32 X0,
|
|
int32 Y0,
|
|
int32 X1,
|
|
int32 Y1,
|
|
const FColor& Color)
|
|
{
|
|
int32 DX = FMath::Abs(X1 - X0);
|
|
const int32 SX = X0 < X1 ? 1 : -1;
|
|
int32 DY = -FMath::Abs(Y1 - Y0);
|
|
const int32 SY = Y0 < Y1 ? 1 : -1;
|
|
int32 Error = DX + DY;
|
|
for (;;)
|
|
{
|
|
PutPixel(Pixels, Width, Height, X0, Y0, Color);
|
|
if (X0 == X1 && Y0 == Y1)
|
|
{
|
|
break;
|
|
}
|
|
const int32 DoubleError = 2 * Error;
|
|
if (DoubleError >= DY)
|
|
{
|
|
Error += DY;
|
|
X0 += SX;
|
|
}
|
|
if (DoubleError <= DX)
|
|
{
|
|
Error += DX;
|
|
Y0 += SY;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool ProjectPoint(
|
|
const FVector& Point,
|
|
const FVector& CameraPosition,
|
|
const FExploreCameraBasis& Basis,
|
|
int32 Width,
|
|
int32 Height,
|
|
FIntPoint& OutPixel)
|
|
{
|
|
const FVector Relative = Point - CameraPosition;
|
|
const float Depth = FVector::DotProduct(Relative, Basis.Forward);
|
|
if (Depth <= KINDA_SMALL_NUMBER)
|
|
{
|
|
return false;
|
|
}
|
|
const float NdcX = FVector::DotProduct(Relative, Basis.Right)
|
|
/ (Depth * Basis.Aspect * Basis.TanHalfFov);
|
|
const float NdcY = FVector::DotProduct(Relative, Basis.Up)
|
|
/ (Depth * Basis.TanHalfFov);
|
|
OutPixel.X = FMath::RoundToInt((NdcX * 0.5f + 0.5f) * static_cast<float>(Width - 1));
|
|
OutPixel.Y = FMath::RoundToInt((1.0f - (NdcY * 0.5f + 0.5f))
|
|
* static_cast<float>(Height - 1));
|
|
return NdcX >= -1.0f && NdcX <= 1.0f && NdcY >= -1.0f && NdcY <= 1.0f;
|
|
}
|
|
|
|
void DrawScaleMarker(
|
|
TArray<FColor>& Pixels,
|
|
int32 Width,
|
|
int32 Height,
|
|
const FVector& CameraPosition,
|
|
const FVector& Target,
|
|
const FExploreCameraBasis& Basis,
|
|
bool& bOutProjected)
|
|
{
|
|
constexpr float HumanHeightVoxels = 2.0f
|
|
* FVoxelPlayerCapsuleConstants::HalfHeightVoxels;
|
|
const FVector MarkerBase = FVector(
|
|
Target.X,
|
|
Target.Y,
|
|
Target.Z - FVoxelPlayerCapsuleConstants::HalfHeightVoxels)
|
|
- Basis.Right * 18.0f
|
|
+ Basis.Forward * 48.0f;
|
|
const FVector MarkerTop = MarkerBase + FVector(0.0f, 0.0f, HumanHeightVoxels);
|
|
FIntPoint BasePixel = FIntPoint::ZeroValue;
|
|
FIntPoint TopPixel = FIntPoint::ZeroValue;
|
|
bOutProjected = ProjectPoint(
|
|
MarkerBase, CameraPosition, Basis, Width, Height, BasePixel)
|
|
&& ProjectPoint(MarkerTop, CameraPosition, Basis, Width, Height, TopPixel);
|
|
if (!bOutProjected)
|
|
{
|
|
// The world-space marker is normally in frame by construction. Keep a visible fallback
|
|
// for unusual camera/FOV overrides, while the JSON records that it was not projected.
|
|
BasePixel = FIntPoint(12, Height - 12);
|
|
TopPixel = FIntPoint(12, FMath::Max(12, Height - 12 - Height / 5));
|
|
}
|
|
|
|
const FColor MarkerColor(255, 220, 32, 255);
|
|
DrawLine(Pixels, Width, Height, BasePixel.X, BasePixel.Y, TopPixel.X, TopPixel.Y, MarkerColor);
|
|
const int32 TickHalfWidth = 5;
|
|
for (const FIntPoint& Tick : { BasePixel, TopPixel })
|
|
{
|
|
DrawLine(
|
|
Pixels,
|
|
Width,
|
|
Height,
|
|
Tick.X - TickHalfWidth,
|
|
Tick.Y,
|
|
Tick.X + TickHalfWidth,
|
|
Tick.Y,
|
|
MarkerColor);
|
|
}
|
|
}
|
|
|
|
bool SavePng(
|
|
const FString& FilePath,
|
|
const TArray<FColor>& Pixels,
|
|
int32 Width,
|
|
int32 Height,
|
|
FString& OutError)
|
|
{
|
|
IImageWrapperModule& ImageWrapperModule = FModuleManager::LoadModuleChecked<IImageWrapperModule>(
|
|
FName(TEXT("ImageWrapper")));
|
|
TSharedPtr<IImageWrapper> ImageWrapper = ImageWrapperModule.CreateImageWrapper(
|
|
EImageFormat::PNG);
|
|
if (!ImageWrapper.IsValid()
|
|
|| !ImageWrapper->SetRaw(
|
|
Pixels.GetData(),
|
|
static_cast<int64>(Pixels.Num()) * sizeof(FColor),
|
|
Width,
|
|
Height,
|
|
ERGBFormat::BGRA,
|
|
8))
|
|
{
|
|
OutError = TEXT("ImageWrapper could not accept the BGRA render buffer.");
|
|
return false;
|
|
}
|
|
const TArray64<uint8>& Compressed = ImageWrapper->GetCompressed(100);
|
|
if (!FFileHelper::SaveArrayToFile(Compressed, *FilePath))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not write PNG '%s'."), *FilePath);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
FIntVector ChooseExploreMeshOrigin(
|
|
const FExploreArguments& Arguments,
|
|
const FExploreWalkOutput* CameraSeedWalk,
|
|
int32 MeshSize,
|
|
int32 TargetMiddleWorldZ)
|
|
{
|
|
if (Arguments.bExportTileOverride)
|
|
{
|
|
return FIntVector(
|
|
Arguments.ExportTileX * CHUNK_SIZE,
|
|
Arguments.ExportTileY * CHUNK_SIZE,
|
|
Arguments.ExportTileZ * CHUNK_SIZE);
|
|
}
|
|
|
|
FVector Centre(0.0f, 0.0f, static_cast<float>(TargetMiddleWorldZ));
|
|
if (CameraSeedWalk != nullptr && CameraSeedWalk->CameraSeedPoses.Num() > 0)
|
|
{
|
|
Centre = CameraSeedWalk->CameraSeedPoses[0];
|
|
}
|
|
|
|
const auto AlignTileOrigin = [MeshSize](float CentreCoordinate)
|
|
{
|
|
return FMath::FloorToInt(
|
|
(CentreCoordinate - 0.5f * static_cast<float>(MeshSize))
|
|
/ static_cast<float>(CHUNK_SIZE))
|
|
* CHUNK_SIZE;
|
|
};
|
|
return FIntVector(
|
|
AlignTileOrigin(Centre.X),
|
|
AlignTileOrigin(Centre.Y),
|
|
AlignTileOrigin(Centre.Z));
|
|
}
|
|
|
|
bool AppendCanonicalMesh(
|
|
FVoxelMeshData& Destination,
|
|
const FVoxelMeshData& Source,
|
|
FString& OutError)
|
|
{
|
|
if (!ValidateCanonicalTile(Source, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const int32 VertexOffset = Destination.Vertices.Num();
|
|
for (const int32 Index : Source.Triangles)
|
|
{
|
|
if (Index < 0 || Index >= Source.Vertices.Num())
|
|
{
|
|
OutError = TEXT("Canonical mesher returned an out-of-range triangle index.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Destination.Vertices.Append(Source.Vertices);
|
|
Destination.Normals.Append(Source.Normals);
|
|
Destination.UVs.Append(Source.UVs);
|
|
Destination.Colors.Append(Source.Colors);
|
|
Destination.Triangles.Reserve(Destination.Triangles.Num() + Source.Triangles.Num());
|
|
for (const int32 Index : Source.Triangles)
|
|
{
|
|
Destination.Triangles.Add(Index + VertexOffset);
|
|
}
|
|
// A multi-tile aggregate interleaves each tile's ground/cap runs. The CPU rasterizer uses
|
|
// flat geometry and the OBJ path is material-neutral, so there is deliberately no invented
|
|
// global ceiling run here.
|
|
Destination.NumCeilingTriangles = 0;
|
|
return true;
|
|
}
|
|
|
|
FString ComputeGeometryHash(const FVoxelMeshData& MeshData)
|
|
{
|
|
return VoxelForgeGeometryHash::Compute(MeshData);
|
|
}
|
|
|
|
bool EnsureExploreMesh(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
const FExploreWalkOutput* CameraSeedWalk,
|
|
FExploreBudget& Budget,
|
|
FString& OutError)
|
|
{
|
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ExploreMesh);
|
|
if (World.bExploreMeshBuilt)
|
|
{
|
|
OutError = World.ExploreMeshError;
|
|
return World.bExploreMeshComplete;
|
|
}
|
|
|
|
World.bExploreMeshBuilt = true;
|
|
World.bExploreMeshComplete = false;
|
|
World.ExploreMeshError.Reset();
|
|
World.ExploreMesh.Clear();
|
|
World.ExploreMeshSize = Arguments.ExportSize;
|
|
const int32 TilesPerAxis = Arguments.ExportSize / CHUNK_SIZE;
|
|
World.ExploreMeshTileCount = TilesPerAxis * TilesPerAxis * TilesPerAxis;
|
|
World.ExploreMeshTilesCompleted = 0;
|
|
World.ExploreTileTriangleCounts.Init(0, World.ExploreMeshTileCount);
|
|
World.ExploreTileGeometryHashes.SetNum(World.ExploreMeshTileCount);
|
|
World.ExploreMeshOrigin = ChooseExploreMeshOrigin(
|
|
Arguments,
|
|
CameraSeedWalk,
|
|
World.ExploreMeshSize,
|
|
FMath::FloorToInt(0.5f * static_cast<float>(
|
|
World.TargetBottomWorldZ + World.TargetTopWorldZ)));
|
|
World.ExploreMeshSeconds = 0.0;
|
|
World.ExploreMeshPreparationSeconds = 0.0;
|
|
World.ExploreMeshPreTileSetupSeconds = 0.0;
|
|
World.ExploreMeshMergeSeconds = 0.0;
|
|
World.ExploreMeshAccountingRemainderSeconds = 0.0;
|
|
World.ExploreMeshHashSeconds = 0.0;
|
|
World.ExploreDensityGridSeconds = 0.0;
|
|
World.ExploreDensityGridTotalSamples = 0;
|
|
World.ExploreDensityGridUniqueSamples = 0;
|
|
World.ExploreDensityGridDuplicateSamplesBefore = 0;
|
|
World.ExploreDensityGridDuplicateSamplesAfter = 0;
|
|
World.bExploreDensityGridReused = false;
|
|
World.ExploreMeshTaskWorkerCount = 0;
|
|
World.ExploreMeshTaskJobCount = 0;
|
|
World.ExploreMeshTaskMinBatchSize = FMath::Clamp(Arguments.MeshMinBatchSize, 1, 64);
|
|
World.ExploreMeshTaskMinSeconds = 0.0;
|
|
World.ExploreMeshTaskMeanSeconds = 0.0;
|
|
World.ExploreMeshTaskMaxSeconds = 0.0;
|
|
World.ExploreMeshTaskSumSeconds = 0.0;
|
|
World.ExploreMeshTaskWallSeconds = 0.0;
|
|
World.ExploreMeshTaskLaunchOverheadSeconds = 0.0;
|
|
World.Mesher->SetSharedDensityGrid(nullptr);
|
|
World.ExploreSharedDensityGrid.Reset();
|
|
|
|
if (Budget.ShouldStop(TEXT("mesh")))
|
|
{
|
|
World.ExploreMeshError = TEXT("The wall-clock budget elapsed before meshing began.");
|
|
OutError = World.ExploreMeshError;
|
|
return false;
|
|
}
|
|
|
|
// This is the same canonical UVoxelMarchingCubesMesher used by export and runtime chunk
|
|
// generation. Historical export deliberately omitted presentation skirts; parity opts into
|
|
// the authored runtime settings so the requested comparison has the same mesher inputs.
|
|
World.Mesher->bGenerateSkirts = Arguments.bGameRuntimeConfig
|
|
? World.Settings->bGenerateSkirts : false;
|
|
World.Mesher->SkirtCells = Arguments.bGameRuntimeConfig
|
|
? World.Settings->SkirtCells : 2.0f;
|
|
World.Mesher->LODOctaveDrop = Arguments.bGameRuntimeConfig
|
|
? World.Settings->LODOctaveDrop : 0;
|
|
|
|
const double MeshStartSeconds = FPlatformTime::Seconds();
|
|
if (Budget.ShouldStop(TEXT("mesh")))
|
|
{
|
|
World.ExploreMeshError = TEXT("The wall-clock budget elapsed before mesh tiles began.");
|
|
OutError = World.ExploreMeshError;
|
|
World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds;
|
|
return false;
|
|
}
|
|
|
|
// Adjacent tiles ask for the same one-point-halo planes. Build one immutable lattice for
|
|
// the whole export when requested, then let each tile copy its local window from it. The
|
|
// precomputation is inside MeshStartSeconds on purpose: this is an end-to-end export number,
|
|
// not a profiled bucket hiding the cost of producing the cache.
|
|
const int32 CellsPerTile = CHUNK_SIZE / Arguments.ExportStep;
|
|
const int32 PointsPerTile = CellsPerTile + 3;
|
|
const int64 TileGridSamples = static_cast<int64>(PointsPerTile)
|
|
* static_cast<int64>(PointsPerTile)
|
|
* static_cast<int64>(PointsPerTile);
|
|
const int32 SharedGridDim = Arguments.ExportSize / Arguments.ExportStep + 3;
|
|
const int64 SharedGridSamples = static_cast<int64>(SharedGridDim)
|
|
* static_cast<int64>(SharedGridDim)
|
|
* static_cast<int64>(SharedGridDim);
|
|
World.ExploreDensityGridTotalSamples = static_cast<int64>(World.ExploreMeshTileCount)
|
|
* TileGridSamples;
|
|
World.ExploreDensityGridUniqueSamples = SharedGridSamples;
|
|
World.ExploreDensityGridDuplicateSamplesBefore = FMath::Max<int64>(
|
|
0, World.ExploreDensityGridTotalSamples - SharedGridSamples);
|
|
World.ExploreDensityGridDuplicateSamplesAfter =
|
|
Arguments.bReuseDensityGrid ? 0 : World.ExploreDensityGridDuplicateSamplesBefore;
|
|
|
|
const double DensityStageStartSeconds = FPlatformTime::Seconds();
|
|
if (Arguments.bReuseDensityGrid)
|
|
{
|
|
const double DensityGridStartSeconds = FPlatformTime::Seconds();
|
|
World.ExploreSharedDensityGrid.OriginVoxels = World.ExploreMeshOrigin
|
|
- FIntVector(Arguments.ExportStep);
|
|
World.ExploreSharedDensityGrid.Step = Arguments.ExportStep;
|
|
World.ExploreSharedDensityGrid.Dim = SharedGridDim;
|
|
World.ExploreSharedDensityGrid.Samples.SetNumUninitialized(
|
|
SharedGridDim * SharedGridDim * SharedGridDim);
|
|
std::atomic<bool> bGridWorkCancelled(false);
|
|
const double GridWorkDeadline = Budget.StartSeconds + Budget.LimitSeconds - 0.25;
|
|
const int32 GridTileCount = TilesPerAxis;
|
|
ParallelFor(
|
|
TEXT("VoxelForgeExploreDensityGrid"),
|
|
World.ExploreMeshTileCount,
|
|
World.ExploreMeshTaskMinBatchSize,
|
|
[&](int32 LinearOwnerIndex)
|
|
{
|
|
if (bGridWorkCancelled.load(std::memory_order_relaxed)
|
|
|| FPlatformTime::Seconds() >= GridWorkDeadline)
|
|
{
|
|
bGridWorkCancelled.store(true, std::memory_order_relaxed);
|
|
return;
|
|
}
|
|
FScopedVoxelStackRegistration StackRegistration;
|
|
const int32 GridOctaveBias = (World.Mesher->LODOctaveDrop > 0
|
|
&& Arguments.ExportStep > 1)
|
|
? World.Mesher->LODOctaveDrop
|
|
* (int32)FMath::FloorLog2((uint32)Arguments.ExportStep)
|
|
: 0;
|
|
const int32 PreviousOctaveBias = VoxelGenLOD::GetThreadOctaveBias();
|
|
const int32 PreviousSampleStep = VoxelGenLOD::GetThreadSampleStep();
|
|
VoxelGenLOD::SetThreadOctaveBias(GridOctaveBias);
|
|
VoxelGenLOD::SetThreadSampleStep(Arguments.ExportStep);
|
|
const int32 OwnerX = LinearOwnerIndex % GridTileCount;
|
|
const int32 OwnerY = (LinearOwnerIndex / GridTileCount) % GridTileCount;
|
|
const int32 OwnerZ = LinearOwnerIndex / (GridTileCount * GridTileCount);
|
|
FIntVector PreviousTileOrigin = FIntVector::ZeroValue;
|
|
int32 IgnoredPreviousTileStep = 1;
|
|
int32 PreviousTileCells = 0;
|
|
VoxelGenLOD::GetThreadTileCacheWindow(
|
|
PreviousTileOrigin, IgnoredPreviousTileStep, PreviousTileCells);
|
|
VoxelGenLOD::SetThreadTileContext(
|
|
World.ExploreMeshOrigin
|
|
+ FIntVector(OwnerX * CHUNK_SIZE, OwnerY * CHUNK_SIZE, OwnerZ * CHUNK_SIZE),
|
|
CellsPerTile);
|
|
// Partition the shared lattice into disjoint rectangular owner regions. A
|
|
// boundary sample belongs to exactly one owner; tile windows may read it from
|
|
// either side. This keeps the generator's spatial locality while guaranteeing
|
|
// one evaluation per global lattice point.
|
|
const auto OwnerMinGrid = [CellsPerTile](int32 Owner) -> int32
|
|
{
|
|
return Owner == 0 ? 0 : 1 + Owner * CellsPerTile;
|
|
};
|
|
const auto OwnerMaxGrid = [CellsPerTile, SharedGridDim, GridTileCount](int32 Owner) -> int32
|
|
{
|
|
return Owner == GridTileCount - 1
|
|
? SharedGridDim
|
|
: 1 + (Owner + 1) * CellsPerTile;
|
|
};
|
|
const int32 GxMin = OwnerMinGrid(OwnerX);
|
|
const int32 GxMax = OwnerMaxGrid(OwnerX);
|
|
const int32 GyMin = OwnerMinGrid(OwnerY);
|
|
const int32 GyMax = OwnerMaxGrid(OwnerY);
|
|
const int32 GzMin = OwnerMinGrid(OwnerZ);
|
|
const int32 GzMax = OwnerMaxGrid(OwnerZ);
|
|
for (int32 Gz = GzMin; Gz < GzMax; ++Gz)
|
|
{
|
|
for (int32 Gy = GyMin; Gy < GyMax; ++Gy)
|
|
{
|
|
for (int32 Gx = GxMin; Gx < GxMax; ++Gx)
|
|
{
|
|
const int32 Index = (Gz * SharedGridDim + Gy) * SharedGridDim + Gx;
|
|
World.ExploreSharedDensityGrid.Samples[Index] =
|
|
World.Generator->GetDensityAt(
|
|
World.ExploreSharedDensityGrid.OriginVoxels.X + Gx * Arguments.ExportStep,
|
|
World.ExploreSharedDensityGrid.OriginVoxels.Y + Gy * Arguments.ExportStep,
|
|
World.ExploreSharedDensityGrid.OriginVoxels.Z + Gz * Arguments.ExportStep);
|
|
}
|
|
}
|
|
}
|
|
VoxelGenLOD::SetThreadOctaveBias(PreviousOctaveBias);
|
|
VoxelGenLOD::SetThreadSampleStep(PreviousSampleStep);
|
|
VoxelGenLOD::SetThreadTileContext(PreviousTileOrigin, PreviousTileCells);
|
|
},
|
|
EParallelForFlags::None);
|
|
World.ExploreDensityGridSeconds = FPlatformTime::Seconds() - DensityGridStartSeconds;
|
|
if (bGridWorkCancelled.load(std::memory_order_relaxed))
|
|
{
|
|
World.ExploreMeshError = TEXT("The wall-clock budget elapsed while building the shared density grid.");
|
|
OutError = World.ExploreMeshError;
|
|
World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds;
|
|
return false;
|
|
}
|
|
World.Mesher->SetSharedDensityGrid(&World.ExploreSharedDensityGrid);
|
|
World.bExploreDensityGridReused = true;
|
|
}
|
|
const double DensityStageEndSeconds = FPlatformTime::Seconds();
|
|
World.ExploreMeshPreparationSeconds = DensityStageStartSeconds - MeshStartSeconds;
|
|
|
|
// Generate each tile through the canonical mesher on worker threads, then append strictly in
|
|
// Z/Y/X order. The mesher's scratch buffers and the generator's hot caches are thread-local;
|
|
// the fixed merge order keeps the aggregate mesh and all downstream raster/export bytes
|
|
// deterministic even though tile completion order is not.
|
|
TArray<FVoxelMeshData> TileMeshes;
|
|
TileMeshes.SetNum(World.ExploreMeshTileCount);
|
|
TArray<FString> TileErrors;
|
|
TileErrors.SetNum(World.ExploreMeshTileCount);
|
|
TArray<uint8> TileCompleted;
|
|
TileCompleted.Init(0, World.ExploreMeshTileCount);
|
|
TArray<double> TileDurations;
|
|
TileDurations.Init(0.0, World.ExploreMeshTileCount);
|
|
TArray<double> TileStartOffsets;
|
|
TileStartOffsets.Init(-1.0, World.ExploreMeshTileCount);
|
|
TArray<double> TileEndOffsets;
|
|
TileEndOffsets.Init(-1.0, World.ExploreMeshTileCount);
|
|
TArray<uint32> TileThreadIds;
|
|
TileThreadIds.Init(0, World.ExploreMeshTileCount);
|
|
std::atomic<bool> bTileWorkCancelled(false);
|
|
const double TileWorkDeadline = Budget.StartSeconds + Budget.LimitSeconds - 0.25;
|
|
const double ParallelStartSeconds = FPlatformTime::Seconds();
|
|
World.ExploreMeshPreTileSetupSeconds = ParallelStartSeconds - DensityStageEndSeconds;
|
|
ParallelFor(
|
|
TEXT("VoxelForgeExploreMeshTiles"),
|
|
World.ExploreMeshTileCount,
|
|
World.ExploreMeshTaskMinBatchSize,
|
|
[&](int32 LinearTileIndex)
|
|
{
|
|
if (bTileWorkCancelled.load(std::memory_order_relaxed)
|
|
|| FPlatformTime::Seconds() >= TileWorkDeadline)
|
|
{
|
|
bTileWorkCancelled.store(true, std::memory_order_relaxed);
|
|
return;
|
|
}
|
|
|
|
FScopedVoxelStackRegistration StackRegistration;
|
|
const double TileStartSeconds = FPlatformTime::Seconds();
|
|
TileStartOffsets[LinearTileIndex] = TileStartSeconds - ParallelStartSeconds;
|
|
TileThreadIds[LinearTileIndex] = FPlatformTLS::GetCurrentThreadId();
|
|
|
|
const int32 TileX = LinearTileIndex % TilesPerAxis;
|
|
const int32 TileY = (LinearTileIndex / TilesPerAxis) % TilesPerAxis;
|
|
const int32 TileZ = LinearTileIndex / (TilesPerAxis * TilesPerAxis);
|
|
const FIntVector TileOrigin = World.ExploreMeshOrigin
|
|
+ FIntVector(TileX * CHUNK_SIZE, TileY * CHUNK_SIZE, TileZ * CHUNK_SIZE);
|
|
TileMeshes[LinearTileIndex] = World.Mesher->GenerateMesh(
|
|
TileOrigin,
|
|
Arguments.ExportStep,
|
|
CHUNK_SIZE / Arguments.ExportStep);
|
|
|
|
FString TileError;
|
|
if (!ValidateCanonicalTile(TileMeshes[LinearTileIndex], TileError))
|
|
{
|
|
TileErrors[LinearTileIndex] = MoveTemp(TileError);
|
|
bTileWorkCancelled.store(true, std::memory_order_relaxed);
|
|
const double TileEndSeconds = FPlatformTime::Seconds();
|
|
TileEndOffsets[LinearTileIndex] = TileEndSeconds - ParallelStartSeconds;
|
|
TileDurations[LinearTileIndex] = TileEndSeconds - TileStartSeconds;
|
|
return;
|
|
}
|
|
TileCompleted[LinearTileIndex] = 1;
|
|
const double TileEndSeconds = FPlatformTime::Seconds();
|
|
TileEndOffsets[LinearTileIndex] = TileEndSeconds - ParallelStartSeconds;
|
|
TileDurations[LinearTileIndex] = TileEndSeconds - TileStartSeconds;
|
|
},
|
|
EParallelForFlags::None);
|
|
const double ParallelEndSeconds = FPlatformTime::Seconds();
|
|
|
|
TArray<double> CompletedTileDurations;
|
|
TSet<uint32> WorkerIds;
|
|
double EarliestTileStart = 1.0e30;
|
|
double LatestTileEnd = 0.0;
|
|
for (int32 TileIndex = 0; TileIndex < World.ExploreMeshTileCount; ++TileIndex)
|
|
{
|
|
if (!TileCompleted[TileIndex])
|
|
{
|
|
continue;
|
|
}
|
|
CompletedTileDurations.Add(TileDurations[TileIndex]);
|
|
WorkerIds.Add(TileThreadIds[TileIndex]);
|
|
EarliestTileStart = FMath::Min(EarliestTileStart, TileStartOffsets[TileIndex]);
|
|
LatestTileEnd = FMath::Max(LatestTileEnd, TileEndOffsets[TileIndex]);
|
|
}
|
|
if (CompletedTileDurations.Num() > 0)
|
|
{
|
|
CompletedTileDurations.Sort();
|
|
World.ExploreMeshTaskJobCount = CompletedTileDurations.Num();
|
|
World.ExploreMeshTaskWorkerCount = WorkerIds.Num();
|
|
World.ExploreMeshTaskMinSeconds = CompletedTileDurations[0];
|
|
World.ExploreMeshTaskMaxSeconds = CompletedTileDurations.Last();
|
|
for (const double Duration : CompletedTileDurations)
|
|
{
|
|
World.ExploreMeshTaskSumSeconds += Duration;
|
|
}
|
|
World.ExploreMeshTaskMeanSeconds = World.ExploreMeshTaskSumSeconds
|
|
/ static_cast<double>(CompletedTileDurations.Num());
|
|
}
|
|
World.ExploreMeshTaskWallSeconds = ParallelEndSeconds - ParallelStartSeconds;
|
|
if (EarliestTileStart < 1.0e30)
|
|
{
|
|
World.ExploreMeshTaskLaunchOverheadSeconds = FMath::Max(0.0, EarliestTileStart)
|
|
+ FMath::Max(0.0, World.ExploreMeshTaskWallSeconds - LatestTileEnd);
|
|
}
|
|
|
|
int32 CompletedTiles = 0;
|
|
FString MeshError;
|
|
for (; CompletedTiles < World.ExploreMeshTileCount; ++CompletedTiles)
|
|
{
|
|
if (!TileCompleted[CompletedTiles])
|
|
{
|
|
break;
|
|
}
|
|
if (!AppendCanonicalMesh(
|
|
World.ExploreMesh,
|
|
TileMeshes[CompletedTiles],
|
|
MeshError))
|
|
{
|
|
World.ExploreMeshError = MeshError;
|
|
OutError = MeshError;
|
|
World.ExploreMeshTilesCompleted = CompletedTiles;
|
|
World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds;
|
|
return false;
|
|
}
|
|
World.ExploreTileTriangleCounts[CompletedTiles] =
|
|
TileMeshes[CompletedTiles].Triangles.Num() / 3;
|
|
World.ExploreTileGeometryHashes[CompletedTiles] =
|
|
ComputeGeometryHash(TileMeshes[CompletedTiles]);
|
|
World.ExploreMeshTilesCompleted = CompletedTiles + 1;
|
|
}
|
|
|
|
if (CompletedTiles < World.ExploreMeshTileCount)
|
|
{
|
|
if (!TileErrors[CompletedTiles].IsEmpty())
|
|
{
|
|
World.ExploreMeshError = TileErrors[CompletedTiles];
|
|
OutError = World.ExploreMeshError;
|
|
World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds;
|
|
return false;
|
|
}
|
|
|
|
Budget.ShouldStop(TEXT("mesh"));
|
|
World.ExploreMeshError = FString::Printf(
|
|
TEXT("Mesh was truncated after %d/%d canonical tiles."),
|
|
CompletedTiles,
|
|
World.ExploreMeshTileCount);
|
|
OutError = World.ExploreMeshError;
|
|
World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds;
|
|
return false;
|
|
}
|
|
|
|
const double MeshEndSeconds = FPlatformTime::Seconds();
|
|
World.ExploreMeshSeconds = MeshEndSeconds - MeshStartSeconds;
|
|
World.ExploreMeshMergeSeconds = MeshEndSeconds - ParallelEndSeconds;
|
|
const double MeshStageSumSeconds = World.ExploreMeshPreparationSeconds
|
|
+ World.ExploreDensityGridSeconds
|
|
+ World.ExploreMeshPreTileSetupSeconds
|
|
+ World.ExploreMeshTaskWallSeconds
|
|
+ World.ExploreMeshMergeSeconds;
|
|
World.ExploreMeshAccountingRemainderSeconds =
|
|
World.ExploreMeshSeconds - MeshStageSumSeconds;
|
|
World.bExploreMeshComplete = true;
|
|
const double GeometryHashStartSeconds = FPlatformTime::Seconds();
|
|
World.ExploreGeometryHash = ComputeGeometryHash(World.ExploreMesh);
|
|
World.ExploreMeshHashSeconds = FPlatformTime::Seconds() - GeometryHashStartSeconds;
|
|
OutError.Reset();
|
|
return true;
|
|
}
|
|
|
|
float ExploreAxisValue(const FVector& Value, int32 Axis)
|
|
{
|
|
switch (Axis)
|
|
{
|
|
case 1: return Value.Y;
|
|
case 2: return Value.Z;
|
|
default: return Value.X;
|
|
}
|
|
}
|
|
|
|
FVector ExploreTriangleMin(const FVector& A, const FVector& B, const FVector& C)
|
|
{
|
|
return FVector(
|
|
FMath::Min3(A.X, B.X, C.X),
|
|
FMath::Min3(A.Y, B.Y, C.Y),
|
|
FMath::Min3(A.Z, B.Z, C.Z));
|
|
}
|
|
|
|
FVector ExploreTriangleMax(const FVector& A, const FVector& B, const FVector& C)
|
|
{
|
|
return FVector(
|
|
FMath::Max3(A.X, B.X, C.X),
|
|
FMath::Max3(A.Y, B.Y, C.Y),
|
|
FMath::Max3(A.Z, B.Z, C.Z));
|
|
}
|
|
|
|
bool EnsureExploreAcceleration(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
FExploreBudget& Budget,
|
|
FString& OutError)
|
|
{
|
|
if (World.bExploreAccelerationBuilt)
|
|
{
|
|
OutError.Reset();
|
|
return true;
|
|
}
|
|
|
|
World.ExploreTriangles.Reset();
|
|
World.ExploreTriangleOrder.Reset();
|
|
World.ExploreBvhNodes.Reset();
|
|
World.ExploreBvhRoot = INDEX_NONE;
|
|
World.ExploreAccelerationSeconds = 0.0;
|
|
const double AccelerationStartSeconds = FPlatformTime::Seconds();
|
|
|
|
if (!World.bExploreMeshComplete)
|
|
{
|
|
OutError = TEXT("Cannot build mesh acceleration before the canonical mesh is complete.");
|
|
return false;
|
|
}
|
|
if (Budget.ShouldStop(TEXT("mesh-acceleration")))
|
|
{
|
|
OutError = TEXT("The wall-clock budget elapsed before mesh acceleration began.");
|
|
return false;
|
|
}
|
|
|
|
const FVoxelMeshData& Mesh = World.ExploreMesh;
|
|
if (Mesh.Triangles.Num() % 3 != 0)
|
|
{
|
|
OutError = TEXT("Canonical mesh triangle index data is not divisible by three.");
|
|
return false;
|
|
}
|
|
|
|
World.ExploreTriangles.Reserve(Mesh.Triangles.Num() / 3);
|
|
for (int32 TriangleIndex = 0;
|
|
TriangleIndex + 2 < Mesh.Triangles.Num();
|
|
TriangleIndex += 3)
|
|
{
|
|
if ((TriangleIndex & 16383) == 0
|
|
&& Budget.ShouldStop(TEXT("mesh-acceleration")))
|
|
{
|
|
OutError = TEXT("Mesh acceleration was truncated before its triangle cache completed.");
|
|
World.ExploreTriangles.Reset();
|
|
return false;
|
|
}
|
|
|
|
const int32 IndexA = Mesh.Triangles[TriangleIndex];
|
|
const int32 IndexB = Mesh.Triangles[TriangleIndex + 1];
|
|
const int32 IndexC = Mesh.Triangles[TriangleIndex + 2];
|
|
if (!Mesh.Vertices.IsValidIndex(IndexA)
|
|
|| !Mesh.Vertices.IsValidIndex(IndexB)
|
|
|| !Mesh.Vertices.IsValidIndex(IndexC))
|
|
{
|
|
OutError = TEXT("Canonical mesh acceleration saw an out-of-range triangle index.");
|
|
World.ExploreTriangles.Reset();
|
|
return false;
|
|
}
|
|
|
|
const FVector A = Mesh.Vertices[IndexA] / VOXEL_SIZE;
|
|
const FVector B = Mesh.Vertices[IndexB] / VOXEL_SIZE;
|
|
const FVector C = Mesh.Vertices[IndexC] / VOXEL_SIZE;
|
|
if (!FMath::IsFinite(A.X) || !FMath::IsFinite(A.Y) || !FMath::IsFinite(A.Z)
|
|
|| !FMath::IsFinite(B.X) || !FMath::IsFinite(B.Y) || !FMath::IsFinite(B.Z)
|
|
|| !FMath::IsFinite(C.X) || !FMath::IsFinite(C.Y) || !FMath::IsFinite(C.Z))
|
|
{
|
|
OutError = TEXT("Canonical mesh acceleration saw a non-finite vertex.");
|
|
World.ExploreTriangles.Reset();
|
|
return false;
|
|
}
|
|
|
|
const FVector Cross = FVector::CrossProduct(B - A, C - A);
|
|
if (Cross.SizeSquared() <= SMALL_NUMBER)
|
|
{
|
|
// Degenerate canonical triangles have no raster contribution. Keep them out of the
|
|
// acceleration structure without changing the source mesh or its export semantics.
|
|
continue;
|
|
}
|
|
|
|
FExploreMeshTriangle& Triangle = World.ExploreTriangles.AddDefaulted_GetRef();
|
|
Triangle.A = A;
|
|
Triangle.B = B;
|
|
Triangle.C = C;
|
|
Triangle.NormalA = Mesh.Normals[IndexA].GetSafeNormal();
|
|
Triangle.NormalB = Mesh.Normals[IndexB].GetSafeNormal();
|
|
Triangle.NormalC = Mesh.Normals[IndexC].GetSafeNormal();
|
|
Triangle.Min = ExploreTriangleMin(A, B, C);
|
|
Triangle.Max = ExploreTriangleMax(A, B, C);
|
|
Triangle.Centre = (A + B + C) / 3.0f;
|
|
Triangle.Normal = Cross.GetSafeNormal();
|
|
}
|
|
|
|
if (World.ExploreTriangles.Num() == 0)
|
|
{
|
|
// A valid empty mesh is still a complete mesh render: every pixel remains the explicit
|
|
// background colour. The root sentinel lets the rasterizer handle this deterministically.
|
|
World.ExploreAccelerationSeconds = FPlatformTime::Seconds() - AccelerationStartSeconds;
|
|
World.bExploreAccelerationBuilt = true;
|
|
OutError.Reset();
|
|
return true;
|
|
}
|
|
|
|
World.ExploreTriangleOrder.SetNumUninitialized(World.ExploreTriangles.Num());
|
|
for (int32 TriangleIndex = 0;
|
|
TriangleIndex < World.ExploreTriangleOrder.Num();
|
|
++TriangleIndex)
|
|
{
|
|
World.ExploreTriangleOrder[TriangleIndex] = TriangleIndex;
|
|
}
|
|
|
|
World.ExploreBvhNodes.Reserve(World.ExploreTriangles.Num() / 4 + 2);
|
|
bool bBuildFailed = false;
|
|
TFunction<int32(int32, int32)> BuildNode;
|
|
BuildNode = [&](int32 Start, int32 Count) -> int32
|
|
{
|
|
if (bBuildFailed || Count <= 0)
|
|
{
|
|
return INDEX_NONE;
|
|
}
|
|
if (Budget.ShouldStop(TEXT("mesh-acceleration")))
|
|
{
|
|
bBuildFailed = true;
|
|
return INDEX_NONE;
|
|
}
|
|
|
|
const int32 NodeIndex = World.ExploreBvhNodes.AddDefaulted();
|
|
FExploreMeshBvhNode Node;
|
|
Node.Min = FVector(FLT_MAX, FLT_MAX, FLT_MAX);
|
|
Node.Max = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX);
|
|
FVector CentroidMin = FVector(FLT_MAX, FLT_MAX, FLT_MAX);
|
|
FVector CentroidMax = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX);
|
|
for (int32 Offset = 0; Offset < Count; ++Offset)
|
|
{
|
|
const int32 TriangleIndex = World.ExploreTriangleOrder[Start + Offset];
|
|
const FExploreMeshTriangle& Triangle = World.ExploreTriangles[TriangleIndex];
|
|
Node.Min.X = FMath::Min(Node.Min.X, Triangle.Min.X);
|
|
Node.Min.Y = FMath::Min(Node.Min.Y, Triangle.Min.Y);
|
|
Node.Min.Z = FMath::Min(Node.Min.Z, Triangle.Min.Z);
|
|
Node.Max.X = FMath::Max(Node.Max.X, Triangle.Max.X);
|
|
Node.Max.Y = FMath::Max(Node.Max.Y, Triangle.Max.Y);
|
|
Node.Max.Z = FMath::Max(Node.Max.Z, Triangle.Max.Z);
|
|
CentroidMin.X = FMath::Min(CentroidMin.X, Triangle.Centre.X);
|
|
CentroidMin.Y = FMath::Min(CentroidMin.Y, Triangle.Centre.Y);
|
|
CentroidMin.Z = FMath::Min(CentroidMin.Z, Triangle.Centre.Z);
|
|
CentroidMax.X = FMath::Max(CentroidMax.X, Triangle.Centre.X);
|
|
CentroidMax.Y = FMath::Max(CentroidMax.Y, Triangle.Centre.Y);
|
|
CentroidMax.Z = FMath::Max(CentroidMax.Z, Triangle.Centre.Z);
|
|
}
|
|
|
|
constexpr int32 LeafTriangleCount = 8;
|
|
if (Count <= LeafTriangleCount)
|
|
{
|
|
Node.FirstTriangle = Start;
|
|
Node.TriangleCount = Count;
|
|
World.ExploreBvhNodes[NodeIndex] = Node;
|
|
return NodeIndex;
|
|
}
|
|
|
|
const FVector CentroidExtent = CentroidMax - CentroidMin;
|
|
int32 SplitAxis = 0;
|
|
if (CentroidExtent.Y > CentroidExtent.X
|
|
&& CentroidExtent.Y >= CentroidExtent.Z)
|
|
{
|
|
SplitAxis = 1;
|
|
}
|
|
else if (CentroidExtent.Z > CentroidExtent.X
|
|
&& CentroidExtent.Z > CentroidExtent.Y)
|
|
{
|
|
SplitAxis = 2;
|
|
}
|
|
|
|
int32* OrderBegin = World.ExploreTriangleOrder.GetData() + Start;
|
|
int32* OrderEnd = OrderBegin + Count;
|
|
std::sort(
|
|
OrderBegin,
|
|
OrderEnd,
|
|
[&](int32 LeftIndex, int32 RightIndex)
|
|
{
|
|
const float LeftValue = ExploreAxisValue(
|
|
World.ExploreTriangles[LeftIndex].Centre, SplitAxis);
|
|
const float RightValue = ExploreAxisValue(
|
|
World.ExploreTriangles[RightIndex].Centre, SplitAxis);
|
|
if (LeftValue != RightValue)
|
|
{
|
|
return LeftValue < RightValue;
|
|
}
|
|
return LeftIndex < RightIndex;
|
|
});
|
|
|
|
const int32 LeftCount = Count / 2;
|
|
const int32 RightCount = Count - LeftCount;
|
|
const int32 LeftNode = BuildNode(Start, LeftCount);
|
|
const int32 RightNode = BuildNode(Start + LeftCount, RightCount);
|
|
if (LeftNode == INDEX_NONE || RightNode == INDEX_NONE)
|
|
{
|
|
bBuildFailed = true;
|
|
return INDEX_NONE;
|
|
}
|
|
|
|
Node.Left = LeftNode;
|
|
Node.Right = RightNode;
|
|
World.ExploreBvhNodes[NodeIndex] = Node;
|
|
return NodeIndex;
|
|
};
|
|
|
|
World.ExploreBvhRoot = BuildNode(0, World.ExploreTriangles.Num());
|
|
if (bBuildFailed || World.ExploreBvhRoot == INDEX_NONE || Budget.bTruncated)
|
|
{
|
|
World.ExploreTriangles.Reset();
|
|
World.ExploreTriangleOrder.Reset();
|
|
World.ExploreBvhNodes.Reset();
|
|
World.ExploreBvhRoot = INDEX_NONE;
|
|
OutError = TEXT("Mesh acceleration was truncated before its BVH completed.");
|
|
return false;
|
|
}
|
|
|
|
World.ExploreAccelerationSeconds = FPlatformTime::Seconds() - AccelerationStartSeconds;
|
|
World.bExploreAccelerationBuilt = true;
|
|
OutError.Reset();
|
|
return true;
|
|
}
|
|
|
|
bool ExploreRayAabb(
|
|
const FVector& Origin,
|
|
const FVector& Direction,
|
|
const FVector& BoundsMin,
|
|
const FVector& BoundsMax,
|
|
float MaxDistance,
|
|
float& OutNear)
|
|
{
|
|
float NearDistance = 0.0f;
|
|
float FarDistance = MaxDistance;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
const float OriginComponent = ExploreAxisValue(Origin, Axis);
|
|
const float DirectionComponent = ExploreAxisValue(Direction, Axis);
|
|
const float MinComponent = ExploreAxisValue(BoundsMin, Axis);
|
|
const float MaxComponent = ExploreAxisValue(BoundsMax, Axis);
|
|
if (FMath::Abs(DirectionComponent) <= KINDA_SMALL_NUMBER)
|
|
{
|
|
if (OriginComponent < MinComponent || OriginComponent > MaxComponent)
|
|
{
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const float InverseDirection = 1.0f / DirectionComponent;
|
|
float AxisNear = (MinComponent - OriginComponent) * InverseDirection;
|
|
float AxisFar = (MaxComponent - OriginComponent) * InverseDirection;
|
|
if (AxisNear > AxisFar)
|
|
{
|
|
Swap(AxisNear, AxisFar);
|
|
}
|
|
NearDistance = FMath::Max(NearDistance, AxisNear);
|
|
FarDistance = FMath::Min(FarDistance, AxisFar);
|
|
if (NearDistance > FarDistance)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
OutNear = NearDistance;
|
|
return true;
|
|
}
|
|
|
|
bool ExploreRayTriangle(
|
|
const FVector& Origin,
|
|
const FVector& Direction,
|
|
const FExploreMeshTriangle& Triangle,
|
|
float MaxDistance,
|
|
float& OutDistance,
|
|
float& OutU,
|
|
float& OutV)
|
|
{
|
|
const FVector Edge1 = Triangle.B - Triangle.A;
|
|
const FVector Edge2 = Triangle.C - Triangle.A;
|
|
const FVector Perpendicular = FVector::CrossProduct(Direction, Edge2);
|
|
const float Determinant = FVector::DotProduct(Edge1, Perpendicular);
|
|
if (FMath::Abs(Determinant) <= KINDA_SMALL_NUMBER)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const float InverseDeterminant = 1.0f / Determinant;
|
|
const FVector ToOrigin = Origin - Triangle.A;
|
|
const float U = FVector::DotProduct(ToOrigin, Perpendicular) * InverseDeterminant;
|
|
if (U < 0.0f || U > 1.0f)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVector CrossOrigin = FVector::CrossProduct(ToOrigin, Edge1);
|
|
const float V = FVector::DotProduct(Direction, CrossOrigin) * InverseDeterminant;
|
|
if (V < 0.0f || U + V > 1.0f)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const float Distance = FVector::DotProduct(Edge2, CrossOrigin) * InverseDeterminant;
|
|
if (Distance <= 0.01f || Distance >= MaxDistance)
|
|
{
|
|
return false;
|
|
}
|
|
OutDistance = Distance;
|
|
OutU = U;
|
|
OutV = V;
|
|
return true;
|
|
}
|
|
|
|
bool RasterizeExploreMesh(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
const FVector& CameraPosition,
|
|
const FVector& Target,
|
|
FExploreBudget& Budget,
|
|
TArray<FColor>& OutPixels,
|
|
int32& OutHitPixels,
|
|
bool& bOutTruncated)
|
|
{
|
|
OutPixels.Init(FColor(12, 18, 28, 255), Arguments.RenderWidth * Arguments.RenderHeight);
|
|
OutHitPixels = 0;
|
|
bOutTruncated = false;
|
|
|
|
if (!World.bExploreAccelerationBuilt)
|
|
{
|
|
FString AccelerationError;
|
|
if (!EnsureExploreAcceleration(Arguments, World, Budget, AccelerationError))
|
|
{
|
|
if (Budget.bTruncated)
|
|
{
|
|
bOutTruncated = true;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
FExploreCameraBasis Basis;
|
|
if (!BuildCameraBasis(
|
|
CameraPosition,
|
|
Target,
|
|
70.0f,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
Basis))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVector KeyLightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal();
|
|
const FVector FillLightDirection = FVector(0.65f, 0.30f, 0.70f).GetSafeNormal();
|
|
constexpr float AmbientFill = 0.62f;
|
|
constexpr float KeyLightStrength = 0.50f;
|
|
constexpr float FillLightStrength = 0.24f;
|
|
|
|
for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY)
|
|
{
|
|
if (Budget.ShouldStop(TEXT("raster")))
|
|
{
|
|
bOutTruncated = true;
|
|
return true;
|
|
}
|
|
|
|
for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX)
|
|
{
|
|
const float NormalizedX = (2.0f * (static_cast<float>(PixelX) + 0.5f)
|
|
/ static_cast<float>(Arguments.RenderWidth)) - 1.0f;
|
|
const float NormalizedY = 1.0f - (2.0f * (static_cast<float>(PixelY) + 0.5f)
|
|
/ static_cast<float>(Arguments.RenderHeight));
|
|
const FVector Direction = (
|
|
Basis.Forward
|
|
+ Basis.Right * (NormalizedX * Basis.Aspect * Basis.TanHalfFov)
|
|
+ Basis.Up * (NormalizedY * Basis.TanHalfFov)).GetSafeNormal();
|
|
|
|
float ClosestDistance = Arguments.RenderMaxDistanceVoxels;
|
|
int32 ClosestTriangleIndex = INDEX_NONE;
|
|
float ClosestU = 0.0f;
|
|
float ClosestV = 0.0f;
|
|
int32 NodeStack[256];
|
|
int32 StackCount = 0;
|
|
if (World.ExploreBvhRoot != INDEX_NONE)
|
|
{
|
|
NodeStack[StackCount++] = World.ExploreBvhRoot;
|
|
}
|
|
|
|
while (StackCount > 0)
|
|
{
|
|
const int32 NodeIndex = NodeStack[--StackCount];
|
|
if (!World.ExploreBvhNodes.IsValidIndex(NodeIndex))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FExploreMeshBvhNode& Node = World.ExploreBvhNodes[NodeIndex];
|
|
float NodeNear = 0.0f;
|
|
if (!ExploreRayAabb(
|
|
CameraPosition,
|
|
Direction,
|
|
Node.Min,
|
|
Node.Max,
|
|
ClosestDistance,
|
|
NodeNear))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (Node.IsLeaf())
|
|
{
|
|
for (int32 Offset = 0; Offset < Node.TriangleCount; ++Offset)
|
|
{
|
|
const int32 OrderIndex = Node.FirstTriangle + Offset;
|
|
if (!World.ExploreTriangleOrder.IsValidIndex(OrderIndex))
|
|
{
|
|
continue;
|
|
}
|
|
const int32 TriangleIndex = World.ExploreTriangleOrder[OrderIndex];
|
|
if (!World.ExploreTriangles.IsValidIndex(TriangleIndex))
|
|
{
|
|
continue;
|
|
}
|
|
float TriangleDistance = 0.0f;
|
|
float TriangleU = 0.0f;
|
|
float TriangleV = 0.0f;
|
|
if (ExploreRayTriangle(
|
|
CameraPosition,
|
|
Direction,
|
|
World.ExploreTriangles[TriangleIndex],
|
|
ClosestDistance,
|
|
TriangleDistance,
|
|
TriangleU,
|
|
TriangleV))
|
|
{
|
|
ClosestDistance = TriangleDistance;
|
|
ClosestTriangleIndex = TriangleIndex;
|
|
ClosestU = TriangleU;
|
|
ClosestV = TriangleV;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const int32 LeftNodeIndex = Node.Left;
|
|
const int32 RightNodeIndex = Node.Right;
|
|
float LeftNear = 0.0f;
|
|
float RightNear = 0.0f;
|
|
const bool bHitLeft = World.ExploreBvhNodes.IsValidIndex(LeftNodeIndex)
|
|
&& ExploreRayAabb(
|
|
CameraPosition,
|
|
Direction,
|
|
World.ExploreBvhNodes[LeftNodeIndex].Min,
|
|
World.ExploreBvhNodes[LeftNodeIndex].Max,
|
|
ClosestDistance,
|
|
LeftNear);
|
|
const bool bHitRight = World.ExploreBvhNodes.IsValidIndex(RightNodeIndex)
|
|
&& ExploreRayAabb(
|
|
CameraPosition,
|
|
Direction,
|
|
World.ExploreBvhNodes[RightNodeIndex].Min,
|
|
World.ExploreBvhNodes[RightNodeIndex].Max,
|
|
ClosestDistance,
|
|
RightNear);
|
|
if (bHitLeft && bHitRight)
|
|
{
|
|
if (LeftNear < RightNear)
|
|
{
|
|
if (StackCount + 2 <= UE_ARRAY_COUNT(NodeStack))
|
|
{
|
|
NodeStack[StackCount++] = RightNodeIndex;
|
|
NodeStack[StackCount++] = LeftNodeIndex;
|
|
}
|
|
}
|
|
else if (StackCount + 2 <= UE_ARRAY_COUNT(NodeStack))
|
|
{
|
|
NodeStack[StackCount++] = LeftNodeIndex;
|
|
NodeStack[StackCount++] = RightNodeIndex;
|
|
}
|
|
}
|
|
else if (bHitLeft && StackCount < UE_ARRAY_COUNT(NodeStack))
|
|
{
|
|
NodeStack[StackCount++] = LeftNodeIndex;
|
|
}
|
|
else if (bHitRight && StackCount < UE_ARRAY_COUNT(NodeStack))
|
|
{
|
|
NodeStack[StackCount++] = RightNodeIndex;
|
|
}
|
|
}
|
|
|
|
if (ClosestTriangleIndex == INDEX_NONE)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const FExploreMeshTriangle& Triangle = World.ExploreTriangles[ClosestTriangleIndex];
|
|
FVector ShadingNormal = (
|
|
Triangle.NormalA * (1.0f - ClosestU - ClosestV)
|
|
+ Triangle.NormalB * ClosestU
|
|
+ Triangle.NormalC * ClosestV).GetSafeNormal();
|
|
if (ShadingNormal.IsNearlyZero())
|
|
{
|
|
// The canonical mesher supplies non-zero gradient normals. Keep a defensive
|
|
// fallback for malformed mesh data without reintroducing camera-facing flips.
|
|
ShadingNormal = Triangle.Normal;
|
|
}
|
|
const float Key = FMath::Max(
|
|
0.0f, FVector::DotProduct(ShadingNormal, KeyLightDirection));
|
|
const float Fill = FMath::Max(
|
|
0.0f, FVector::DotProduct(ShadingNormal, FillLightDirection));
|
|
const float Brightness = FMath::Clamp(
|
|
AmbientFill + KeyLightStrength * Key + FillLightStrength * Fill,
|
|
0.0f,
|
|
1.25f);
|
|
const int32 PixelIndex = PixelX + Arguments.RenderWidth * PixelY;
|
|
OutPixels[PixelIndex] = FColor(
|
|
static_cast<uint8>(FMath::Clamp(100.0f * Brightness, 0.0f, 255.0f)),
|
|
static_cast<uint8>(FMath::Clamp(128.0f * Brightness, 0.0f, 255.0f)),
|
|
static_cast<uint8>(FMath::Clamp(160.0f * Brightness, 0.0f, 255.0f)),
|
|
255);
|
|
++OutHitPixels;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool RunRender(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
const FExploreWalkOutput& CameraSeedWalk,
|
|
FExploreRenderOutput& OutOutput,
|
|
FExploreBudget& Budget)
|
|
{
|
|
OutOutput = FExploreRenderOutput();
|
|
OutOutput.Width = Arguments.RenderWidth;
|
|
OutOutput.Height = Arguments.RenderHeight;
|
|
OutOutput.FixedStepVoxels = Arguments.RenderStepVoxels;
|
|
OutOutput.FixedStepMeters = Arguments.RenderStepVoxels
|
|
* FVoxelPlayerCapsuleConstants::VoxelSizeMeters;
|
|
OutOutput.BisectionIterations = 0;
|
|
OutOutput.MaxDistanceVoxels = Arguments.RenderMaxDistanceVoxels;
|
|
OutOutput.EstimatedFixedDensitySamples = 0;
|
|
OutOutput.LegacyTwoViewEstimatedDensitySamples = static_cast<int64>(Arguments.RenderWidth)
|
|
* static_cast<int64>(Arguments.RenderHeight)
|
|
* 3ll
|
|
* static_cast<int64>(FMath::Max(
|
|
1,
|
|
FMath::CeilToInt(
|
|
Arguments.RenderMaxDistanceVoxels / Arguments.RenderStepVoxels)))
|
|
* 2ll;
|
|
OutOutput.RequestedViewpointCount = 8;
|
|
OutOutput.StepRationale = TEXT(
|
|
"The renderer meshes the bounded region once with UVoxelMarchingCubesMesher, then CPU "
|
|
"rasterises its canonical triangles through a deterministic BVH-assisted depth pass. "
|
|
"-renderstep is retained only for legacy invocation compatibility; no render pixel samples "
|
|
"the density field.");
|
|
|
|
OutOutput.CameraSeedPolicy = TEXT(
|
|
"Eight deterministic viewpoints are selected from the one shared player-fit mask. Their "
|
|
"eye-height origins are density-checked, and their azimuths are rotated in fixed 45-degree "
|
|
"increments so every image is from inside walkable space.");
|
|
|
|
FString MeshError;
|
|
if (!EnsureExploreMesh(Arguments, World, &CameraSeedWalk, Budget, MeshError))
|
|
{
|
|
OutOutput.MeshSeconds = World.ExploreMeshSeconds;
|
|
OutOutput.bMeshComplete = World.bExploreMeshComplete;
|
|
if (Budget.bTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.bTruncated = true;
|
|
OutOutput.RefusalReason = MeshError;
|
|
}
|
|
else
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = MeshError;
|
|
}
|
|
return false;
|
|
}
|
|
OutOutput.MeshSeconds = World.ExploreMeshSeconds;
|
|
OutOutput.bMeshComplete = true;
|
|
FString AccelerationError;
|
|
if (!EnsureExploreAcceleration(Arguments, World, Budget, AccelerationError))
|
|
{
|
|
OutOutput.MeshAccelerationSeconds = World.ExploreAccelerationSeconds;
|
|
if (Budget.bTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.bTruncated = true;
|
|
OutOutput.RefusalReason = AccelerationError;
|
|
}
|
|
else
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = AccelerationError;
|
|
}
|
|
return false;
|
|
}
|
|
OutOutput.MeshAccelerationSeconds = World.ExploreAccelerationSeconds;
|
|
const double RasterStartSeconds = FPlatformTime::Seconds();
|
|
|
|
TArray<TPair<FVector, FString>, TInlineAllocator<8>> CameraSeeds;
|
|
const auto AddCameraSeed = [&](bool bAvailable, const FVector& Position, const TCHAR* Source)
|
|
{
|
|
if (!bAvailable
|
|
|| !FMath::IsFinite(Position.X)
|
|
|| !FMath::IsFinite(Position.Y)
|
|
|| !FMath::IsFinite(Position.Z))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The walk mask is authoritative, but this guard catches stale/corrupt report data before
|
|
// a render can silently start inside rock.
|
|
const float Density = World.Generator->GetDensityAt(Position.X, Position.Y, Position.Z);
|
|
if (!FMath::IsFinite(Density) || !(Density > 0.0f))
|
|
{
|
|
return;
|
|
}
|
|
for (const TPair<FVector, FString>& Existing : CameraSeeds)
|
|
{
|
|
if (Existing.Key.Equals(Position, 0.001f))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
CameraSeeds.Emplace(Position, FString(Source));
|
|
};
|
|
|
|
if (Arguments.bRenderCameraOverride)
|
|
{
|
|
AddCameraSeed(true, Arguments.RenderCamera, TEXT("render_camera_override"));
|
|
}
|
|
|
|
for (int32 SeedIndex = 0; SeedIndex < CameraSeedWalk.CameraSeedPoses.Num(); ++SeedIndex)
|
|
{
|
|
const FString Source = CameraSeedWalk.CameraSeedSources.IsValidIndex(SeedIndex)
|
|
? CameraSeedWalk.CameraSeedSources[SeedIndex]
|
|
: FString::Printf(TEXT("player_fit_%02d"), SeedIndex);
|
|
AddCameraSeed(
|
|
true,
|
|
CameraSeedWalk.CameraSeedPoses[SeedIndex],
|
|
*Source);
|
|
if (CameraSeeds.Num() >= OutOutput.RequestedViewpointCount)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (CameraSeeds.Num() == 0)
|
|
{
|
|
OutOutput.Status = TEXT("refused");
|
|
OutOutput.RefusalReason = TEXT(
|
|
"The player-fit seed pass produced no density-positive pose; refusing an inside-rock camera.");
|
|
return false;
|
|
}
|
|
|
|
// A small room can contain fewer than eight distinct lattice cells. Reusing the same valid
|
|
// player-fit origin with another deterministic angle is still a valid viewpoint and keeps
|
|
// the contract (all camera origins are inside walkable space) without fabricating positions.
|
|
while (CameraSeeds.Num() < OutOutput.RequestedViewpointCount)
|
|
{
|
|
const TPair<FVector, FString>& Anchor = CameraSeeds[0];
|
|
CameraSeeds.Emplace(
|
|
Anchor.Key,
|
|
FString::Printf(TEXT("%s_angle_%02d"), *Anchor.Value, CameraSeeds.Num()));
|
|
}
|
|
|
|
OutOutput.CameraSeedCount = CameraSeeds.Num();
|
|
OutOutput.bAllCamerasPlayerFit = true;
|
|
FVector RouteFocus = FVector::ZeroVector;
|
|
int32 RouteFocusCount = 0;
|
|
if (Arguments.bRenderTargetOverride)
|
|
{
|
|
RouteFocus = Arguments.RenderTarget;
|
|
RouteFocusCount = 1;
|
|
}
|
|
else if (CameraSeedWalk.bHasArrival)
|
|
{
|
|
RouteFocus += CameraSeedWalk.ArrivalVoxels;
|
|
++RouteFocusCount;
|
|
}
|
|
if (CameraSeedWalk.bHasDeparture)
|
|
{
|
|
RouteFocus += CameraSeedWalk.DepartureVoxels;
|
|
++RouteFocusCount;
|
|
}
|
|
if (RouteFocusCount > 0)
|
|
{
|
|
RouteFocus /= static_cast<float>(RouteFocusCount);
|
|
}
|
|
else
|
|
{
|
|
RouteFocus = CameraSeeds[0].Key;
|
|
}
|
|
|
|
for (int32 ViewIndex = 0; ViewIndex < CameraSeeds.Num(); ++ViewIndex)
|
|
{
|
|
if (Budget.ShouldStop(TEXT("render")))
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.bTruncated = true;
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget stopped viewpoint generation.");
|
|
return false;
|
|
}
|
|
|
|
const FVector CameraSeedPose = CameraSeeds[ViewIndex].Key;
|
|
const FVector CameraPosition = CameraSeedPose
|
|
+ FVector::UpVector * (FVoxelPlayerCapsuleConstants::HalfHeightVoxels - 0.5f);
|
|
const float CameraDensity = World.Generator->GetDensityAt(
|
|
CameraPosition.X, CameraPosition.Y, CameraPosition.Z);
|
|
if (!FMath::IsFinite(CameraDensity) || !(CameraDensity > 0.0f))
|
|
{
|
|
OutOutput.Status = TEXT("refused");
|
|
OutOutput.RefusalReason = TEXT(
|
|
"The eye-height offset of a player-fit camera entered solid density.");
|
|
return false;
|
|
}
|
|
FVector LookDirection = RouteFocus - CameraPosition;
|
|
LookDirection.Z = 0.0f;
|
|
LookDirection.Normalize();
|
|
if (LookDirection.IsNearlyZero())
|
|
{
|
|
LookDirection = FVector(
|
|
ViewIndex == 1 ? -0.6f : 0.8f,
|
|
ViewIndex == 2 ? 0.7f : -0.4f,
|
|
0.18f).GetSafeNormal();
|
|
}
|
|
// Keep the camera at the fitted pose while changing the azimuth for each viewpoint.
|
|
LookDirection = LookDirection.RotateAngleAxis(
|
|
45.0f * static_cast<float>(ViewIndex),
|
|
FVector::UpVector).GetSafeNormal();
|
|
const FVector Target = CameraPosition + LookDirection * 96.0f + FVector::UpVector * 8.0f;
|
|
|
|
const double ViewStartSeconds = FPlatformTime::Seconds();
|
|
TArray<FColor> Pixels;
|
|
int32 HitPixels = 0;
|
|
bool bRasterTruncated = false;
|
|
if (!RasterizeExploreMesh(
|
|
Arguments,
|
|
World,
|
|
CameraPosition,
|
|
Target,
|
|
Budget,
|
|
Pixels,
|
|
HitPixels,
|
|
bRasterTruncated))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not construct a mesh raster camera.");
|
|
return false;
|
|
}
|
|
|
|
FExploreCameraBasis Basis;
|
|
if (!BuildCameraBasis(
|
|
CameraPosition,
|
|
Target,
|
|
70.0f,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
Basis))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not construct a render camera basis.");
|
|
return false;
|
|
}
|
|
|
|
bool bScaleMarkerProjected = false;
|
|
DrawScaleMarker(
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
CameraPosition,
|
|
Target,
|
|
Basis,
|
|
bScaleMarkerProjected);
|
|
|
|
FExploreRenderFrame& Frame = OutOutput.Frames.AddDefaulted_GetRef();
|
|
Frame.FileName = FString::Printf(TEXT("render_%02d.png"), ViewIndex);
|
|
Frame.Purpose = CameraSeeds[ViewIndex].Value;
|
|
Frame.CameraSeedSource = CameraSeeds[ViewIndex].Value;
|
|
Frame.CameraSeedPoseVoxels = CameraSeedPose;
|
|
Frame.CameraVoxels = CameraPosition;
|
|
Frame.TargetVoxels = Target;
|
|
Frame.HitPixels = HitPixels;
|
|
Frame.bScaleMarkerProjected = bScaleMarkerProjected;
|
|
Frame.bPartial = bRasterTruncated;
|
|
|
|
FString Error;
|
|
if (!SavePng(
|
|
FPaths::Combine(Arguments.OutDirectory, Frame.FileName),
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
Error))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = Error;
|
|
return false;
|
|
}
|
|
|
|
const double ViewSeconds = FPlatformTime::Seconds() - ViewStartSeconds;
|
|
if (ViewIndex == 0)
|
|
{
|
|
OutOutput.FirstViewRasterSeconds = ViewSeconds;
|
|
}
|
|
|
|
if (bRasterTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.bTruncated = true;
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget stopped triangle rasterisation.");
|
|
break;
|
|
}
|
|
}
|
|
|
|
OutOutput.MeshSeconds = World.ExploreMeshSeconds;
|
|
OutOutput.RasterSeconds = FPlatformTime::Seconds() - RasterStartSeconds;
|
|
const int32 AdditionalViewCount = FMath::Max(0, OutOutput.Frames.Num() - 1);
|
|
OutOutput.AdditionalViewRasterSeconds = AdditionalViewCount > 0
|
|
? (OutOutput.RasterSeconds - OutOutput.FirstViewRasterSeconds)
|
|
/ static_cast<double>(AdditionalViewCount)
|
|
: 0.0;
|
|
if (!OutOutput.bTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("ok");
|
|
}
|
|
return !OutOutput.bTruncated;
|
|
}
|
|
|
|
void DrawPointMarker(
|
|
TArray<FColor>& Pixels,
|
|
int32 Width,
|
|
int32 Height,
|
|
const FVector& Point,
|
|
const FVector& CameraPosition,
|
|
const FExploreCameraBasis& Basis,
|
|
const FColor& Color,
|
|
bool& bOutProjected)
|
|
{
|
|
FIntPoint Pixel = FIntPoint::ZeroValue;
|
|
bOutProjected = ProjectPoint(
|
|
Point, CameraPosition, Basis, Width, Height, Pixel);
|
|
if (!bOutProjected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
constexpr int32 MarkerRadius = 7;
|
|
DrawLine(
|
|
Pixels, Width, Height,
|
|
Pixel.X - MarkerRadius, Pixel.Y,
|
|
Pixel.X + MarkerRadius, Pixel.Y,
|
|
Color);
|
|
DrawLine(
|
|
Pixels, Width, Height,
|
|
Pixel.X, Pixel.Y - MarkerRadius,
|
|
Pixel.X, Pixel.Y + MarkerRadius,
|
|
Color);
|
|
DrawLine(
|
|
Pixels, Width, Height,
|
|
Pixel.X - 4, Pixel.Y - 4,
|
|
Pixel.X + 4, Pixel.Y + 4,
|
|
Color);
|
|
DrawLine(
|
|
Pixels, Width, Height,
|
|
Pixel.X - 4, Pixel.Y + 4,
|
|
Pixel.X + 4, Pixel.Y - 4,
|
|
Color);
|
|
}
|
|
|
|
bool RunFailureBoundaryRender(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
const FExploreWalkOutput& Walk,
|
|
FExploreRenderOutput& InOutOutput,
|
|
FExploreBudget& Budget)
|
|
{
|
|
const FVoxelPlayerFitWalkReport& Report = Walk.Report;
|
|
if (!Report.bHasAgentFinalPosition || !Report.bHasTargetComponentNearestCell)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const FVector AgentFinal = Report.AgentFinalVoxels;
|
|
const FVector TargetComponentCell = Report.TargetComponentNearestVoxels;
|
|
const FVector LastReached = Report.LastReachedVoxels;
|
|
const bool bHaveLastReached = Report.bHasLastReachedPosition;
|
|
const FVector Focus = (AgentFinal + TargetComponentCell) * 0.5f;
|
|
const FVector Segment = TargetComponentCell - AgentFinal;
|
|
const FVector SegmentDirection = Segment.GetSafeNormal();
|
|
if (SegmentDirection.IsNearlyZero())
|
|
{
|
|
return false;
|
|
}
|
|
FVector ViewSide = FVector::CrossProduct(SegmentDirection, FVector::UpVector).GetSafeNormal();
|
|
if (ViewSide.IsNearlyZero())
|
|
{
|
|
ViewSide = FVector::CrossProduct(SegmentDirection, FVector::RightVector).GetSafeNormal();
|
|
}
|
|
const float SegmentLength = Segment.Size();
|
|
const float ViewSideOffset = FMath::Min(64.0f, FMath::Max(16.0f, 0.25f * SegmentLength));
|
|
// Failure-focus views are still required to originate from the fitted graph. The old
|
|
// implementation nudged the camera four voxels toward/away from the boundary and could put
|
|
// the viewpoint in the very rock that this diagnostic is meant to explain.
|
|
const FVector CameraCandidates[] = {
|
|
AgentFinal,
|
|
bHaveLastReached ? LastReached : AgentFinal,
|
|
};
|
|
const FVector ViewTargets[] = {
|
|
Focus + ViewSide * ViewSideOffset,
|
|
Focus - ViewSide * ViewSideOffset,
|
|
};
|
|
|
|
for (int32 ViewIndex = 0; ViewIndex < UE_ARRAY_COUNT(ViewTargets); ++ViewIndex)
|
|
{
|
|
if (Budget.ShouldStop(TEXT("failure-focus-render")))
|
|
{
|
|
InOutOutput.Status = TEXT("truncated");
|
|
InOutOutput.bTruncated = true;
|
|
InOutOutput.RefusalReason = TEXT(
|
|
"The wall-clock budget stopped failure-focus viewpoint generation.");
|
|
return false;
|
|
}
|
|
|
|
const FVector CameraSeedPose = CameraCandidates[ViewIndex];
|
|
const FVector CameraPosition = CameraSeedPose
|
|
+ FVector::UpVector * (FVoxelPlayerCapsuleConstants::HalfHeightVoxels - 0.5f);
|
|
const float CameraDensity = World.Generator->GetDensityAt(
|
|
CameraPosition.X, CameraPosition.Y, CameraPosition.Z);
|
|
if (!FMath::IsFinite(CameraDensity) || !(CameraDensity > 0.0f))
|
|
{
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeExplore] refusing failure-focus camera %d: fitted pose is not air."),
|
|
ViewIndex);
|
|
return false;
|
|
}
|
|
const FVector Target = ViewTargets[ViewIndex];
|
|
TArray<FColor> Pixels;
|
|
int32 HitPixels = 0;
|
|
bool bRasterTruncated = false;
|
|
if (!RasterizeExploreMesh(
|
|
Arguments,
|
|
World,
|
|
CameraPosition,
|
|
Target,
|
|
Budget,
|
|
Pixels,
|
|
HitPixels,
|
|
bRasterTruncated))
|
|
{
|
|
InOutOutput.Status = TEXT("error");
|
|
InOutOutput.RefusalReason = TEXT("Could not construct failure-focus mesh raster.");
|
|
return false;
|
|
}
|
|
|
|
FExploreCameraBasis Basis;
|
|
if (!BuildCameraBasis(
|
|
CameraPosition,
|
|
Target,
|
|
70.0f,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
Basis))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool bAgentProjected = false;
|
|
bool bTargetProjected = false;
|
|
bool bLastReachedProjected = false;
|
|
DrawPointMarker(
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
AgentFinal,
|
|
CameraPosition,
|
|
Basis,
|
|
FColor(242, 72, 72, 255),
|
|
bAgentProjected);
|
|
DrawPointMarker(
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
TargetComponentCell,
|
|
CameraPosition,
|
|
Basis,
|
|
FColor(72, 242, 112, 255),
|
|
bTargetProjected);
|
|
if (bHaveLastReached)
|
|
{
|
|
DrawPointMarker(
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
LastReached,
|
|
CameraPosition,
|
|
Basis,
|
|
FColor(242, 176, 56, 255),
|
|
bLastReachedProjected);
|
|
}
|
|
|
|
FIntPoint AgentPixel = FIntPoint::ZeroValue;
|
|
FIntPoint TargetPixel = FIntPoint::ZeroValue;
|
|
if (ProjectPoint(
|
|
AgentFinal,
|
|
CameraPosition,
|
|
Basis,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
AgentPixel)
|
|
&& ProjectPoint(
|
|
TargetComponentCell,
|
|
CameraPosition,
|
|
Basis,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
TargetPixel))
|
|
{
|
|
DrawLine(
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
AgentPixel.X,
|
|
AgentPixel.Y,
|
|
TargetPixel.X,
|
|
TargetPixel.Y,
|
|
FColor(244, 214, 72, 255));
|
|
}
|
|
|
|
FExploreRenderFrame& Frame = InOutOutput.Frames.AddDefaulted_GetRef();
|
|
Frame.FileName = FString::Printf(TEXT("failure_boundary_%02d.png"), ViewIndex);
|
|
Frame.Purpose = TEXT("agent_final_red_target_component_nearest_green_last_reached_orange");
|
|
Frame.CameraSeedSource = bHaveLastReached && ViewIndex == 1
|
|
? TEXT("last_reached_player_fit")
|
|
: TEXT("agent_final_player_fit");
|
|
Frame.CameraSeedPoseVoxels = CameraSeedPose;
|
|
Frame.CameraVoxels = CameraPosition;
|
|
Frame.TargetVoxels = Target;
|
|
Frame.HitPixels = HitPixels;
|
|
Frame.bFailureMarkersProjected = bAgentProjected && bTargetProjected
|
|
&& (!bHaveLastReached || bLastReachedProjected);
|
|
Frame.bPartial = bRasterTruncated;
|
|
|
|
bool bScaleMarkerProjected = false;
|
|
DrawScaleMarker(
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
CameraPosition,
|
|
Target,
|
|
Basis,
|
|
bScaleMarkerProjected);
|
|
Frame.bScaleMarkerProjected = bScaleMarkerProjected;
|
|
|
|
FString Error;
|
|
if (!SavePng(
|
|
FPaths::Combine(Arguments.OutDirectory, Frame.FileName),
|
|
Pixels,
|
|
Arguments.RenderWidth,
|
|
Arguments.RenderHeight,
|
|
Error))
|
|
{
|
|
InOutOutput.Status = TEXT("error");
|
|
InOutOutput.RefusalReason = Error;
|
|
return false;
|
|
}
|
|
|
|
if (bRasterTruncated)
|
|
{
|
|
InOutOutput.Status = TEXT("truncated");
|
|
InOutOutput.bTruncated = true;
|
|
InOutOutput.RefusalReason = TEXT(
|
|
"The wall-clock budget stopped failure-focus rasterisation.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool RunWalk(
|
|
const FExploreArguments& Arguments,
|
|
const FExploreWorld& World,
|
|
FExploreWalkOutput& OutOutput,
|
|
FExploreBudget& Budget)
|
|
{
|
|
OutOutput = FExploreWalkOutput();
|
|
OutOutput.WindowPolicy = NeedsOriginInWindow(Arguments.Archetype)
|
|
? TEXT("origin-inclusive target-strate passage-envelope AABB + margin")
|
|
: TEXT("target-strate passage-envelope AABB + margin");
|
|
|
|
const int64 EstimatedWorkingBytes = static_cast<int64>(Arguments.MaxWalkCells)
|
|
* EstimatedWalkBytesPerCell;
|
|
if (EstimatedWorkingBytes > MaxWalkWorkingBytes)
|
|
{
|
|
OutOutput.Status = TEXT("refused");
|
|
OutOutput.RefusalReason = FString::Printf(
|
|
TEXT("Refused before sampling: estimated walk memory %lld bytes exceeds cap %lld bytes."),
|
|
static_cast<long long>(EstimatedWorkingBytes),
|
|
static_cast<long long>(MaxWalkWorkingBytes));
|
|
return false;
|
|
}
|
|
if (Budget.ShouldStop(TEXT("walk")))
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget elapsed before walk sampling.");
|
|
return false;
|
|
}
|
|
|
|
const FVoxelPassage* ArrivalPassage = nullptr;
|
|
const FVoxelPassage* DeparturePassage = nullptr;
|
|
if (!FindMouths(
|
|
*World.Manager,
|
|
Arguments.Slot,
|
|
OutOutput.ArrivalVoxels,
|
|
OutOutput.DepartureVoxels,
|
|
OutOutput.ArrivalPassageCount,
|
|
OutOutput.DeparturePassageCount,
|
|
ArrivalPassage,
|
|
DeparturePassage))
|
|
{
|
|
OutOutput.Status = TEXT("refused");
|
|
OutOutput.RefusalReason = TEXT(
|
|
"The target slot did not have both one-step chain mouths in the generated passage list.");
|
|
return false;
|
|
}
|
|
OutOutput.bHasArrival = true;
|
|
OutOutput.bHasDeparture = true;
|
|
|
|
FVoxelStrateMeasureSettings Settings;
|
|
Settings.SampleStep = 1;
|
|
Settings.MaxCells = Arguments.MaxWalkCells;
|
|
Settings.MaxRouteRetries = 16;
|
|
Settings.CoverPointA = FVector2D(OutOutput.ArrivalVoxels.X, OutOutput.ArrivalVoxels.Y);
|
|
Settings.CoverPointB = FVector2D(OutOutput.DepartureVoxels.X, OutOutput.DepartureVoxels.Y);
|
|
Settings.CoverMarginVoxels = FMath::CeilToFloat(
|
|
Settings.PlayerCapsuleRadiusVoxels) + 2.0f;
|
|
|
|
// A mouth-AABB is not a landing window: the landing door and its local apron can legitimately
|
|
// leave that box before the player reaches the next room. Expand the same two-point box just
|
|
// enough to contain the selected production mouth envelope. This is still a bounded,
|
|
// deterministic measurement window; the central inter-strate descent belongs to the adjacent
|
|
// strata and is not folded into this intra-strate walk question.
|
|
const float WindowMinX = FMath::Min(OutOutput.ArrivalVoxels.X, OutOutput.DepartureVoxels.X);
|
|
const float WindowMaxX = FMath::Max(OutOutput.ArrivalVoxels.X, OutOutput.DepartureVoxels.X);
|
|
const float WindowMinY = FMath::Min(OutOutput.ArrivalVoxels.Y, OutOutput.DepartureVoxels.Y);
|
|
const float WindowMaxY = FMath::Max(OutOutput.ArrivalVoxels.Y, OutOutput.DepartureVoxels.Y);
|
|
const auto IncludeTargetPassageEnvelope = [&](
|
|
const FVoxelPassage* Passage,
|
|
const FVoxelPassageLanding& Landing)
|
|
{
|
|
if (Passage == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const auto IncludeXY = [&](const FVector& Point, float Radius)
|
|
{
|
|
if (!FMath::IsFinite(Point.X) || !FMath::IsFinite(Point.Y)
|
|
|| !FMath::IsFinite(Point.Z) || !FMath::IsFinite(Radius))
|
|
{
|
|
return;
|
|
}
|
|
const float AbsRadius = FMath::Abs(Radius);
|
|
// Include control points whose centreline belongs to the measured strate. A control
|
|
// point just outside the boundary belongs to the adjacent strate's descent; pulling
|
|
// that whole switchback into this bounded walk box can multiply the sampled volume
|
|
// without improving the intra-strate mouth-to-mouth question.
|
|
if (Point.Z < static_cast<float>(World.TargetBottomWorldZ)
|
|
|| Point.Z > static_cast<float>(World.TargetTopWorldZ))
|
|
{
|
|
return;
|
|
}
|
|
const float ExcessX = FMath::Max(
|
|
0.0f,
|
|
FMath::Max(
|
|
WindowMinX - (Point.X + AbsRadius),
|
|
(Point.X - AbsRadius) - WindowMaxX));
|
|
const float ExcessY = FMath::Max(
|
|
0.0f,
|
|
FMath::Max(
|
|
WindowMinY - (Point.Y + AbsRadius),
|
|
(Point.Y - AbsRadius) - WindowMaxY));
|
|
Settings.CoverMarginVoxels = FMath::Max(
|
|
Settings.CoverMarginVoxels,
|
|
FMath::Max(ExcessX, ExcessY));
|
|
};
|
|
|
|
IncludeXY(
|
|
Landing.StandingPoint,
|
|
Landing.HalfWidth + VoxelPassageGeometry::LandingCarveBlendVoxels);
|
|
// The first/last three points are the production mouth, apron, and first slope section.
|
|
// Do not pull the whole inter-strate switchback into the bounded target-strate test.
|
|
const int32 NumLocalPoints = FMath::Min(3, Passage->ControlPoints.Num());
|
|
const bool bUseUpperEnd = &Landing == &Passage->UpperLanding;
|
|
const int32 FirstPoint = bUseUpperEnd
|
|
? 0 : FMath::Max(0, Passage->ControlPoints.Num() - NumLocalPoints);
|
|
const int32 LastPoint = bUseUpperEnd
|
|
? NumLocalPoints : Passage->ControlPoints.Num();
|
|
for (int32 PointIndex = FirstPoint; PointIndex < LastPoint; ++PointIndex)
|
|
{
|
|
const float Radius = Passage->ControlRadii.IsValidIndex(PointIndex)
|
|
? Passage->ControlRadii[PointIndex] : Passage->Radius;
|
|
IncludeXY(Passage->ControlPoints[PointIndex], Radius
|
|
+ VoxelPassageGeometry::LandingCarveBlendVoxels);
|
|
}
|
|
};
|
|
IncludeTargetPassageEnvelope(
|
|
ArrivalPassage,
|
|
ArrivalPassage != nullptr ? ArrivalPassage->LowerLanding : FVoxelPassageLanding());
|
|
IncludeTargetPassageEnvelope(
|
|
DeparturePassage,
|
|
DeparturePassage != nullptr ? DeparturePassage->UpperLanding : FVoxelPassageLanding());
|
|
Settings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Arguments.Archetype);
|
|
|
|
TArray<FVector> LandingRoomProbePoints;
|
|
|
|
auto AppendLandingRoomProbes = [&LandingRoomProbePoints](
|
|
const FVoxelPassageLanding& Landing)
|
|
{
|
|
const float ProbeOffset = FMath::Min(
|
|
2.0f,
|
|
FMath::Max(0.0f, Landing.HalfWidth - 2.0f));
|
|
LandingRoomProbePoints.Add(Landing.StandingPoint);
|
|
LandingRoomProbePoints.Add(
|
|
Landing.StandingPoint + FVector(ProbeOffset, 0.0f, 0.0f));
|
|
LandingRoomProbePoints.Add(
|
|
Landing.StandingPoint + FVector(-ProbeOffset, 0.0f, 0.0f));
|
|
LandingRoomProbePoints.Add(
|
|
Landing.StandingPoint + FVector(0.0f, ProbeOffset, 0.0f));
|
|
LandingRoomProbePoints.Add(
|
|
Landing.StandingPoint + FVector(0.0f, -ProbeOffset, 0.0f));
|
|
};
|
|
if (ArrivalPassage != nullptr)
|
|
{
|
|
AppendLandingRoomProbes(ArrivalPassage->LowerLanding);
|
|
}
|
|
if (DeparturePassage != nullptr)
|
|
{
|
|
AppendLandingRoomProbes(DeparturePassage->UpperLanding);
|
|
}
|
|
|
|
const FGeneratorDensitySampler Sampler(*World.Generator);
|
|
if (!VF_MeasurePlayerFitWalkWithSampler(
|
|
Sampler,
|
|
World.TargetBottomWorldZ,
|
|
World.TargetTopWorldZ,
|
|
World.TargetBoundarySealThickness,
|
|
OutOutput.ArrivalVoxels,
|
|
OutOutput.DepartureVoxels,
|
|
Settings,
|
|
OutOutput.Report,
|
|
&LandingRoomProbePoints,
|
|
&OutOutput.SharedSampleGrid))
|
|
{
|
|
OutOutput.Status = TEXT("refused");
|
|
OutOutput.RefusalReason = OutOutput.Report.RefusalReason;
|
|
return false;
|
|
}
|
|
|
|
// Camera seeds are selected from this exact captured mask. The first valid fit cell anchors
|
|
// the bounded render mesh; subsequent cells are kept local to that anchor so the mesh region
|
|
// contains every requested viewpoint rather than silently rendering a distant empty box.
|
|
OutOutput.CameraSeedPoses.Reset();
|
|
OutOutput.CameraSeedSources.Reset();
|
|
FVector CameraAnchor = FVector::ZeroVector;
|
|
bool bHaveCameraAnchor = false;
|
|
const auto AddCameraSeed = [&](const FVector& Position, const TCHAR* Source)
|
|
{
|
|
if (!FMath::IsFinite(Position.X) || !FMath::IsFinite(Position.Y)
|
|
|| !FMath::IsFinite(Position.Z)
|
|
|| !OutOutput.SharedSampleGrid.ContainsPoint(Position))
|
|
{
|
|
return;
|
|
}
|
|
const float Step = static_cast<float>(OutOutput.SharedSampleGrid.SampleStep);
|
|
const int32 X = FMath::Clamp(
|
|
FMath::FloorToInt((Position.X - OutOutput.SharedSampleGrid.MinX) / Step),
|
|
0,
|
|
OutOutput.SharedSampleGrid.NumX - 1);
|
|
const int32 Y = FMath::Clamp(
|
|
FMath::FloorToInt((Position.Y - OutOutput.SharedSampleGrid.MinY) / Step),
|
|
0,
|
|
OutOutput.SharedSampleGrid.NumY - 1);
|
|
const int32 Z = FMath::Clamp(
|
|
FMath::FloorToInt((Position.Z - OutOutput.SharedSampleGrid.MinZ) / Step),
|
|
0,
|
|
OutOutput.SharedSampleGrid.NumZ - 1);
|
|
const int32 Cell = OutOutput.SharedSampleGrid.Index(X, Y, Z);
|
|
if (!OutOutput.SharedSampleGrid.PlayerFitMask.IsValidIndex(Cell)
|
|
|| OutOutput.SharedSampleGrid.PlayerFitMask[Cell] == 0u)
|
|
{
|
|
return;
|
|
}
|
|
if (!bHaveCameraAnchor)
|
|
{
|
|
CameraAnchor = Position;
|
|
bHaveCameraAnchor = true;
|
|
}
|
|
if (FVector::DistSquared(Position, CameraAnchor) > FMath::Square(40.0f))
|
|
{
|
|
return;
|
|
}
|
|
for (const FVector& Existing : OutOutput.CameraSeedPoses)
|
|
{
|
|
if (Existing.Equals(Position, 0.001f))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
OutOutput.CameraSeedPoses.Add(Position);
|
|
OutOutput.CameraSeedSources.Add(
|
|
Source != nullptr ? FString(Source)
|
|
: FString::Printf(TEXT("player_fit_%02d"),
|
|
OutOutput.CameraSeedPoses.Num() - 1));
|
|
};
|
|
AddCameraSeed(OutOutput.ArrivalVoxels, TEXT("arrival_player_fit"));
|
|
AddCameraSeed(OutOutput.DepartureVoxels, TEXT("departure_player_fit"));
|
|
if (OutOutput.Report.bHasLastReachedPosition)
|
|
{
|
|
AddCameraSeed(
|
|
OutOutput.Report.LastReachedVoxels,
|
|
TEXT("last_reached_player_fit"));
|
|
}
|
|
if (OutOutput.Report.bHasAgentFinalPosition)
|
|
{
|
|
AddCameraSeed(
|
|
OutOutput.Report.AgentFinalVoxels,
|
|
TEXT("agent_final_player_fit"));
|
|
}
|
|
if (!bHaveCameraAnchor)
|
|
{
|
|
for (int32 Cell = 0;
|
|
Cell < OutOutput.SharedSampleGrid.PlayerFitMask.Num();
|
|
++Cell)
|
|
{
|
|
if (OutOutput.SharedSampleGrid.PlayerFitMask[Cell] == 0u)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 Plane = OutOutput.SharedSampleGrid.NumX
|
|
* OutOutput.SharedSampleGrid.NumY;
|
|
const int32 Z = Cell / Plane;
|
|
const int32 InPlane = Cell - Z * Plane;
|
|
const int32 Y = InPlane / OutOutput.SharedSampleGrid.NumX;
|
|
const int32 X = InPlane - Y * OutOutput.SharedSampleGrid.NumX;
|
|
AddCameraSeed(
|
|
FVector(
|
|
OutOutput.SharedSampleGrid.MinX
|
|
+ (static_cast<float>(X) + 0.5f)
|
|
* OutOutput.SharedSampleGrid.SampleStep,
|
|
OutOutput.SharedSampleGrid.MinY
|
|
+ (static_cast<float>(Y) + 0.5f)
|
|
* OutOutput.SharedSampleGrid.SampleStep,
|
|
OutOutput.SharedSampleGrid.MinZ
|
|
+ (static_cast<float>(Z) + 0.5f)
|
|
* OutOutput.SharedSampleGrid.SampleStep),
|
|
TEXT("player_fit_grid"));
|
|
if (bHaveCameraAnchor)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (bHaveCameraAnchor)
|
|
{
|
|
const int32 NumCandidates = OutOutput.SharedSampleGrid.PlayerFitMask.Num();
|
|
for (int32 Cell = 0; Cell < NumCandidates && OutOutput.CameraSeedPoses.Num() < 8; ++Cell)
|
|
{
|
|
if (OutOutput.SharedSampleGrid.PlayerFitMask[Cell] == 0u)
|
|
{
|
|
continue;
|
|
}
|
|
const int32 Plane = OutOutput.SharedSampleGrid.NumX
|
|
* OutOutput.SharedSampleGrid.NumY;
|
|
const int32 Z = Cell / Plane;
|
|
const int32 InPlane = Cell - Z * Plane;
|
|
const int32 Y = InPlane / OutOutput.SharedSampleGrid.NumX;
|
|
const int32 X = InPlane - Y * OutOutput.SharedSampleGrid.NumX;
|
|
AddCameraSeed(
|
|
FVector(
|
|
OutOutput.SharedSampleGrid.MinX
|
|
+ (static_cast<float>(X) + 0.5f)
|
|
* OutOutput.SharedSampleGrid.SampleStep,
|
|
OutOutput.SharedSampleGrid.MinY
|
|
+ (static_cast<float>(Y) + 0.5f)
|
|
* OutOutput.SharedSampleGrid.SampleStep,
|
|
OutOutput.SharedSampleGrid.MinZ
|
|
+ (static_cast<float>(Z) + 0.5f)
|
|
* OutOutput.SharedSampleGrid.SampleStep),
|
|
TEXT("player_fit_grid"));
|
|
}
|
|
}
|
|
|
|
// The origin observation is now a view over the shared primary grid. A second, expanded
|
|
// walk would defeat the one-grid contract; callers that need origin coverage must request an
|
|
// origin-inclusive archetype window, which is already selected before the single sample pass.
|
|
OutOutput.bOriginCheckAttempted = true;
|
|
if (OutOutput.Report.bOriginColumnInSampledWindow)
|
|
{
|
|
OutOutput.OriginCheckReport = OutOutput.Report;
|
|
OutOutput.bOriginCheckAvailable = true;
|
|
}
|
|
else
|
|
{
|
|
OutOutput.OriginCheckRefusalReason = TEXT(
|
|
"Origin is outside the shared primary sample window; no second grid was sampled.");
|
|
}
|
|
const int32 ArrivalProbeCount = ArrivalPassage != nullptr ? 5 : 0;
|
|
const int32 DepartureProbeCount = DeparturePassage != nullptr ? 5 : 0;
|
|
const int32 LandingProbeCount = ArrivalProbeCount + DepartureProbeCount;
|
|
OutOutput.ArrivalLandingRoomProbeCount = ArrivalProbeCount;
|
|
OutOutput.DepartureLandingRoomProbeCount = DepartureProbeCount;
|
|
for (int32 ProbeIndex = 0;
|
|
ProbeIndex < FMath::Min(LandingProbeCount,
|
|
OutOutput.Report.ComponentProbePlayerFit.Num());
|
|
++ProbeIndex)
|
|
{
|
|
const bool bPlayerFit = OutOutput.Report.ComponentProbePlayerFit[ProbeIndex] != 0u;
|
|
const bool bInArrivalComponent =
|
|
OutOutput.Report.ComponentProbeInStartComponent.IsValidIndex(ProbeIndex)
|
|
&& OutOutput.Report.ComponentProbeInStartComponent[ProbeIndex] != 0u;
|
|
if (ProbeIndex < ArrivalProbeCount)
|
|
{
|
|
OutOutput.ArrivalLandingRoomPlayerFitProbeCount += bPlayerFit ? 1 : 0;
|
|
OutOutput.ArrivalLandingRoomArrivalComponentProbeCount +=
|
|
bInArrivalComponent ? 1 : 0;
|
|
}
|
|
else
|
|
{
|
|
OutOutput.DepartureLandingRoomPlayerFitProbeCount += bPlayerFit ? 1 : 0;
|
|
OutOutput.DepartureLandingRoomArrivalComponentProbeCount +=
|
|
bInArrivalComponent ? 1 : 0;
|
|
}
|
|
}
|
|
if (Budget.ShouldStop(TEXT("walk")))
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget elapsed after walk sampling.");
|
|
return false;
|
|
}
|
|
OutOutput.Status = TEXT("ok");
|
|
return true;
|
|
}
|
|
|
|
int64 EstimateExportWorkingBytes(int32 RegionSize)
|
|
{
|
|
const int64 TileCells = static_cast<int64>(CHUNK_SIZE)
|
|
* static_cast<int64>(CHUNK_SIZE)
|
|
* static_cast<int64>(CHUNK_SIZE);
|
|
const int64 TileGridSamples = static_cast<int64>(CHUNK_SIZE + 3)
|
|
* static_cast<int64>(CHUNK_SIZE + 3)
|
|
* static_cast<int64>(CHUNK_SIZE + 3);
|
|
// This preflight covers one canonical tile's scalar grid, edge map, mesh arrays, and temporary
|
|
// triangle lists. The run-level canonical aggregate is retained separately so render and
|
|
// export consume the same mesh. RegionSize is still accepted here to keep the call site's
|
|
// contract explicit.
|
|
(void)RegionSize;
|
|
return TileGridSamples * static_cast<int64>(sizeof(float)) + TileCells * 448ll;
|
|
}
|
|
|
|
bool WriteUtf8(FArchive& Archive, const FString& Text)
|
|
{
|
|
FTCHARToUTF8 Converted(*Text);
|
|
if (Converted.Length() > 0)
|
|
{
|
|
Archive.Serialize(const_cast<ANSICHAR*>(Converted.Get()), Converted.Length());
|
|
}
|
|
return !Archive.IsError();
|
|
}
|
|
|
|
bool ValidateCanonicalTile(const FVoxelMeshData& MeshData, FString& OutError)
|
|
{
|
|
if (MeshData.Vertices.Num() != MeshData.Normals.Num()
|
|
|| MeshData.Vertices.Num() != MeshData.UVs.Num()
|
|
|| MeshData.Vertices.Num() != MeshData.Colors.Num()
|
|
|| MeshData.Triangles.Num() % 3 != 0)
|
|
{
|
|
OutError = TEXT("Canonical mesher returned non-parallel mesh arrays.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool ExploreMetricVectorIsFinite(const FVector& Value)
|
|
{
|
|
return FMath::IsFinite(Value.X) && FMath::IsFinite(Value.Y) && FMath::IsFinite(Value.Z);
|
|
}
|
|
|
|
FIntVector ExploreMetricWeldPosition(const FVector& PositionCm)
|
|
{
|
|
// Mesh positions are Unreal centimetres. 0.01 cm is deliberately much smaller than a
|
|
// marching-cubes cell and only absorbs independent-tile floating-point round-off.
|
|
constexpr float WeldQuantumCm = 0.01f;
|
|
return FIntVector(
|
|
FMath::RoundToInt(PositionCm.X / WeldQuantumCm),
|
|
FMath::RoundToInt(PositionCm.Y / WeldQuantumCm),
|
|
FMath::RoundToInt(PositionCm.Z / WeldQuantumCm));
|
|
}
|
|
|
|
FExploreMeshEdgeKey ExploreMetricMakeEdgeKey(
|
|
const FVector& PositionA,
|
|
const FVector& PositionB)
|
|
{
|
|
const FIntVector A = ExploreMetricWeldPosition(PositionA);
|
|
const FIntVector B = ExploreMetricWeldPosition(PositionB);
|
|
const bool bAFirst = A.X < B.X
|
|
|| (A.X == B.X && (A.Y < B.Y || (A.Y == B.Y && A.Z <= B.Z)));
|
|
return bAFirst
|
|
? FExploreMeshEdgeKey{A, B}
|
|
: FExploreMeshEdgeKey{B, A};
|
|
}
|
|
|
|
float ExploreMetricPercentile(const TArray<float>& SortedValues, float Quantile)
|
|
{
|
|
if (SortedValues.Num() == 0)
|
|
{
|
|
return 0.0f;
|
|
}
|
|
const float Position = FMath::Clamp(Quantile, 0.0f, 1.0f)
|
|
* static_cast<float>(SortedValues.Num() - 1);
|
|
const int32 Lower = FMath::FloorToInt(Position);
|
|
const int32 Upper = FMath::Min(Lower + 1, SortedValues.Num() - 1);
|
|
return FMath::Lerp(SortedValues[Lower], SortedValues[Upper], Position - static_cast<float>(Lower));
|
|
}
|
|
|
|
bool MeasureExploreMesh(
|
|
const FVoxelMeshData& MeshData,
|
|
FExploreMeshMetrics& OutMetrics,
|
|
FString& OutError)
|
|
{
|
|
OutMetrics = FExploreMeshMetrics();
|
|
if (!ValidateCanonicalTile(MeshData, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const int32 NumTriangles = MeshData.Triangles.Num() / 3;
|
|
OutMetrics.VertexCount = MeshData.Vertices.Num();
|
|
OutMetrics.TriangleCount = NumTriangles;
|
|
|
|
TArray<float> EdgeLengthsMeters;
|
|
TArray<float> DihedralAnglesDegrees;
|
|
TArray<float> FaceNormalDivergencesDegrees;
|
|
EdgeLengthsMeters.Reserve(NumTriangles * 3);
|
|
DihedralAnglesDegrees.Reserve(NumTriangles * 2);
|
|
FaceNormalDivergencesDegrees.Reserve(NumTriangles);
|
|
|
|
TArray<FVector> FaceNormals;
|
|
FaceNormals.Init(FVector::ZeroVector, NumTriangles);
|
|
TArray<uint8> ValidFaceNormals;
|
|
ValidFaceNormals.Init(0, NumTriangles);
|
|
TMap<FExploreMeshEdgeKey, FExploreMeshEdgeIncident> EdgeIncidents;
|
|
EdgeIncidents.Reserve(FMath::Max(1, NumTriangles * 2));
|
|
|
|
const auto AddEdgeIncident = [&EdgeIncidents](
|
|
const FVector& PositionA, const FVector& PositionB, int32 TriangleIndex)
|
|
{
|
|
const FExploreMeshEdgeKey Key = ExploreMetricMakeEdgeKey(PositionA, PositionB);
|
|
if (Key.First == Key.Second)
|
|
{
|
|
return;
|
|
}
|
|
FExploreMeshEdgeIncident& Incident = EdgeIncidents.FindOrAdd(Key);
|
|
if (Incident.IncidentCount == 0)
|
|
{
|
|
Incident.FirstTriangle = TriangleIndex;
|
|
}
|
|
else if (Incident.IncidentCount == 1)
|
|
{
|
|
Incident.SecondTriangle = TriangleIndex;
|
|
}
|
|
Incident.IncidentCount = FMath::Min(Incident.IncidentCount + 1, 3);
|
|
};
|
|
|
|
for (int32 TriangleIndex = 0; TriangleIndex < NumTriangles; ++TriangleIndex)
|
|
{
|
|
const int32 Base = TriangleIndex * 3;
|
|
const int32 IndexA = MeshData.Triangles[Base];
|
|
const int32 IndexB = MeshData.Triangles[Base + 1];
|
|
const int32 IndexC = MeshData.Triangles[Base + 2];
|
|
if (!MeshData.Vertices.IsValidIndex(IndexA)
|
|
|| !MeshData.Vertices.IsValidIndex(IndexB)
|
|
|| !MeshData.Vertices.IsValidIndex(IndexC)
|
|
|| !MeshData.Normals.IsValidIndex(IndexA)
|
|
|| !MeshData.Normals.IsValidIndex(IndexB)
|
|
|| !MeshData.Normals.IsValidIndex(IndexC))
|
|
{
|
|
OutError = TEXT("Surface metric measurement saw an out-of-range mesh index.");
|
|
return false;
|
|
}
|
|
|
|
const FVector& A = MeshData.Vertices[IndexA];
|
|
const FVector& B = MeshData.Vertices[IndexB];
|
|
const FVector& C = MeshData.Vertices[IndexC];
|
|
if (!ExploreMetricVectorIsFinite(A)
|
|
|| !ExploreMetricVectorIsFinite(B)
|
|
|| !ExploreMetricVectorIsFinite(C))
|
|
{
|
|
OutError = TEXT("Surface metric measurement saw a non-finite vertex.");
|
|
return false;
|
|
}
|
|
|
|
EdgeLengthsMeters.Add(FVector::Dist(A, B) * 0.01f);
|
|
EdgeLengthsMeters.Add(FVector::Dist(B, C) * 0.01f);
|
|
EdgeLengthsMeters.Add(FVector::Dist(C, A) * 0.01f);
|
|
|
|
const FVector Cross = FVector::CrossProduct(B - A, C - A);
|
|
if (Cross.SizeSquared() > SMALL_NUMBER)
|
|
{
|
|
const FVector MeanVertexNormal = (
|
|
MeshData.Normals[IndexA]
|
|
+ MeshData.Normals[IndexB]
|
|
+ MeshData.Normals[IndexC]).GetSafeNormal();
|
|
if (ExploreMetricVectorIsFinite(MeanVertexNormal)
|
|
&& !MeanVertexNormal.IsNearlyZero())
|
|
{
|
|
// The canonical marching-cubes table is emitted with the winding required by
|
|
// the runtime mesh path, while the density-gradient normals point solid-to-air.
|
|
// Orient the geometric face normal to that same outward convention before
|
|
// measuring the relief divergence; otherwise a winding convention change would
|
|
// report ~180 degrees for an otherwise smooth surface.
|
|
FVector FaceNormal = Cross.GetSafeNormal();
|
|
if (FVector::DotProduct(FaceNormal, MeanVertexNormal) < 0.0f)
|
|
{
|
|
FaceNormal *= -1.0f;
|
|
}
|
|
FaceNormals[TriangleIndex] = FaceNormal;
|
|
ValidFaceNormals[TriangleIndex] = 1;
|
|
const float Dot = FMath::Clamp(
|
|
FVector::DotProduct(FaceNormal, MeanVertexNormal), -1.0f, 1.0f);
|
|
FaceNormalDivergencesDegrees.Add(
|
|
FMath::RadiansToDegrees(FMath::Acos(Dot)));
|
|
++OutMetrics.NormalComparisonTriangleCount;
|
|
}
|
|
}
|
|
|
|
AddEdgeIncident(A, B, TriangleIndex);
|
|
AddEdgeIncident(B, C, TriangleIndex);
|
|
AddEdgeIncident(C, A, TriangleIndex);
|
|
}
|
|
|
|
for (const TPair<FExploreMeshEdgeKey, FExploreMeshEdgeIncident>& Pair : EdgeIncidents)
|
|
{
|
|
const FExploreMeshEdgeIncident& Incident = Pair.Value;
|
|
if (Incident.IncidentCount != 2)
|
|
{
|
|
continue;
|
|
}
|
|
++OutMetrics.SharedEdgeCount;
|
|
if (!ValidFaceNormals.IsValidIndex(Incident.FirstTriangle)
|
|
|| !ValidFaceNormals.IsValidIndex(Incident.SecondTriangle)
|
|
|| !ValidFaceNormals[Incident.FirstTriangle]
|
|
|| !ValidFaceNormals[Incident.SecondTriangle])
|
|
{
|
|
continue;
|
|
}
|
|
const float Dot = FMath::Clamp(
|
|
FVector::DotProduct(
|
|
FaceNormals[Incident.FirstTriangle],
|
|
FaceNormals[Incident.SecondTriangle]),
|
|
-1.0f,
|
|
1.0f);
|
|
DihedralAnglesDegrees.Add(FMath::RadiansToDegrees(FMath::Acos(Dot)));
|
|
}
|
|
|
|
EdgeLengthsMeters.Sort();
|
|
DihedralAnglesDegrees.Sort();
|
|
FaceNormalDivergencesDegrees.Sort();
|
|
|
|
OutMetrics.DihedralSampleCount = DihedralAnglesDegrees.Num();
|
|
OutMetrics.EdgeLengthP50Meters = ExploreMetricPercentile(EdgeLengthsMeters, 0.50f);
|
|
OutMetrics.EdgeLengthP99Meters = ExploreMetricPercentile(EdgeLengthsMeters, 0.99f);
|
|
OutMetrics.EdgeLengthMaxMeters = EdgeLengthsMeters.Num() > 0
|
|
? EdgeLengthsMeters.Last() : 0.0f;
|
|
OutMetrics.DihedralP50Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.50f);
|
|
OutMetrics.DihedralP75Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.75f);
|
|
OutMetrics.DihedralP90Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.90f);
|
|
OutMetrics.DihedralP95Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.95f);
|
|
OutMetrics.DihedralP99Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.99f);
|
|
if (DihedralAnglesDegrees.Num() > 0)
|
|
{
|
|
int32 Over20 = 0;
|
|
int32 Over40 = 0;
|
|
for (const float Angle : DihedralAnglesDegrees)
|
|
{
|
|
Over20 += Angle > 20.0f ? 1 : 0;
|
|
Over40 += Angle > 40.0f ? 1 : 0;
|
|
}
|
|
OutMetrics.DihedralFractionOver20Degrees = static_cast<float>(Over20)
|
|
/ static_cast<float>(DihedralAnglesDegrees.Num());
|
|
OutMetrics.DihedralFractionOver40Degrees = static_cast<float>(Over40)
|
|
/ static_cast<float>(DihedralAnglesDegrees.Num());
|
|
}
|
|
OutMetrics.FaceNormalVsMeanVertexNormalP50Degrees =
|
|
ExploreMetricPercentile(FaceNormalDivergencesDegrees, 0.50f);
|
|
OutMetrics.FaceNormalVsMeanVertexNormalP90Degrees =
|
|
ExploreMetricPercentile(FaceNormalDivergencesDegrees, 0.90f);
|
|
OutError.Reset();
|
|
return true;
|
|
}
|
|
|
|
bool WriteObjHeader(FArchive& Archive)
|
|
{
|
|
bool bOk = WriteUtf8(Archive, TEXT("# VoxelForge authoritative marching-cubes mesh\n"));
|
|
bOk = bOk && WriteUtf8(Archive,
|
|
TEXT("# Positions are metres; UVs retain canonical voxel-space values; source winding is preserved.\n"));
|
|
bOk = bOk && WriteUtf8(Archive, TEXT("o VoxelForgeExplore\n"));
|
|
return bOk && !Archive.IsError();
|
|
}
|
|
|
|
bool WriteObjMesh(
|
|
FArchive& Archive,
|
|
const FVoxelMeshData& MeshData,
|
|
FExploreBudget& Budget,
|
|
FString& OutError,
|
|
bool& bOutTruncated,
|
|
int32& OutVerticesWritten,
|
|
int32& OutTrianglesWritten)
|
|
{
|
|
bOutTruncated = false;
|
|
OutVerticesWritten = 0;
|
|
OutTrianglesWritten = 0;
|
|
if (!ValidateCanonicalTile(MeshData, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool bOk = true;
|
|
for (int32 VertexIndex = 0; VertexIndex < MeshData.Vertices.Num(); ++VertexIndex)
|
|
{
|
|
if ((VertexIndex & 255) == 0 && Budget.ShouldStop(TEXT("export-write")))
|
|
{
|
|
bOutTruncated = true;
|
|
return true;
|
|
}
|
|
const FVector& Vertex = MeshData.Vertices[VertexIndex];
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("v %.9f %.9f %.9f\n"),
|
|
Vertex.X / 100.0,
|
|
Vertex.Y / 100.0,
|
|
Vertex.Z / 100.0));
|
|
++OutVerticesWritten;
|
|
}
|
|
for (int32 UVIndex = 0; UVIndex < MeshData.UVs.Num(); ++UVIndex)
|
|
{
|
|
if ((UVIndex & 255) == 0 && Budget.ShouldStop(TEXT("export-write")))
|
|
{
|
|
bOutTruncated = true;
|
|
return true;
|
|
}
|
|
const FVector2D& UV = MeshData.UVs[UVIndex];
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("vt %.9f %.9f\n"), UV.X, UV.Y));
|
|
}
|
|
for (int32 NormalIndex = 0; NormalIndex < MeshData.Normals.Num(); ++NormalIndex)
|
|
{
|
|
if ((NormalIndex & 255) == 0 && Budget.ShouldStop(TEXT("export-write")))
|
|
{
|
|
bOutTruncated = true;
|
|
return true;
|
|
}
|
|
const FVector& Normal = MeshData.Normals[NormalIndex];
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("vn %.9f %.9f %.9f\n"), Normal.X, Normal.Y, Normal.Z));
|
|
}
|
|
|
|
const int32 NumTriangles = MeshData.Triangles.Num() / 3;
|
|
if (NumTriangles > 0)
|
|
{
|
|
bOk = bOk && WriteUtf8(Archive, TEXT("usemtl VoxelGround\n"));
|
|
}
|
|
for (int32 Triangle = 0; Triangle < NumTriangles; ++Triangle)
|
|
{
|
|
if ((Triangle & 255) == 0 && Budget.ShouldStop(TEXT("export-write")))
|
|
{
|
|
bOutTruncated = true;
|
|
return true;
|
|
}
|
|
const int32 Base = Triangle * 3;
|
|
const int32 A = MeshData.Triangles[Base];
|
|
const int32 B = MeshData.Triangles[Base + 1];
|
|
const int32 C = MeshData.Triangles[Base + 2];
|
|
if (A < 0 || B < 0 || C < 0
|
|
|| A >= MeshData.Vertices.Num()
|
|
|| B >= MeshData.Vertices.Num()
|
|
|| C >= MeshData.Vertices.Num())
|
|
{
|
|
OutError = TEXT("Canonical aggregate mesh returned an out-of-range triangle index.");
|
|
return false;
|
|
}
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("f %d/%d/%d %d/%d/%d %d/%d/%d\n"),
|
|
A + 1, A + 1, A + 1,
|
|
B + 1, B + 1, B + 1,
|
|
C + 1, C + 1, C + 1));
|
|
++OutTrianglesWritten;
|
|
}
|
|
if (!bOk || Archive.IsError())
|
|
{
|
|
OutError = TEXT("Could not write the canonical aggregate OBJ.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void WriteJsonVector(FExploreJsonWriter& Writer, const TCHAR* Key, const FVector& Value, float Scale)
|
|
{
|
|
Writer.WriteObjectStart(Key);
|
|
Writer.WriteValue(TEXT("x"), static_cast<double>(Value.X) * Scale);
|
|
Writer.WriteValue(TEXT("y"), static_cast<double>(Value.Y) * Scale);
|
|
Writer.WriteValue(TEXT("z"), static_cast<double>(Value.Z) * Scale);
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
void WriteJsonIntVector(FExploreJsonWriter& Writer, const TCHAR* Key, const FIntVector& Value)
|
|
{
|
|
Writer.WriteObjectStart(Key);
|
|
Writer.WriteValue(TEXT("x"), Value.X);
|
|
Writer.WriteValue(TEXT("y"), Value.Y);
|
|
Writer.WriteValue(TEXT("z"), Value.Z);
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
void WriteJsonBounds(
|
|
FExploreJsonWriter& Writer,
|
|
const TCHAR* Key,
|
|
const FIntVector& Origin,
|
|
int32 Size,
|
|
float VoxelToMeters)
|
|
{
|
|
Writer.WriteObjectStart(Key);
|
|
Writer.WriteObjectStart(TEXT("min"));
|
|
Writer.WriteValue(TEXT("x"), Origin.X);
|
|
Writer.WriteValue(TEXT("y"), Origin.Y);
|
|
Writer.WriteValue(TEXT("z"), Origin.Z);
|
|
Writer.WriteObjectEnd();
|
|
Writer.WriteObjectStart(TEXT("max_exclusive"));
|
|
Writer.WriteValue(TEXT("x"), Origin.X + Size);
|
|
Writer.WriteValue(TEXT("y"), Origin.Y + Size);
|
|
Writer.WriteValue(TEXT("z"), Origin.Z + Size);
|
|
Writer.WriteObjectEnd();
|
|
Writer.WriteValue(TEXT("units"), TEXT("voxels"));
|
|
Writer.WriteValue(TEXT("voxel_size_m"), static_cast<double>(VoxelToMeters));
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
void WriteSurfaceReliefMetrics(
|
|
FExploreJsonWriter& Writer,
|
|
const FExploreMeshMetrics& Metrics)
|
|
{
|
|
Writer.WriteObjectStart(TEXT("surface_relief_metrics"));
|
|
Writer.WriteValue(TEXT("vertex_count"), Metrics.VertexCount);
|
|
Writer.WriteValue(TEXT("triangle_count"), Metrics.TriangleCount);
|
|
Writer.WriteValue(TEXT("shared_edge_count"), Metrics.SharedEdgeCount);
|
|
Writer.WriteValue(TEXT("dihedral_sample_count"), Metrics.DihedralSampleCount);
|
|
Writer.WriteValue(TEXT("normal_comparison_triangle_count"), Metrics.NormalComparisonTriangleCount);
|
|
Writer.WriteValue(TEXT("vertex_weld_quantum_cm"), 0.01);
|
|
Writer.WriteObjectStart(TEXT("triangle_edge_length_m"));
|
|
Writer.WriteValue(TEXT("p50"), static_cast<double>(Metrics.EdgeLengthP50Meters));
|
|
Writer.WriteValue(TEXT("p99"), static_cast<double>(Metrics.EdgeLengthP99Meters));
|
|
Writer.WriteValue(TEXT("max"), static_cast<double>(Metrics.EdgeLengthMaxMeters));
|
|
Writer.WriteObjectEnd();
|
|
Writer.WriteObjectStart(TEXT("dihedral_angle_degrees"));
|
|
Writer.WriteValue(TEXT("p50"), static_cast<double>(Metrics.DihedralP50Degrees));
|
|
Writer.WriteValue(TEXT("p75"), static_cast<double>(Metrics.DihedralP75Degrees));
|
|
Writer.WriteValue(TEXT("p90"), static_cast<double>(Metrics.DihedralP90Degrees));
|
|
Writer.WriteValue(TEXT("p95"), static_cast<double>(Metrics.DihedralP95Degrees));
|
|
Writer.WriteValue(TEXT("p99"), static_cast<double>(Metrics.DihedralP99Degrees));
|
|
Writer.WriteValue(TEXT("fraction_over_20_degrees"),
|
|
static_cast<double>(Metrics.DihedralFractionOver20Degrees));
|
|
Writer.WriteValue(TEXT("fraction_over_40_degrees"),
|
|
static_cast<double>(Metrics.DihedralFractionOver40Degrees));
|
|
Writer.WriteObjectEnd();
|
|
Writer.WriteObjectStart(TEXT("face_normal_vs_mean_vertex_normal_degrees"));
|
|
Writer.WriteValue(TEXT("p50"),
|
|
static_cast<double>(Metrics.FaceNormalVsMeanVertexNormalP50Degrees));
|
|
Writer.WriteValue(TEXT("p90"),
|
|
static_cast<double>(Metrics.FaceNormalVsMeanVertexNormalP90Degrees));
|
|
Writer.WriteObjectEnd();
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
void WriteTunnelFloorOverrides(
|
|
FExploreJsonWriter& Writer,
|
|
const FExploreArguments& Arguments)
|
|
{
|
|
Writer.WriteObjectStart(TEXT("tunnel_floor_overrides"));
|
|
Writer.WriteValue(TEXT("legacy_enabled_override"), Arguments.bTunnelFloorEnabledOverride);
|
|
if (Arguments.bTunnelFloorEnabledOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("legacy_enabled_value"), Arguments.TunnelFloorEnabled != 0);
|
|
}
|
|
Writer.WriteValue(TEXT("enabled_ignored"), true);
|
|
Writer.WriteValue(TEXT("terracing_override"), Arguments.bTunnelFloorTerracingOverride);
|
|
if (Arguments.bTunnelFloorTerracingOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("terracing"), Arguments.TunnelFloorTerracing != 0);
|
|
}
|
|
Writer.WriteValue(TEXT("step_height_override"), Arguments.bTunnelFloorStepOverride);
|
|
if (Arguments.bTunnelFloorStepOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("step_height_voxels"),
|
|
static_cast<double>(Arguments.TunnelFloorStep));
|
|
}
|
|
Writer.WriteValue(TEXT("max_ledge_height_override"), Arguments.bTunnelFloorMaxLedgeOverride);
|
|
if (Arguments.bTunnelFloorMaxLedgeOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("max_ledge_height_voxels"),
|
|
static_cast<double>(Arguments.TunnelFloorMaxLedge));
|
|
}
|
|
Writer.WriteValue(TEXT("gentle_slope_override"), Arguments.bTunnelFloorGentleOverride);
|
|
if (Arguments.bTunnelFloorGentleOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("gentle_slope"),
|
|
static_cast<double>(Arguments.TunnelFloorGentle));
|
|
Writer.WriteValue(TEXT("gentle_slope_units"), TEXT("degrees"));
|
|
}
|
|
Writer.WriteValue(TEXT("ledge_count_override"), Arguments.bTunnelFloorLedgesOverride);
|
|
if (Arguments.bTunnelFloorLedgesOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("ledge_count"), Arguments.TunnelFloorLedges);
|
|
}
|
|
Writer.WriteValue(TEXT("max_ledges_override"), Arguments.bTunnelFloorMaxLedgesOverride);
|
|
if (Arguments.bTunnelFloorMaxLedgesOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("max_ledges"), Arguments.TunnelFloorMaxLedges);
|
|
}
|
|
Writer.WriteValue(TEXT("passage_support_floor_writes_override"),
|
|
Arguments.bPassageFloorWritesOverride);
|
|
if (Arguments.bPassageFloorWritesOverride)
|
|
{
|
|
Writer.WriteValue(TEXT("passage_support_floor_writes"),
|
|
Arguments.PassageFloorWrites != 0);
|
|
}
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
FString BuildManifestJson(
|
|
const FExploreArguments& Arguments,
|
|
const FExploreExportOutput& Export)
|
|
{
|
|
FString Json;
|
|
TSharedRef<FExploreJsonWriter> Writer =
|
|
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Json);
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("schema_version"), 7);
|
|
Writer->WriteValue(TEXT("format"), TEXT("OBJ"));
|
|
Writer->WriteValue(TEXT("mesh_file"), Export.MeshFileName);
|
|
Writer->WriteValue(TEXT("mesh_positions_units"), TEXT("metres"));
|
|
Writer->WriteValue(TEXT("mesh_uv_units"), TEXT("voxels"));
|
|
Writer->WriteValue(TEXT("load_notes"), TEXT(
|
|
"Load geometry.obj directly in a browser OBJ loader; do not regenerate density. "
|
|
"The manifest bounds are max-exclusive voxel bounds, OBJ positions are metres, and OBJ UVs "
|
|
"are the canonical voxel-space UVs."));
|
|
Writer->WriteValue(TEXT("skirts"), false);
|
|
Writer->WriteValue(TEXT("vertex_count"), Export.VertexCount);
|
|
Writer->WriteValue(TEXT("triangle_count"), Export.TriangleCount);
|
|
Writer->WriteValue(TEXT("geometry_hash"), Export.GeometryHash);
|
|
WriteSurfaceReliefMetrics(*Writer, Export.SurfaceMetrics);
|
|
Writer->WriteValue(TEXT("mesh_file_size_bytes"), Export.MeshFileSizeBytes);
|
|
Writer->WriteValue(TEXT("mesh_array_bytes_sum_over_tiles"), Export.MeshArrayBytes);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tile_cells"), CHUNK_SIZE);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tile_count"), Export.MesherTileCount);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tiles_completed"), Export.MesherTilesCompleted);
|
|
Writer->WriteValue(TEXT("mesh_seconds"), Export.MeshSeconds);
|
|
Writer->WriteValue(TEXT("mesh_us_per_voxel"), Export.MeshUsPerVoxel);
|
|
Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep);
|
|
Writer->WriteValue(TEXT("truncated"), Export.bTruncated);
|
|
Writer->WriteValue(TEXT("seed"), Arguments.Seed);
|
|
Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype));
|
|
Writer->WriteValue(TEXT("slot"), Arguments.Slot);
|
|
Writer->WriteValue(TEXT("worms_enabled_override"), Arguments.bWormsEnabledOverride);
|
|
if (Arguments.bWormsEnabledOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("worms_enabled"), Arguments.bWormsEnabled);
|
|
}
|
|
Writer->WriteValue(TEXT("worm_strength_override"), Arguments.bWormStrengthOverride);
|
|
if (Arguments.bWormStrengthOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("worm_strength"), static_cast<double>(Arguments.WormStrength));
|
|
}
|
|
Writer->WriteValue(TEXT("tunnel_ablation_mask"),
|
|
static_cast<int64>(VoxelDensityAblation::GetResolvedMask()));
|
|
WriteTunnelFloorOverrides(*Writer, Arguments);
|
|
Writer->WriteValue(TEXT("room_mouth_rise_override"), Arguments.bRoomMouthRiseOverride);
|
|
if (Arguments.bRoomMouthRiseOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("room_mouth_rise_strength"),
|
|
static_cast<double>(Arguments.RoomMouthRise));
|
|
}
|
|
Writer->WriteValue(TEXT("room_mouth_rise_blend_override"),
|
|
Arguments.bRoomMouthRiseBlendOverride);
|
|
if (Arguments.bRoomMouthRiseBlendOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("room_mouth_rise_blend_voxels"),
|
|
static_cast<double>(Arguments.RoomMouthRiseBlend));
|
|
}
|
|
Writer->WriteValue(TEXT("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride);
|
|
if (Arguments.bSurfaceRoughnessOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("surface_roughness"),
|
|
static_cast<double>(Arguments.SurfaceRoughness));
|
|
}
|
|
Writer->WriteValue(TEXT("roughness_noise_type_override"),
|
|
Arguments.bRoughnessNoiseTypeOverride);
|
|
if (Arguments.bRoughnessNoiseTypeOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("roughness_noise_type"),
|
|
NoiseTypeName(Arguments.RoughnessNoiseType));
|
|
}
|
|
Writer->WriteValue(TEXT("roughness_frequency_override"),
|
|
Arguments.bRoughnessFrequencyOverride);
|
|
if (Arguments.bRoughnessFrequencyOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("roughness_frequency"),
|
|
static_cast<double>(Arguments.RoughnessFrequency));
|
|
}
|
|
Writer->WriteValue(TEXT("render_camera_override"), Arguments.bRenderCameraOverride);
|
|
if (Arguments.bRenderCameraOverride)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("render_camera_voxels"), Arguments.RenderCamera, 1.0f);
|
|
}
|
|
WriteJsonIntVector(*Writer, TEXT("region_origin_voxels"), Export.RegionOrigin);
|
|
WriteJsonBounds(*Writer, TEXT("region_bounds_voxels"), Export.RegionOrigin, Export.RegionSize, VOXEL_SIZE / 100.0f);
|
|
Writer->WriteObjectStart(TEXT("region_bounds_metres"));
|
|
Writer->WriteObjectStart(TEXT("min"));
|
|
Writer->WriteValue(TEXT("x"), static_cast<double>(Export.RegionOrigin.X) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("y"), static_cast<double>(Export.RegionOrigin.Y) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("z"), static_cast<double>(Export.RegionOrigin.Z) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("max_exclusive"));
|
|
Writer->WriteValue(TEXT("x"), static_cast<double>(Export.RegionOrigin.X + Export.RegionSize) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("y"), static_cast<double>(Export.RegionOrigin.Y + Export.RegionSize) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("z"), static_cast<double>(Export.RegionOrigin.Z + Export.RegionSize) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteValue(TEXT("units"), TEXT("metres"));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("player_dimensions"));
|
|
Writer->WriteValue(TEXT("capsule_radius_m"), static_cast<double>(FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0);
|
|
Writer->WriteValue(TEXT("capsule_width_m"), static_cast<double>(2.0f * FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0);
|
|
Writer->WriteValue(TEXT("capsule_half_height_m"), static_cast<double>(FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0);
|
|
Writer->WriteValue(TEXT("capsule_height_m"), static_cast<double>(2.0f * FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0);
|
|
Writer->WriteValue(TEXT("max_step_height_m"), static_cast<double>(FVoxelPlayerCapsuleConstants::MaxStepHeightMeters));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("world_invariants"));
|
|
Writer->WriteValue(TEXT("world_radius_voxels"), 0);
|
|
Writer->WriteValue(TEXT("lateral_regions_enabled"), false);
|
|
Writer->WriteValue(TEXT("diff_layer_mutated"), false);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectEnd();
|
|
return Writer->Close() ? Json : FString();
|
|
}
|
|
|
|
bool RunExport(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
const FExploreWalkOutput* SharedSampling,
|
|
FExploreExportOutput& OutOutput,
|
|
FExploreBudget& Budget)
|
|
{
|
|
OutOutput = FExploreExportOutput();
|
|
const double ExportStartSeconds = FPlatformTime::Seconds();
|
|
OutOutput.RegionSize = Arguments.ExportSize;
|
|
OutOutput.EstimatedWorkingBytes = EstimateExportWorkingBytes(Arguments.ExportSize);
|
|
if (OutOutput.EstimatedWorkingBytes > OutOutput.WorkingMemoryCapBytes)
|
|
{
|
|
OutOutput.Status = TEXT("refused");
|
|
OutOutput.RefusalReason = FString::Printf(
|
|
TEXT("Refused before meshing: estimated working memory %lld bytes exceeds cap %lld bytes."),
|
|
static_cast<long long>(OutOutput.EstimatedWorkingBytes),
|
|
static_cast<long long>(OutOutput.WorkingMemoryCapBytes));
|
|
return false;
|
|
}
|
|
|
|
FString MeshError;
|
|
const bool bMeshComplete = EnsureExploreMesh(
|
|
Arguments, World, SharedSampling, Budget, MeshError);
|
|
if (!bMeshComplete && !Budget.bTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = MeshError;
|
|
return false;
|
|
}
|
|
OutOutput.RegionSize = World.ExploreMeshSize;
|
|
OutOutput.MesherTileCount = World.ExploreMeshTileCount;
|
|
OutOutput.MesherTilesCompleted = World.ExploreMeshTilesCompleted;
|
|
OutOutput.RegionOrigin = World.ExploreMeshOrigin;
|
|
|
|
FString Error;
|
|
OutOutput.MeshFileName = TEXT("geometry.obj");
|
|
OutOutput.ManifestFileName = TEXT("manifest.json");
|
|
const FString MeshPath = FPaths::Combine(Arguments.OutDirectory, OutOutput.MeshFileName);
|
|
TUniquePtr<FArchive> Archive(IFileManager::Get().CreateFileWriter(*MeshPath));
|
|
if (!Archive.IsValid())
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = FString::Printf(TEXT("Could not create OBJ '%s'."), *MeshPath);
|
|
return false;
|
|
}
|
|
const double ObjWriteStartSeconds = FPlatformTime::Seconds();
|
|
if (!WriteObjHeader(*Archive))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not write the OBJ header.");
|
|
return false;
|
|
}
|
|
|
|
const double MeasureStartSeconds = FPlatformTime::Seconds();
|
|
if (!MeasureExploreMesh(World.ExploreMesh, OutOutput.SurfaceMetrics, Error))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = Error;
|
|
return false;
|
|
}
|
|
OutOutput.MeasureSeconds = FPlatformTime::Seconds() - MeasureStartSeconds;
|
|
OutOutput.MeshArrayBytes = static_cast<int64>(World.ExploreMesh.Vertices.Num()) * sizeof(FVector)
|
|
+ static_cast<int64>(World.ExploreMesh.Normals.Num()) * sizeof(FVector)
|
|
+ static_cast<int64>(World.ExploreMesh.UVs.Num()) * sizeof(FVector2D)
|
|
+ static_cast<int64>(World.ExploreMesh.Colors.Num()) * sizeof(FColor)
|
|
+ static_cast<int64>(World.ExploreMesh.Triangles.Num()) * sizeof(int32);
|
|
OutOutput.VertexCount = World.ExploreMesh.Vertices.Num();
|
|
OutOutput.TriangleCount = World.ExploreMesh.Triangles.Num() / 3;
|
|
OutOutput.MeshSeconds = World.ExploreMeshSeconds;
|
|
OutOutput.MeshUsPerVoxel = Arguments.ExportSize > 0
|
|
? OutOutput.MeshSeconds * 1.0e6
|
|
/ static_cast<double>(Arguments.ExportSize)
|
|
/ static_cast<double>(Arguments.ExportSize)
|
|
/ static_cast<double>(Arguments.ExportSize)
|
|
: 0.0;
|
|
OutOutput.GeometryHash = World.ExploreGeometryHash;
|
|
bool bObjWriteTruncated = false;
|
|
int32 VerticesWritten = 0;
|
|
int32 TrianglesWritten = 0;
|
|
if (!WriteObjMesh(
|
|
*Archive,
|
|
World.ExploreMesh,
|
|
Budget,
|
|
Error,
|
|
bObjWriteTruncated,
|
|
VerticesWritten,
|
|
TrianglesWritten))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = Error;
|
|
return false;
|
|
}
|
|
if (Archive->IsError())
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not finish the streamed OBJ.");
|
|
return false;
|
|
}
|
|
Archive.Reset();
|
|
OutOutput.MeshFileSizeBytes = IFileManager::Get().FileSize(*MeshPath);
|
|
if (OutOutput.MeshFileSizeBytes < 0)
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("OBJ was written but its file size could not be read.");
|
|
return false;
|
|
}
|
|
OutOutput.ObjWriteSeconds = FPlatformTime::Seconds() - ObjWriteStartSeconds;
|
|
|
|
OutOutput.bTruncated = Budget.bTruncated
|
|
|| !World.bExploreMeshComplete
|
|
|| bObjWriteTruncated;
|
|
if (OutOutput.bTruncated)
|
|
{
|
|
OutOutput.VertexCount = VerticesWritten;
|
|
OutOutput.TriangleCount = TrianglesWritten;
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.RefusalReason = !World.bExploreMeshComplete
|
|
? MeshError
|
|
: TEXT("The wall-clock budget stopped export after writing a partial mesh.");
|
|
}
|
|
|
|
const double ManifestStartSeconds = FPlatformTime::Seconds();
|
|
const FString ManifestJson = BuildManifestJson(Arguments, OutOutput);
|
|
const FString ManifestJsonRepeat = BuildManifestJson(Arguments, OutOutput);
|
|
if (ManifestJson.IsEmpty() || ManifestJsonRepeat.IsEmpty())
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("The export manifest JSON writer failed.");
|
|
return false;
|
|
}
|
|
if (ManifestJson != ManifestJsonRepeat)
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("The export manifest failed its deterministic repeat assertion.");
|
|
return false;
|
|
}
|
|
if (!FFileHelper::SaveStringToFile(
|
|
ManifestJson,
|
|
*FPaths::Combine(Arguments.OutDirectory, OutOutput.ManifestFileName),
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not write export manifest.json.");
|
|
return false;
|
|
}
|
|
OutOutput.ManifestSeconds = FPlatformTime::Seconds() - ManifestStartSeconds;
|
|
OutOutput.ExportWallSeconds = FPlatformTime::Seconds() - ExportStartSeconds;
|
|
OutOutput.ExportAccountingRemainderSeconds = OutOutput.ExportWallSeconds
|
|
- (World.ExploreMeshSeconds
|
|
+ World.ExploreMeshHashSeconds
|
|
+ OutOutput.ObjWriteSeconds
|
|
+ OutOutput.ManifestSeconds);
|
|
if (!OutOutput.bTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("ok");
|
|
}
|
|
return !OutOutput.bTruncated;
|
|
}
|
|
|
|
bool RunDensitySlice(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
FExploreDensitySliceOutput& OutOutput,
|
|
FExploreBudget& Budget)
|
|
{
|
|
OutOutput = FExploreDensitySliceOutput();
|
|
OutOutput.FileName = TEXT("density_slice.csv");
|
|
OutOutput.SampleCount = Arguments.DensitySliceSamples;
|
|
const double StartSeconds = FPlatformTime::Seconds();
|
|
FString Csv;
|
|
Csv.Reserve(Arguments.DensitySliceSamples * 96);
|
|
Csv += TEXT("index,x_voxels,y_voxels,z_voxels,density\r\n");
|
|
|
|
float PreviousDensity = 0.0f;
|
|
bool bHavePreviousDensity = false;
|
|
float MinDensity = TNumericLimits<float>::Max();
|
|
float MaxDensity = -TNumericLimits<float>::Max();
|
|
float MaxAbsDensity = 0.0f;
|
|
for (int32 Index = 0; Index < Arguments.DensitySliceSamples; ++Index)
|
|
{
|
|
if ((Index & 127) == 0 && Budget.ShouldStop(TEXT("density-slice")))
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget stopped the density slice.");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
return false;
|
|
}
|
|
|
|
const FVector Position = Arguments.DensitySliceOrigin
|
|
+ Arguments.DensitySliceDirection
|
|
* (Arguments.DensitySliceStep * static_cast<float>(Index));
|
|
const float Density = World.Generator->GetDensityAt(
|
|
Position.X, Position.Y, Position.Z);
|
|
Csv += FString::Printf(
|
|
TEXT("%d,%.9g,%.9g,%.9g,%.9g\r\n"),
|
|
Index, Position.X, Position.Y, Position.Z, Density);
|
|
|
|
if (!FMath::IsFinite(Density))
|
|
{
|
|
++OutOutput.NonFiniteSampleCount;
|
|
bHavePreviousDensity = false;
|
|
continue;
|
|
}
|
|
++OutOutput.FiniteSampleCount;
|
|
MinDensity = FMath::Min(MinDensity, Density);
|
|
MaxDensity = FMath::Max(MaxDensity, Density);
|
|
MaxAbsDensity = FMath::Max(MaxAbsDensity, FMath::Abs(Density));
|
|
if (FMath::Abs(Density) >= 0.999f)
|
|
{
|
|
++OutOutput.SaturatedSampleCount;
|
|
}
|
|
if (bHavePreviousDensity
|
|
&& ((PreviousDensity < 0.0f && Density > 0.0f)
|
|
|| (PreviousDensity > 0.0f && Density < 0.0f)))
|
|
{
|
|
++OutOutput.ZeroCrossingCount;
|
|
}
|
|
PreviousDensity = Density;
|
|
bHavePreviousDensity = true;
|
|
}
|
|
|
|
if (OutOutput.FiniteSampleCount > 0)
|
|
{
|
|
OutOutput.MinDensity = MinDensity;
|
|
OutOutput.MaxDensity = MaxDensity;
|
|
OutOutput.MaxAbsDensity = MaxAbsDensity;
|
|
}
|
|
const FString Path = FPaths::Combine(Arguments.OutDirectory, OutOutput.FileName);
|
|
if (!FFileHelper::SaveStringToFile(
|
|
Csv, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not write density_slice.csv.");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
return false;
|
|
}
|
|
OutOutput.Status = TEXT("ok");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeDensitySlice] file=%s samples=%d finite=%d non_finite=%d "
|
|
"zero_crossings=%d saturated=%d min=%.9g max=%.9g max_abs=%.9g "
|
|
"origin=(%.3f,%.3f,%.3f) direction=(%.6f,%.6f,%.6f) step=%.6g"),
|
|
*Path, OutOutput.SampleCount, OutOutput.FiniteSampleCount,
|
|
OutOutput.NonFiniteSampleCount, OutOutput.ZeroCrossingCount,
|
|
OutOutput.SaturatedSampleCount, OutOutput.MinDensity,
|
|
OutOutput.MaxDensity, OutOutput.MaxAbsDensity,
|
|
Arguments.DensitySliceOrigin.X, Arguments.DensitySliceOrigin.Y,
|
|
Arguments.DensitySliceOrigin.Z, Arguments.DensitySliceDirection.X,
|
|
Arguments.DensitySliceDirection.Y, Arguments.DensitySliceDirection.Z,
|
|
Arguments.DensitySliceStep);
|
|
return true;
|
|
}
|
|
|
|
void ComputeTunnelCoreProbeMetrics(
|
|
const TArray<float>& Samples,
|
|
float StepVoxels,
|
|
int32& OutValidCount,
|
|
float& OutMean,
|
|
float& OutMin,
|
|
float& OutMax,
|
|
float& OutPeriodicAmplitudeVoxels,
|
|
float& OutPeriodicPeriodVoxels,
|
|
int32& OutPeriodicMeasuredSamples,
|
|
float& OutPeriodicMeasuredLengthVoxels,
|
|
bool bPolynomialTrend = false)
|
|
{
|
|
OutValidCount = 0;
|
|
OutMean = 0.0f;
|
|
OutMin = 0.0f;
|
|
OutMax = 0.0f;
|
|
OutPeriodicAmplitudeVoxels = 0.0f;
|
|
OutPeriodicPeriodVoxels = 0.0f;
|
|
OutPeriodicMeasuredSamples = 0;
|
|
OutPeriodicMeasuredLengthVoxels = 0.0f;
|
|
if (Samples.Num() < 4 || !FMath::IsFinite(StepVoxels) || StepVoxels <= 0.0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
double Sum = 0.0;
|
|
for (int32 Index = 0; Index < Samples.Num(); ++Index)
|
|
{
|
|
const float Value = Samples[Index];
|
|
if (!FMath::IsFinite(Value))
|
|
{
|
|
continue;
|
|
}
|
|
++OutValidCount;
|
|
Sum += Value;
|
|
if (OutValidCount == 1)
|
|
{
|
|
OutMin = Value;
|
|
OutMax = Value;
|
|
}
|
|
else
|
|
{
|
|
OutMin = FMath::Min(OutMin, Value);
|
|
OutMax = FMath::Max(OutMax, Value);
|
|
}
|
|
}
|
|
if (OutValidCount < 4)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const double Count = static_cast<double>(OutValidCount);
|
|
OutMean = static_cast<float>(Sum / Count);
|
|
|
|
// Find the longest contiguous surface run. A missing radial crossing is not a zero-valued
|
|
// sample: it is a hole in the measurement and must not be bridged before the spectral pass.
|
|
int32 BestRunStart = INDEX_NONE;
|
|
int32 BestRunEnd = INDEX_NONE;
|
|
int32 RunStart = INDEX_NONE;
|
|
for (int32 Index = 0; Index <= Samples.Num(); ++Index)
|
|
{
|
|
const bool bFinite = Index < Samples.Num() && FMath::IsFinite(Samples[Index]);
|
|
if (bFinite)
|
|
{
|
|
if (RunStart == INDEX_NONE)
|
|
{
|
|
RunStart = Index;
|
|
}
|
|
continue;
|
|
}
|
|
if (RunStart != INDEX_NONE
|
|
&& (BestRunStart == INDEX_NONE
|
|
|| Index - RunStart > BestRunEnd - BestRunStart + 1))
|
|
{
|
|
BestRunStart = RunStart;
|
|
BestRunEnd = Index - 1;
|
|
}
|
|
RunStart = INDEX_NONE;
|
|
}
|
|
const int32 BestRunLength = BestRunStart != INDEX_NONE
|
|
? BestRunEnd - BestRunStart + 1
|
|
: 0;
|
|
// The requested acceptance window is at least 40 voxels. Keep the raw crossing statistics
|
|
// above for shorter runs, but explicitly mark their periodic result as unmeasured.
|
|
constexpr float RequiredLengthVoxels = 40.0f;
|
|
const int32 RequiredSampleCount = FMath::CeilToInt(RequiredLengthVoxels / StepVoxels) + 1;
|
|
if (BestRunLength < FMath::Max(4, RequiredSampleCount))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Remove only the slow authored floor/radius trend. A 32-voxel median window is wider than
|
|
// the 1..16 voxel rib band, so a genuine rib train survives while a single tunnel transition
|
|
// or route slope follows the local baseline. The mouth probe uses a degree-six fit instead: the
|
|
// room-side +/-2R window can contain one broad floor bowl, which a local median would turn
|
|
// into a false short-period peak when only the middle 21.5 voxels remain.
|
|
constexpr float TrendWindowVoxels = 32.0f;
|
|
const int32 HalfWindowSamples = FMath::Max(
|
|
1, FMath::RoundToInt(0.5f * TrendWindowVoxels / StepVoxels));
|
|
const int32 AnalysisStart = bPolynomialTrend
|
|
? BestRunStart : BestRunStart + HalfWindowSamples;
|
|
const int32 AnalysisEnd = bPolynomialTrend
|
|
? BestRunEnd : BestRunEnd - HalfWindowSamples;
|
|
const int32 AnalysisCount = AnalysisEnd - AnalysisStart + 1;
|
|
if (AnalysisCount < 4)
|
|
{
|
|
return;
|
|
}
|
|
|
|
OutPeriodicMeasuredSamples = AnalysisCount;
|
|
OutPeriodicMeasuredLengthVoxels = static_cast<float>(AnalysisCount - 1) * StepVoxels;
|
|
|
|
TArray<float, TInlineAllocator<257>> Residuals;
|
|
Residuals.SetNumZeroed(AnalysisCount);
|
|
bool bHavePolynomialTrend = false;
|
|
if (bPolynomialTrend)
|
|
{
|
|
// Fit a normalized degree-six smooth trend over the complete contiguous surface run. The
|
|
// room-side +/-2R sample can contain a broad, asymmetric floor bowl; a cubic leaves that
|
|
// endpoint curvature in the rib-band DFT. Degree six follows that slow authored shape but
|
|
// cannot reproduce a repeated <=16-voxel train across the full run. Keep the legacy
|
|
// local-median measurement separately as an audit of the conservative raw instrument.
|
|
constexpr int32 PolynomialDegree = 6;
|
|
double Matrix[PolynomialDegree + 1][PolynomialDegree + 1] = {};
|
|
double RightHandSide[PolynomialDegree + 1] = {};
|
|
const double Denominator = static_cast<double>(FMath::Max(
|
|
BestRunLength - 1, 1));
|
|
for (int32 Index = BestRunStart; Index <= BestRunEnd; ++Index)
|
|
{
|
|
const double NormalizedX = -1.0
|
|
+ 2.0 * static_cast<double>(Index - BestRunStart) / Denominator;
|
|
double Basis[PolynomialDegree + 1];
|
|
Basis[0] = 1.0;
|
|
for (int32 Power = 1; Power <= PolynomialDegree; ++Power)
|
|
{
|
|
Basis[Power] = Basis[Power - 1] * NormalizedX;
|
|
}
|
|
const double Value = static_cast<double>(Samples[Index]);
|
|
for (int32 Row = 0; Row <= PolynomialDegree; ++Row)
|
|
{
|
|
RightHandSide[Row] += Basis[Row] * Value;
|
|
for (int32 Column = 0; Column <= PolynomialDegree; ++Column)
|
|
{
|
|
Matrix[Row][Column] += Basis[Row] * Basis[Column];
|
|
}
|
|
}
|
|
}
|
|
|
|
double Coefficients[PolynomialDegree + 1] = {};
|
|
bHavePolynomialTrend = true;
|
|
for (int32 PivotIndex = 0; PivotIndex <= PolynomialDegree; ++PivotIndex)
|
|
{
|
|
int32 PivotRow = PivotIndex;
|
|
for (int32 Row = PivotIndex + 1; Row <= PolynomialDegree; ++Row)
|
|
{
|
|
if (FMath::Abs(Matrix[Row][PivotIndex])
|
|
> FMath::Abs(Matrix[PivotRow][PivotIndex]))
|
|
{
|
|
PivotRow = Row;
|
|
}
|
|
}
|
|
if (FMath::Abs(Matrix[PivotRow][PivotIndex]) <= 1.0e-12)
|
|
{
|
|
bHavePolynomialTrend = false;
|
|
break;
|
|
}
|
|
if (PivotRow != PivotIndex)
|
|
{
|
|
for (int32 Column = PivotIndex;
|
|
Column <= PolynomialDegree; ++Column)
|
|
{
|
|
Swap(Matrix[PivotIndex][Column], Matrix[PivotRow][Column]);
|
|
}
|
|
Swap(RightHandSide[PivotIndex], RightHandSide[PivotRow]);
|
|
}
|
|
for (int32 Row = PivotIndex + 1; Row <= PolynomialDegree; ++Row)
|
|
{
|
|
const double Factor = Matrix[Row][PivotIndex]
|
|
/ Matrix[PivotIndex][PivotIndex];
|
|
for (int32 Column = PivotIndex;
|
|
Column <= PolynomialDegree; ++Column)
|
|
{
|
|
Matrix[Row][Column] -= Factor * Matrix[PivotIndex][Column];
|
|
}
|
|
RightHandSide[Row] -= Factor * RightHandSide[PivotIndex];
|
|
}
|
|
}
|
|
if (bHavePolynomialTrend)
|
|
{
|
|
for (int32 Row = PolynomialDegree; Row >= 0; --Row)
|
|
{
|
|
double Value = RightHandSide[Row];
|
|
for (int32 Column = Row + 1;
|
|
Column <= PolynomialDegree; ++Column)
|
|
{
|
|
Value -= Matrix[Row][Column] * Coefficients[Column];
|
|
}
|
|
Coefficients[Row] = Value / Matrix[Row][Row];
|
|
}
|
|
for (int32 Index = AnalysisStart; Index <= AnalysisEnd; ++Index)
|
|
{
|
|
const double NormalizedX = -1.0
|
|
+ 2.0 * static_cast<double>(Index - BestRunStart) / Denominator;
|
|
double Basis = 1.0;
|
|
double Trend = 0.0;
|
|
for (int32 Power = 0; Power <= PolynomialDegree; ++Power)
|
|
{
|
|
Trend += Coefficients[Power] * Basis;
|
|
Basis *= NormalizedX;
|
|
}
|
|
Residuals[Index - AnalysisStart] =
|
|
static_cast<float>(static_cast<double>(Samples[Index]) - Trend);
|
|
}
|
|
}
|
|
}
|
|
|
|
constexpr float MaxRibPeriodVoxels = 16.0f;
|
|
const int32 MinRibFrequency = FMath::Max(
|
|
1, FMath::CeilToInt(StepVoxels * static_cast<float>(AnalysisCount)
|
|
/ MaxRibPeriodVoxels));
|
|
const int32 MaxFrequency = AnalysisCount / 2;
|
|
double BestAmplitude = 0.0;
|
|
int32 BestFrequency = 0;
|
|
for (int32 Frequency = MinRibFrequency; Frequency <= MaxFrequency; ++Frequency)
|
|
{
|
|
double Real = 0.0;
|
|
double Imaginary = 0.0;
|
|
for (int32 Index = AnalysisStart; Index <= AnalysisEnd; ++Index)
|
|
{
|
|
const int32 AnalysisIndex = Index - AnalysisStart;
|
|
if (!bHavePolynomialTrend)
|
|
{
|
|
TArray<float, TInlineAllocator<129>> Window;
|
|
const int32 WindowStart = FMath::Max(BestRunStart, Index - HalfWindowSamples);
|
|
const int32 WindowEnd = FMath::Min(BestRunEnd, Index + HalfWindowSamples);
|
|
Window.Reserve(WindowEnd - WindowStart + 1);
|
|
for (int32 WindowIndex = WindowStart; WindowIndex <= WindowEnd; ++WindowIndex)
|
|
{
|
|
Window.Add(Samples[WindowIndex]);
|
|
}
|
|
Window.Sort();
|
|
const int32 Middle = Window.Num() / 2;
|
|
const double Median = (Window.Num() & 1) != 0
|
|
? static_cast<double>(Window[Middle])
|
|
: 0.5 * (static_cast<double>(Window[Middle - 1])
|
|
+ static_cast<double>(Window[Middle]));
|
|
Residuals[AnalysisIndex] = static_cast<float>(
|
|
static_cast<double>(Samples[Index]) - Median);
|
|
}
|
|
const double Residual = static_cast<double>(Residuals[AnalysisIndex]);
|
|
const double Angle = 2.0 * PI * static_cast<double>(Frequency)
|
|
* static_cast<double>(AnalysisIndex) / static_cast<double>(AnalysisCount);
|
|
Real += Residual * FMath::Cos(static_cast<float>(Angle));
|
|
Imaginary -= Residual * FMath::Sin(static_cast<float>(Angle));
|
|
}
|
|
const double Amplitude = 2.0 * FMath::Sqrt(Real * Real + Imaginary * Imaginary)
|
|
/ static_cast<double>(AnalysisCount);
|
|
if (Amplitude > BestAmplitude)
|
|
{
|
|
BestAmplitude = Amplitude;
|
|
BestFrequency = Frequency;
|
|
}
|
|
}
|
|
OutPeriodicAmplitudeVoxels = static_cast<float>(BestAmplitude);
|
|
OutPeriodicPeriodVoxels = BestFrequency > 0
|
|
? StepVoxels * static_cast<float>(AnalysisCount)
|
|
/ static_cast<float>(BestFrequency)
|
|
: 0.0f;
|
|
}
|
|
|
|
void ComputeTunnelMouthProbeMetrics(
|
|
FExploreTunnelMouthProbeAxis& Axis,
|
|
float StepVoxels)
|
|
{
|
|
int32 LegacyValid = 0;
|
|
float LegacyMean = 0.0f;
|
|
float LegacyMin = 0.0f;
|
|
float LegacyMax = 0.0f;
|
|
int32 LegacyMeasuredSamples = 0;
|
|
float LegacyMeasuredLength = 0.0f;
|
|
ComputeTunnelCoreProbeMetrics(
|
|
Axis.CrossingsVoxels, StepVoxels,
|
|
Axis.ValidCrossings, Axis.MeanCrossingVoxels,
|
|
Axis.MinCrossingVoxels, Axis.MaxCrossingVoxels,
|
|
Axis.LocalMedianPeriodicAmplitudeVoxels,
|
|
Axis.LocalMedianPeriodicPeriodVoxels,
|
|
LegacyMeasuredSamples, LegacyMeasuredLength);
|
|
ComputeTunnelCoreProbeMetrics(
|
|
Axis.CrossingsVoxels, StepVoxels,
|
|
LegacyValid, LegacyMean, LegacyMin, LegacyMax,
|
|
Axis.PeriodicAmplitudeVoxels, Axis.PeriodicPeriodVoxels,
|
|
Axis.PeriodicMeasuredSamples, Axis.PeriodicMeasuredLengthVoxels,
|
|
/*bPolynomialTrend=*/true);
|
|
}
|
|
|
|
bool RunTunnelCoreProbe(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
FExploreTunnelCoreProbeOutput& OutOutput,
|
|
FExploreBudget& Budget)
|
|
{
|
|
OutOutput = FExploreTunnelCoreProbeOutput();
|
|
OutOutput.FileName = TEXT("tunnel_core_probe.csv");
|
|
OutOutput.StepVoxels = 0.25f;
|
|
const double StartSeconds = FPlatformTime::Seconds();
|
|
|
|
if (!World.Manager.IsValid() || !World.Generator.IsValid()
|
|
|| !World.Settings.IsValid()
|
|
|| !World.Definitions.IsValidIndex(Arguments.Slot))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("The owner-value probe has no complete transient world.");
|
|
return false;
|
|
}
|
|
|
|
const int32 TargetBottomChunkZ = FMath::FloorToInt(
|
|
static_cast<float>(World.TargetBottomWorldZ) / static_cast<float>(CHUNK_SIZE));
|
|
const int32 TargetTopChunkZ = FMath::FloorToInt(
|
|
(static_cast<float>(World.TargetTopWorldZ) - KINDA_SMALL_NUMBER)
|
|
/ static_cast<float>(CHUNK_SIZE));
|
|
const float TargetMinZ = static_cast<float>(World.TargetBottomWorldZ);
|
|
const float TargetMaxZ = static_cast<float>(World.TargetTopWorldZ);
|
|
constexpr float TargetBandPadding = 8.0f;
|
|
constexpr float RouteParameterCheckStep = 4.0f;
|
|
|
|
// The owner path asks GetGenerationParams for the exact (x,y,z) chunk of every sample. A
|
|
// cache built from an arbitrary interior Z can therefore be a different graph in a boundary
|
|
// blend band; that was the source of the earlier probe's FLT_MAX/core-only result. Build the
|
|
// small set of candidate graphs used by the target Z chunks, and retain only a complete chain
|
|
// whose route and radial measurement band resolve to the same parameter fingerprint that was
|
|
// used to bake the graph. The field measured below is then the same field the game queries.
|
|
auto GenerationParamsFingerprint = [](const FStrateGenerationParams& InParams) -> uint32
|
|
{
|
|
return FCrc::MemCrc32(&InParams, sizeof(InParams));
|
|
};
|
|
|
|
FChunkSDFCache Cache;
|
|
FStrateGenerationParams Params;
|
|
int32 StrateIndex = INDEX_NONE;
|
|
int32 QueryChunkZ = INDEX_NONE;
|
|
const FCachedTunnel* BestTunnel = nullptr;
|
|
float BestLength = 0.0f;
|
|
int32 BestTunnelIndex = INDEX_NONE;
|
|
bool bBestTunnelFullyInTargetStrate = false;
|
|
int32 BestTargetBandPointCount = 0;
|
|
uint32 BestParamsFingerprint = 0xFFFFFFFFu;
|
|
const uint32 WorldSeed = static_cast<uint32>(World.Settings->Seed);
|
|
|
|
for (int32 CandidateChunkZ = TargetBottomChunkZ;
|
|
CandidateChunkZ <= TargetTopChunkZ;
|
|
++CandidateChunkZ)
|
|
{
|
|
if (Budget.ShouldStop(TEXT("tunnel-core-probe-cache")))
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget stopped candidate cache construction.");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
return false;
|
|
}
|
|
|
|
const FIntVector CandidateChunk(0, 0, CandidateChunkZ);
|
|
const FStrateGenerationParams CandidateParams =
|
|
World.Manager->GetGenerationParams(CandidateChunk);
|
|
const int32 CandidateStrateIndex = World.Manager->GetStrateIndex(
|
|
(static_cast<float>(CandidateChunk.Z) + 0.5f)
|
|
* static_cast<float>(CHUNK_SIZE) * VOXEL_SIZE);
|
|
if (CandidateStrateIndex < 0
|
|
|| !(CandidateParams.RoomDensity > 0.0f)
|
|
|| !(CandidateParams.RoomSpacing > 0.0f))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
const UVoxelStrateDefinition* CandidateDefinition =
|
|
World.Manager->GetStrateForChunk(CandidateChunk);
|
|
const TArray<FStrateTerrainOpEntry>* CandidateTerrainOps = CandidateDefinition != nullptr
|
|
? &CandidateDefinition->TerrainOperations : nullptr;
|
|
const uint32 CandidateFingerprint = GenerationParamsFingerprint(CandidateParams);
|
|
|
|
// The owner render region is centred near the origin. Keep each candidate cache broad
|
|
// enough for the local graph, exactly like the ordinary point-query cache window.
|
|
FChunkSDFCache CandidateCache;
|
|
VoxelCaveMorphology::BuildChunkCache(
|
|
CandidateCache, -256.0f, -256.0f, 256.0f, 256.0f,
|
|
CandidateParams, WorldSeed, CandidateStrateIndex,
|
|
CandidateTerrainOps, ERoomGraphBuildSite::GeneratorTunnelCore);
|
|
|
|
int32 CandidateBestTunnelIndex = INDEX_NONE;
|
|
float CandidateBestLength = 0.0f;
|
|
int32 CandidateBestTargetSampleCount = 0;
|
|
for (int32 TunnelIndex = 0; TunnelIndex < CandidateCache.Tunnels.Num(); ++TunnelIndex)
|
|
{
|
|
const FCachedTunnel& Tunnel = CandidateCache.Tunnels[TunnelIndex];
|
|
const TArray<FVector>& Points = Tunnel.WorldControlPoints.Num() >= 2
|
|
&& Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num()
|
|
? Tunnel.WorldControlPoints : Tunnel.ControlPoints;
|
|
const TArray<float>& Radii = Tunnel.WorldControlRadii.Num() == Points.Num()
|
|
? Tunnel.WorldControlRadii : Tunnel.ControlRadii;
|
|
if (Points.Num() < 2 || Radii.Num() != Points.Num())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
TArray<float> CandidateCumulativeLengths;
|
|
CandidateCumulativeLengths.SetNumZeroed(Points.Num());
|
|
bool bFinite = true;
|
|
float Length = 0.0f;
|
|
float MaxRadius = 0.0f;
|
|
for (int32 PointIndex = 0; PointIndex + 1 < Points.Num(); ++PointIndex)
|
|
{
|
|
const float SegmentLength = FVector::Dist(
|
|
Points[PointIndex], Points[PointIndex + 1]);
|
|
if (!FMath::IsFinite(SegmentLength))
|
|
{
|
|
bFinite = false;
|
|
break;
|
|
}
|
|
Length += SegmentLength;
|
|
CandidateCumulativeLengths[PointIndex + 1] = Length;
|
|
MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Radii[PointIndex]));
|
|
}
|
|
if (Points.Num() > 0)
|
|
{
|
|
MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Radii.Last()));
|
|
}
|
|
if (!bFinite || Length < 40.0f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
auto PointAtCandidateDistance = [&](float Distance) -> FVector
|
|
{
|
|
const float ClampedDistance = FMath::Clamp(Distance, 0.0f, Length);
|
|
for (int32 SegmentIndex = 0;
|
|
SegmentIndex + 1 < Points.Num();
|
|
++SegmentIndex)
|
|
{
|
|
if (ClampedDistance <= CandidateCumulativeLengths[SegmentIndex + 1]
|
|
|| SegmentIndex + 2 == Points.Num())
|
|
{
|
|
const float SegmentLength = CandidateCumulativeLengths[SegmentIndex + 1]
|
|
- CandidateCumulativeLengths[SegmentIndex];
|
|
const float Alpha = SegmentLength > KINDA_SMALL_NUMBER
|
|
? (ClampedDistance - CandidateCumulativeLengths[SegmentIndex])
|
|
/ SegmentLength : 0.0f;
|
|
return FMath::Lerp(Points[SegmentIndex], Points[SegmentIndex + 1], Alpha);
|
|
}
|
|
}
|
|
return Points.Last();
|
|
};
|
|
|
|
const float RadialParameterReach = FMath::Clamp(
|
|
MaxRadius + 12.0f, 12.0f, 64.0f);
|
|
bool bHasTargetSample = false;
|
|
bool bRouteMatchesParams = true;
|
|
int32 TargetSampleCount = 0;
|
|
const int32 ParameterSampleCount = FMath::CeilToInt(
|
|
Length / RouteParameterCheckStep);
|
|
for (int32 SampleIndex = 0;
|
|
SampleIndex <= ParameterSampleCount;
|
|
++SampleIndex)
|
|
{
|
|
const float Distance = FMath::Min(
|
|
Length, static_cast<float>(SampleIndex) * RouteParameterCheckStep);
|
|
const FVector Point = PointAtCandidateDistance(Distance);
|
|
const bool bInTargetBand = Point.Z >= TargetMinZ - TargetBandPadding
|
|
&& Point.Z <= TargetMaxZ + TargetBandPadding;
|
|
bHasTargetSample |= bInTargetBand;
|
|
TargetSampleCount += bInTargetBand ? 1 : 0;
|
|
if (!bInTargetBand)
|
|
{
|
|
bRouteMatchesParams = false;
|
|
break;
|
|
}
|
|
|
|
const FIntVector PointChunk(
|
|
FMath::FloorToInt(Point.X / static_cast<float>(CHUNK_SIZE)),
|
|
FMath::FloorToInt(Point.Y / static_cast<float>(CHUNK_SIZE)),
|
|
FMath::FloorToInt(Point.Z / static_cast<float>(CHUNK_SIZE)));
|
|
if (GenerationParamsFingerprint(
|
|
World.Manager->GetGenerationParams(PointChunk)) != CandidateFingerprint)
|
|
{
|
|
bRouteMatchesParams = false;
|
|
break;
|
|
}
|
|
|
|
// The floor, wall and ceiling roots are measured around this same centerline.
|
|
// Reject a route whose radial band crosses into a different parameter blend; a
|
|
// single cache cannot represent that discontinuous query context.
|
|
for (float RadialOffset = -RadialParameterReach;
|
|
RadialOffset <= RadialParameterReach + KINDA_SMALL_NUMBER;
|
|
RadialOffset += 8.0f)
|
|
{
|
|
const FVector RadialPoint = Point + FVector(0.0f, 0.0f, RadialOffset);
|
|
const FIntVector RadialChunk(
|
|
FMath::FloorToInt(RadialPoint.X / static_cast<float>(CHUNK_SIZE)),
|
|
FMath::FloorToInt(RadialPoint.Y / static_cast<float>(CHUNK_SIZE)),
|
|
FMath::FloorToInt(RadialPoint.Z / static_cast<float>(CHUNK_SIZE)));
|
|
if (GenerationParamsFingerprint(
|
|
World.Manager->GetGenerationParams(RadialChunk))
|
|
!= CandidateFingerprint)
|
|
{
|
|
bRouteMatchesParams = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!bRouteMatchesParams)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (bHasTargetSample && bRouteMatchesParams
|
|
&& (TargetSampleCount > CandidateBestTargetSampleCount
|
|
|| (TargetSampleCount == CandidateBestTargetSampleCount
|
|
&& Length > CandidateBestLength)))
|
|
{
|
|
CandidateBestTunnelIndex = TunnelIndex;
|
|
CandidateBestLength = Length;
|
|
CandidateBestTargetSampleCount = TargetSampleCount;
|
|
}
|
|
}
|
|
|
|
if (CandidateBestTunnelIndex != INDEX_NONE
|
|
&& CandidateBestLength > BestLength)
|
|
{
|
|
Cache = MoveTemp(CandidateCache);
|
|
Params = CandidateParams;
|
|
StrateIndex = CandidateStrateIndex;
|
|
QueryChunkZ = CandidateChunkZ;
|
|
BestLength = CandidateBestLength;
|
|
BestTunnelIndex = CandidateBestTunnelIndex;
|
|
BestTargetBandPointCount = CandidateBestTargetSampleCount;
|
|
bBestTunnelFullyInTargetStrate = true;
|
|
BestParamsFingerprint = CandidateFingerprint;
|
|
}
|
|
}
|
|
|
|
BestTunnel = Cache.Tunnels.IsValidIndex(BestTunnelIndex)
|
|
? &Cache.Tunnels[BestTunnelIndex] : nullptr;
|
|
if (BestTunnel == nullptr || BestLength < 40.0f)
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = FString::Printf(
|
|
TEXT("No cached owner tunnel had a 40-voxel span with stable game params "
|
|
"and a stable radial band (best=%.3f)."), BestLength);
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
return false;
|
|
}
|
|
|
|
const TArray<FVector>& Points = BestTunnel->WorldControlPoints.Num() >= 2
|
|
&& BestTunnel->WorldControlRadii.Num() == BestTunnel->WorldControlPoints.Num()
|
|
? BestTunnel->WorldControlPoints : BestTunnel->ControlPoints;
|
|
TArray<float> CumulativeLengths;
|
|
CumulativeLengths.SetNumZeroed(Points.Num());
|
|
for (int32 PointIndex = 0; PointIndex + 1 < Points.Num(); ++PointIndex)
|
|
{
|
|
CumulativeLengths[PointIndex + 1] = CumulativeLengths[PointIndex]
|
|
+ FVector::Dist(Points[PointIndex], Points[PointIndex + 1]);
|
|
}
|
|
|
|
const float ProbeStep = OutOutput.StepVoxels;
|
|
const int32 MaxProbeSamples = 4097;
|
|
float ProbeStart = 0.0f;
|
|
float ProbeLength = FMath::Min(
|
|
BestLength, static_cast<float>(MaxProbeSamples - 1) * ProbeStep);
|
|
|
|
auto PointAtDistance = [&](float Distance) -> FVector
|
|
{
|
|
const float ClampedDistance = FMath::Clamp(Distance, 0.0f, BestLength);
|
|
for (int32 SegmentIndex = 0; SegmentIndex + 1 < Points.Num(); ++SegmentIndex)
|
|
{
|
|
if (ClampedDistance <= CumulativeLengths[SegmentIndex + 1]
|
|
|| SegmentIndex + 2 == Points.Num())
|
|
{
|
|
const float SegmentLength = CumulativeLengths[SegmentIndex + 1]
|
|
- CumulativeLengths[SegmentIndex];
|
|
const float Alpha = SegmentLength > KINDA_SMALL_NUMBER
|
|
? (ClampedDistance - CumulativeLengths[SegmentIndex]) / SegmentLength : 0.0f;
|
|
return FMath::Lerp(Points[SegmentIndex], Points[SegmentIndex + 1], Alpha);
|
|
}
|
|
}
|
|
return Points.Last();
|
|
};
|
|
|
|
// Keep a long, endpoint-inset window on the selected tunnel chain. The DFT is intended to
|
|
// expose a repeated radial surface displacement, so using the entire wandering chain would
|
|
// mix mouth overlap and changes in the local frame into that signal. The center window stays
|
|
// on the same owner-parameter band already validated above and is at least 40 voxels long.
|
|
const float MaximumWindowLength = static_cast<float>(MaxProbeSamples - 1) * ProbeStep;
|
|
if (BestLength >= 56.0f)
|
|
{
|
|
ProbeLength = FMath::Min(96.0f, FMath::Min(BestLength - 16.0f, MaximumWindowLength));
|
|
if (ProbeLength >= 40.0f)
|
|
{
|
|
ProbeStart = FMath::Clamp(
|
|
(BestLength - ProbeLength) * 0.5f, 8.0f, BestLength - ProbeLength - 8.0f);
|
|
}
|
|
}
|
|
if (ProbeStart + ProbeLength > BestLength)
|
|
{
|
|
ProbeStart = 0.0f;
|
|
ProbeLength = FMath::Min(BestLength, MaximumWindowLength);
|
|
}
|
|
const int32 SampleCount = FMath::FloorToInt(ProbeLength / ProbeStep) + 1;
|
|
ProbeLength = static_cast<float>(SampleCount - 1) * ProbeStep;
|
|
OutOutput.TunnelIndex = BestTunnelIndex;
|
|
OutOutput.SampleCount = SampleCount;
|
|
OutOutput.LengthVoxels = ProbeLength;
|
|
|
|
FVector AxisDirection = PointAtDistance(ProbeStart + ProbeLength)
|
|
- PointAtDistance(ProbeStart);
|
|
AxisDirection.Z = 0.0f;
|
|
if (!AxisDirection.Normalize())
|
|
{
|
|
float LongestSegment = 0.0f;
|
|
for (int32 SegmentIndex = 0; SegmentIndex + 1 < Points.Num(); ++SegmentIndex)
|
|
{
|
|
FVector Candidate = Points[SegmentIndex + 1] - Points[SegmentIndex];
|
|
Candidate.Z = 0.0f;
|
|
const float SegmentLength = Candidate.Size();
|
|
if (SegmentLength > LongestSegment)
|
|
{
|
|
LongestSegment = SegmentLength;
|
|
AxisDirection = Candidate / SegmentLength;
|
|
}
|
|
}
|
|
}
|
|
if (!AxisDirection.Normalize())
|
|
{
|
|
AxisDirection = FVector::ForwardVector;
|
|
}
|
|
const FVector WallDirection(-AxisDirection.Y, AxisDirection.X, 0.0f);
|
|
OutOutput.AxisStart = PointAtDistance(ProbeStart);
|
|
OutOutput.AxisEnd = PointAtDistance(ProbeStart + ProbeLength);
|
|
OutOutput.AxisDirection = AxisDirection;
|
|
OutOutput.WallDirection = WallDirection;
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] cache_tunnels=%d strate_index=%d "
|
|
"query_chunk_z=%d target_z=[%.3f,%.3f] params_z=[%.3f,%.3f] selected=%d "
|
|
"params_fp=0x%08X window_start=%.3f window_length=%.3f "
|
|
"selected_fully_in_target=%d target_points=%d"),
|
|
Cache.Tunnels.Num(), StrateIndex, QueryChunkZ, TargetMinZ, TargetMaxZ,
|
|
Params.StrateBottomWorldZ, Params.StrateTopWorldZ, BestTunnelIndex,
|
|
BestParamsFingerprint, ProbeStart, ProbeLength,
|
|
bBestTunnelFullyInTargetStrate ? 1 : 0, BestTargetBandPointCount);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] selected_endpoints a=(%.3f,%.3f,%.3f) "
|
|
"b=(%.3f,%.3f,%.3f) world_min=(%.3f,%.3f,%.3f) "
|
|
"world_max=(%.3f,%.3f,%.3f) world_radius=%.3f"),
|
|
BestTunnel->EndpointA.X, BestTunnel->EndpointA.Y, BestTunnel->EndpointA.Z,
|
|
BestTunnel->EndpointB.X, BestTunnel->EndpointB.Y, BestTunnel->EndpointB.Z,
|
|
BestTunnel->WorldCenterlineMin.X, BestTunnel->WorldCenterlineMin.Y,
|
|
BestTunnel->WorldCenterlineMin.Z, BestTunnel->WorldCenterlineMax.X,
|
|
BestTunnel->WorldCenterlineMax.Y, BestTunnel->WorldCenterlineMax.Z,
|
|
FMath::Sqrt(FMath::Max(BestTunnel->WorldBoundRadiusSq, 0.0f)));
|
|
|
|
float MaxRadius = 0.0f;
|
|
const TArray<float>& Radii = BestTunnel->WorldControlRadii.Num() == Points.Num()
|
|
? BestTunnel->WorldControlRadii : BestTunnel->ControlRadii;
|
|
for (const float Radius : Radii)
|
|
{
|
|
MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Radius));
|
|
}
|
|
const float MaxRadialDistance = FMath::Clamp(MaxRadius + 12.0f, 12.0f, 64.0f);
|
|
|
|
auto FindZeroCrossing = [&](const FVector& Origin, const FVector& Direction,
|
|
bool bCoreSDF, float& OutDistance) -> bool
|
|
{
|
|
auto Evaluate = [&](float Distance) -> float
|
|
{
|
|
const FVector Position = Origin + Direction * Distance;
|
|
return bCoreSDF
|
|
? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(
|
|
Position.X, Position.Y, Position.Z, Cache, true)
|
|
: World.Generator->GetDensityAt(Position.X, Position.Y, Position.Z);
|
|
};
|
|
float PreviousDistance = 0.0f;
|
|
float PreviousValue = Evaluate(0.0f);
|
|
bool bHavePrevious = FMath::IsFinite(PreviousValue);
|
|
if (bHavePrevious && FMath::Abs(PreviousValue) <= 1.0e-4f)
|
|
{
|
|
OutDistance = 0.0f;
|
|
return true;
|
|
}
|
|
const int32 RadialSteps = FMath::CeilToInt(MaxRadialDistance / ProbeStep);
|
|
for (int32 StepIndex = 1; StepIndex <= RadialSteps; ++StepIndex)
|
|
{
|
|
const float CurrentDistance = FMath::Min(
|
|
MaxRadialDistance, static_cast<float>(StepIndex) * ProbeStep);
|
|
const float CurrentValue = Evaluate(CurrentDistance);
|
|
if (!FMath::IsFinite(CurrentValue))
|
|
{
|
|
// A transient invalid query must split the finite run, not poison every
|
|
// subsequent sample. The floor ray can cross a chunk/parameter seam where
|
|
// one exact fractional query is unavailable; later finite samples still form a
|
|
// valid zero-crossing interval and are deliberately allowed to restart it.
|
|
bHavePrevious = false;
|
|
continue;
|
|
}
|
|
if (!bHavePrevious)
|
|
{
|
|
PreviousDistance = CurrentDistance;
|
|
PreviousValue = CurrentValue;
|
|
bHavePrevious = true;
|
|
if (FMath::Abs(CurrentValue) <= 1.0e-4f)
|
|
{
|
|
OutDistance = CurrentDistance;
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
const bool bCrossed = (PreviousValue < 0.0f && CurrentValue >= 0.0f)
|
|
|| (PreviousValue > 0.0f && CurrentValue <= 0.0f);
|
|
if (bCrossed)
|
|
{
|
|
float Low = PreviousDistance;
|
|
float High = CurrentDistance;
|
|
float LowValue = PreviousValue;
|
|
for (int32 Iteration = 0; Iteration < 24; ++Iteration)
|
|
{
|
|
const float Mid = (Low + High) * 0.5f;
|
|
const float MidValue = Evaluate(Mid);
|
|
if (!FMath::IsFinite(MidValue))
|
|
{
|
|
break;
|
|
}
|
|
if (FMath::Abs(MidValue) <= 1.0e-5f)
|
|
{
|
|
Low = Mid;
|
|
High = Mid;
|
|
break;
|
|
}
|
|
if ((LowValue < 0.0f && MidValue < 0.0f)
|
|
|| (LowValue > 0.0f && MidValue > 0.0f))
|
|
{
|
|
Low = Mid;
|
|
LowValue = MidValue;
|
|
}
|
|
else
|
|
{
|
|
High = Mid;
|
|
}
|
|
}
|
|
OutDistance = (Low + High) * 0.5f;
|
|
return true;
|
|
}
|
|
PreviousDistance = CurrentDistance;
|
|
PreviousValue = CurrentValue;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
OutOutput.Floor.Name = TEXT("floor");
|
|
OutOutput.Wall.Name = TEXT("wall");
|
|
OutOutput.Ceiling.Name = TEXT("ceiling");
|
|
OutOutput.Floor.CoreCrossingsVoxels.Reserve(SampleCount);
|
|
OutOutput.Floor.FinalCrossingsVoxels.Reserve(SampleCount);
|
|
OutOutput.Wall.CoreCrossingsVoxels.Reserve(SampleCount);
|
|
OutOutput.Wall.FinalCrossingsVoxels.Reserve(SampleCount);
|
|
OutOutput.Ceiling.CoreCrossingsVoxels.Reserve(SampleCount);
|
|
OutOutput.Ceiling.FinalCrossingsVoxels.Reserve(SampleCount);
|
|
|
|
FString Csv;
|
|
Csv.Reserve(SampleCount * 180);
|
|
Csv += TEXT("sample,axis_distance_voxels,center_x,center_y,center_z,core_center_sdf,final_center_density,"
|
|
"floor_core_zero,floor_final_zero,wall_core_zero,wall_final_zero,"
|
|
"ceiling_core_zero,ceiling_final_zero\r\n");
|
|
float PreviousCoreCenter = 0.0f;
|
|
float PreviousFinalCenter = 0.0f;
|
|
bool bHavePreviousCoreCenter = false;
|
|
bool bHavePreviousFinalCenter = false;
|
|
float MaxGeneratorCoreDelta = 0.0f;
|
|
int32 GeneratorCoreMismatchCount = 0;
|
|
const uint32 ProbeLayoutVersion = World.Manager->GetLayoutVersion();
|
|
for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex)
|
|
{
|
|
if ((SampleIndex & 127) == 0 && Budget.ShouldStop(TEXT("tunnel-core-probe")))
|
|
{
|
|
OutOutput.Status = TEXT("truncated");
|
|
OutOutput.RefusalReason = TEXT("The wall-clock budget stopped the tunnel-core probe.");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
return false;
|
|
}
|
|
const float AxisDistance = static_cast<float>(SampleIndex) * ProbeStep;
|
|
const FVector Center = PointAtDistance(ProbeStart + AxisDistance);
|
|
const float CoreCenter = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(
|
|
Center.X, Center.Y, Center.Z, Cache, true);
|
|
const float FinalCenter = World.Generator->GetDensityAt(Center.X, Center.Y, Center.Z);
|
|
FTunnelCoreWorldEvaluation GeneratorCoreEvaluation;
|
|
World.Generator->GetDensityWithParams(
|
|
Center.X, Center.Y, Center.Z, Params, BestParamsFingerprint, ProbeLayoutVersion,
|
|
/*bApplyLegacyStructuralPosts=*/false, &GeneratorCoreEvaluation,
|
|
/*bCollectFusedDiagnostics=*/false);
|
|
if (FMath::IsFinite(CoreCenter) && FMath::IsFinite(GeneratorCoreEvaluation.SDF))
|
|
{
|
|
const float CoreDelta = FMath::Abs(CoreCenter - GeneratorCoreEvaluation.SDF);
|
|
MaxGeneratorCoreDelta = FMath::Max(MaxGeneratorCoreDelta, CoreDelta);
|
|
if (CoreDelta > 1.0e-3f)
|
|
{
|
|
++GeneratorCoreMismatchCount;
|
|
}
|
|
}
|
|
if (SampleIndex == 0 || SampleIndex == SampleCount / 2 || SampleIndex + 1 == SampleCount)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] generator_core_compare sample=%d "
|
|
"probe_sdf=%.6g generator_sdf=%.6g generator_mc=%.6g final_mc=%.6g"),
|
|
SampleIndex, CoreCenter, GeneratorCoreEvaluation.SDF,
|
|
-GeneratorCoreEvaluation.SDF, FinalCenter);
|
|
}
|
|
if (FMath::IsFinite(CoreCenter))
|
|
{
|
|
++OutOutput.CoreCenterFiniteSampleCount;
|
|
OutOutput.CoreCenterMin = OutOutput.CoreCenterFiniteSampleCount == 1
|
|
? CoreCenter : FMath::Min(OutOutput.CoreCenterMin, CoreCenter);
|
|
OutOutput.CoreCenterMax = OutOutput.CoreCenterFiniteSampleCount == 1
|
|
? CoreCenter : FMath::Max(OutOutput.CoreCenterMax, CoreCenter);
|
|
if (bHavePreviousCoreCenter
|
|
&& ((PreviousCoreCenter < 0.0f && CoreCenter > 0.0f)
|
|
|| (PreviousCoreCenter > 0.0f && CoreCenter < 0.0f)))
|
|
{
|
|
++OutOutput.CoreCenterZeroCrossingCount;
|
|
}
|
|
PreviousCoreCenter = CoreCenter;
|
|
bHavePreviousCoreCenter = true;
|
|
}
|
|
if (FMath::IsFinite(FinalCenter))
|
|
{
|
|
++OutOutput.FinalCenterFiniteSampleCount;
|
|
OutOutput.FinalCenterMin = OutOutput.FinalCenterFiniteSampleCount == 1
|
|
? FinalCenter : FMath::Min(OutOutput.FinalCenterMin, FinalCenter);
|
|
OutOutput.FinalCenterMax = OutOutput.FinalCenterFiniteSampleCount == 1
|
|
? FinalCenter : FMath::Max(OutOutput.FinalCenterMax, FinalCenter);
|
|
OutOutput.FinalCenterMaxAbs = FMath::Max(
|
|
OutOutput.FinalCenterMaxAbs, FMath::Abs(FinalCenter));
|
|
// The old hand-off wrote a fixed MC-air value (normally +16, with the reciprocal
|
|
// solid plateau at -8 in other definitions). Do not call every ordinary SDF sample
|
|
// "saturated"; count only the known clamp plateaus so the probe distinguishes the
|
|
// old hard switch from the continuous signed field.
|
|
const bool bKnownClampPlateau =
|
|
FMath::Abs(FinalCenter - 8.0f) <= 1.0e-3f
|
|
|| FMath::Abs(FinalCenter + 8.0f) <= 1.0e-3f
|
|
|| FMath::Abs(FinalCenter - 16.0f) <= 1.0e-3f
|
|
|| FMath::Abs(FinalCenter + 16.0f) <= 1.0e-3f;
|
|
if (bKnownClampPlateau)
|
|
{
|
|
++OutOutput.FinalCenterSaturatedSampleCount;
|
|
}
|
|
if (bHavePreviousFinalCenter
|
|
&& ((PreviousFinalCenter < 0.0f && FinalCenter > 0.0f)
|
|
|| (PreviousFinalCenter > 0.0f && FinalCenter < 0.0f)))
|
|
{
|
|
++OutOutput.FinalCenterZeroCrossingCount;
|
|
}
|
|
PreviousFinalCenter = FinalCenter;
|
|
bHavePreviousFinalCenter = true;
|
|
}
|
|
|
|
const FVector RadialDirections[3] = {
|
|
FVector::DownVector, WallDirection, FVector::UpVector };
|
|
FExploreTunnelCoreProbeAxis* Axes[3] = {
|
|
&OutOutput.Floor, &OutOutput.Wall, &OutOutput.Ceiling };
|
|
float Roots[3][2];
|
|
for (int32 AxisIndex = 0; AxisIndex < 3; ++AxisIndex)
|
|
{
|
|
Roots[AxisIndex][0] = std::numeric_limits<float>::quiet_NaN();
|
|
Roots[AxisIndex][1] = std::numeric_limits<float>::quiet_NaN();
|
|
FindZeroCrossing(Center, RadialDirections[AxisIndex], true, Roots[AxisIndex][0]);
|
|
FindZeroCrossing(Center, RadialDirections[AxisIndex], false, Roots[AxisIndex][1]);
|
|
Axes[AxisIndex]->CoreCrossingsVoxels.Add(Roots[AxisIndex][0]);
|
|
Axes[AxisIndex]->FinalCrossingsVoxels.Add(Roots[AxisIndex][1]);
|
|
}
|
|
if (SampleIndex == 50 || SampleIndex == 302)
|
|
{
|
|
const float DiagnosticDistances[] = { 0.0f, 4.0f, 8.0f, 10.0f, 11.0f,
|
|
12.0f, 13.0f, 16.0f, 20.0f, 24.0f, 28.0f, 32.0f };
|
|
for (int32 AxisIndex = 0; AxisIndex < 3; ++AxisIndex)
|
|
{
|
|
FString Values;
|
|
for (const float Distance : DiagnosticDistances)
|
|
{
|
|
const FVector Position = Center + RadialDirections[AxisIndex] * Distance;
|
|
const float CoreValue = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(
|
|
Position.X, Position.Y, Position.Z, Cache, true);
|
|
FTunnelCoreWorldEvaluation GenericCoreEvaluation;
|
|
const float GenericValue = World.Generator->GetDensityWithParams(
|
|
Position.X, Position.Y, Position.Z, Params, BestParamsFingerprint,
|
|
ProbeLayoutVersion, /*bApplyLegacyStructuralPosts=*/false,
|
|
&GenericCoreEvaluation, /*bCollectFusedDiagnostics=*/false);
|
|
const float FinalValue = World.Generator->GetDensityAt(
|
|
Position.X, Position.Y, Position.Z);
|
|
Values += FString::Printf(
|
|
TEXT(" %.0f:(%.3g,%.3g,%.3g)"), Distance, CoreValue,
|
|
GenericValue, FinalValue);
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] radial_diagnostic sample=%d axis=%s "
|
|
"distance:(core_sdf,generic_mc,final_mc)%s"),
|
|
SampleIndex, *Axes[AxisIndex]->Name, *Values);
|
|
}
|
|
}
|
|
Csv += FString::Printf(
|
|
TEXT("%d,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g\r\n"),
|
|
SampleIndex, AxisDistance, Center.X, Center.Y, Center.Z,
|
|
CoreCenter, FinalCenter,
|
|
Roots[0][0], Roots[0][1], Roots[1][0], Roots[1][1], Roots[2][0], Roots[2][1]);
|
|
}
|
|
|
|
FExploreTunnelCoreProbeAxis* Axes[3] = {
|
|
&OutOutput.Floor, &OutOutput.Wall, &OutOutput.Ceiling };
|
|
for (FExploreTunnelCoreProbeAxis* Axis : Axes)
|
|
{
|
|
ComputeTunnelCoreProbeMetrics(
|
|
Axis->CoreCrossingsVoxels, ProbeStep,
|
|
Axis->ValidCoreCrossings, Axis->CoreMeanCrossingVoxels,
|
|
Axis->CoreMinCrossingVoxels, Axis->CoreMaxCrossingVoxels,
|
|
Axis->CorePeriodicAmplitudeVoxels, Axis->CorePeriodicPeriodVoxels,
|
|
Axis->CorePeriodicMeasuredSamples, Axis->CorePeriodicMeasuredLengthVoxels);
|
|
ComputeTunnelCoreProbeMetrics(
|
|
Axis->FinalCrossingsVoxels, ProbeStep,
|
|
Axis->ValidFinalCrossings, Axis->FinalMeanCrossingVoxels,
|
|
Axis->FinalMinCrossingVoxels, Axis->FinalMaxCrossingVoxels,
|
|
Axis->FinalPeriodicAmplitudeVoxels, Axis->FinalPeriodicPeriodVoxels,
|
|
Axis->FinalPeriodicMeasuredSamples, Axis->FinalPeriodicMeasuredLengthVoxels);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] axis=%s samples=%d final_valid=%d "
|
|
"rib_periodic_amplitude=%.6g_voxels=%.6g_cm period=%.6g_voxels "
|
|
"measured=%.6g_voxels/%d core_valid=%d core_rib_periodic_amplitude=%.6g_voxels=%.6g_cm period=%.6g_voxels "
|
|
"measured=%.6g_voxels/%d"),
|
|
*Axis->Name, SampleCount, Axis->ValidFinalCrossings,
|
|
Axis->FinalPeriodicAmplitudeVoxels,
|
|
Axis->FinalPeriodicAmplitudeVoxels * VOXEL_SIZE,
|
|
Axis->FinalPeriodicPeriodVoxels,
|
|
Axis->FinalPeriodicMeasuredLengthVoxels, Axis->FinalPeriodicMeasuredSamples,
|
|
Axis->ValidCoreCrossings, Axis->CorePeriodicAmplitudeVoxels,
|
|
Axis->CorePeriodicAmplitudeVoxels * VOXEL_SIZE,
|
|
Axis->CorePeriodicPeriodVoxels,
|
|
Axis->CorePeriodicMeasuredLengthVoxels, Axis->CorePeriodicMeasuredSamples);
|
|
}
|
|
|
|
// Mouth probe: measure the two surfaces that meet at each room endpoint over a lateral
|
|
// window of approximately +/-2 tunnel radii. The wall ray starts in room air and points
|
|
// toward the tunnel; the floor ray starts below the same room-side patch and points up.
|
|
// Both use the final MC-facing density, so this catches a ring/fillet handoff rather than
|
|
// merely comparing two independent source SDFs. Keep this in the same owner-value process
|
|
// and cache selection as the core probe so the before/after numbers are directly comparable.
|
|
{
|
|
FExploreTunnelMouthProbeOutput& Mouth = OutOutput.Mouth;
|
|
Mouth = FExploreTunnelMouthProbeOutput();
|
|
Mouth.FileName = TEXT("tunnel_mouth_probe.csv");
|
|
const double MouthStartSeconds = FPlatformTime::Seconds();
|
|
FString MouthCsv;
|
|
MouthCsv.Reserve(8192);
|
|
MouthCsv += TEXT(
|
|
"endpoint,lateral_offset_voxels,wall_surface_distance_voxels,"
|
|
"floor_surface_height_voxels\r\n");
|
|
|
|
const TArray<float>& MouthRadii = Radii;
|
|
const TArray<FVector>& MouthPoints = Points;
|
|
const int32 MouthPointCount = MouthPoints.Num();
|
|
const uint32 RoomHashes[2] = {
|
|
BestTunnel->FloorRoomHashA, BestTunnel->FloorRoomHashB };
|
|
const int32 EndpointPointIndices[2] = { 0, MouthPointCount - 1 };
|
|
const int32 InteriorPointIndices[2] = { 1, MouthPointCount - 2 };
|
|
TSet<uint32> CountedRoomHashes;
|
|
|
|
auto CachedRoomRawSDF = [](const FCachedRoom& Room, const FVector& Position) -> float
|
|
{
|
|
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);
|
|
}
|
|
};
|
|
|
|
// The room-volume attribution sample is intentionally coarser than the mesh lattice. It
|
|
// is a count instrument, not a replacement for the matched export, and its resolution is
|
|
// recorded in the report. A core-induced solid must still be zero at this resolution.
|
|
const int32 RoomSampleStep = Mouth.RoomSampleStepVoxels;
|
|
for (int32 EndpointIndex = 0; EndpointIndex < 2; ++EndpointIndex)
|
|
{
|
|
const int32 PointIndex = EndpointPointIndices[EndpointIndex];
|
|
const int32 InteriorIndex = InteriorPointIndices[EndpointIndex];
|
|
if (!MouthPoints.IsValidIndex(PointIndex)
|
|
|| !MouthPoints.IsValidIndex(InteriorIndex)
|
|
|| !MouthRadii.IsValidIndex(PointIndex))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FExploreTunnelMouthProbeEndpoint& Endpoint = Mouth.Endpoints.AddDefaulted_GetRef();
|
|
Endpoint.Name = EndpointIndex == 0 ? TEXT("A") : TEXT("B");
|
|
Endpoint.RoomHash = RoomHashes[EndpointIndex];
|
|
Endpoint.Mouth = MouthPoints[PointIndex];
|
|
Endpoint.RadiusVoxels = FMath::Abs(MouthRadii[PointIndex]);
|
|
Endpoint.TunnelFloorZ = EndpointIndex == 0
|
|
? BestTunnel->WorldMouthFloorZA : BestTunnel->WorldMouthFloorZB;
|
|
if (!FMath::IsFinite(Endpoint.TunnelFloorZ))
|
|
{
|
|
Endpoint.TunnelFloorZ = static_cast<float>(Endpoint.Mouth.Z)
|
|
- Endpoint.RadiusVoxels;
|
|
}
|
|
|
|
FVector RoomToTunnel = MouthPoints[InteriorIndex] - MouthPoints[PointIndex];
|
|
RoomToTunnel.Z = 0.0f;
|
|
if (!RoomToTunnel.Normalize())
|
|
{
|
|
RoomToTunnel = EndpointIndex == 0
|
|
? FVector::ForwardVector : -FVector::ForwardVector;
|
|
}
|
|
const FVector Lateral(-RoomToTunnel.Y, RoomToTunnel.X, 0.0f);
|
|
const float MaxMouthRadius = FMath::Max(MaxRadius, Endpoint.RadiusVoxels);
|
|
const float MouthRayReach = FMath::Max(MaxMouthRadius + 12.0f, 16.0f);
|
|
const float LateralHalfWidth = FMath::Max(
|
|
Endpoint.RadiusVoxels * 2.0f, 8.0f);
|
|
const float LateralStep = 0.25f;
|
|
const int32 LateralSamples = FMath::Max(
|
|
5, FMath::FloorToInt(
|
|
(2.0f * LateralHalfWidth) / LateralStep) + 1);
|
|
const float ActualHalfWidth =
|
|
0.5f * static_cast<float>(LateralSamples - 1) * LateralStep;
|
|
Mouth.LateralSampleCount = FMath::Max(Mouth.LateralSampleCount, LateralSamples);
|
|
Mouth.LateralWindowVoxels = FMath::Max(
|
|
Mouth.LateralWindowVoxels, 2.0f * ActualHalfWidth);
|
|
|
|
Endpoint.Wall.Name = TEXT("wall");
|
|
Endpoint.Floor.Name = TEXT("floor");
|
|
Endpoint.Wall.CrossingsVoxels.Reserve(LateralSamples);
|
|
Endpoint.Floor.CrossingsVoxels.Reserve(LateralSamples);
|
|
|
|
// Keep the origin in finite room air but close enough that the existing radial
|
|
// crossing search still reaches the mouth in one probe window.
|
|
const float WallInteriorDepth = FMath::Min(
|
|
FMath::Max(4.0f * Endpoint.RadiusVoxels, 12.0f),
|
|
0.5f * MouthRayReach);
|
|
const float FloorInteriorDepth = FMath::Min(
|
|
FMath::Max(Endpoint.RadiusVoxels, 4.0f),
|
|
0.25f * MouthRayReach);
|
|
const float FloorOriginZ = static_cast<float>(Endpoint.Mouth.Z)
|
|
- Endpoint.RadiusVoxels - 8.0f;
|
|
|
|
for (int32 SampleIndex = 0; SampleIndex < LateralSamples; ++SampleIndex)
|
|
{
|
|
const float LateralOffset = -ActualHalfWidth
|
|
+ static_cast<float>(SampleIndex) * LateralStep;
|
|
const FVector LateralOffsetVector = Lateral * LateralOffset;
|
|
const FVector WallOrigin = Endpoint.Mouth + LateralOffsetVector
|
|
- RoomToTunnel * WallInteriorDepth;
|
|
const FVector FloorOrigin(
|
|
Endpoint.Mouth.X + LateralOffsetVector.X
|
|
- RoomToTunnel.X * FloorInteriorDepth,
|
|
Endpoint.Mouth.Y + LateralOffsetVector.Y
|
|
- RoomToTunnel.Y * FloorInteriorDepth,
|
|
FloorOriginZ);
|
|
float WallCrossing = std::numeric_limits<float>::quiet_NaN();
|
|
float FloorCrossing = std::numeric_limits<float>::quiet_NaN();
|
|
FindZeroCrossing(
|
|
WallOrigin, RoomToTunnel, false, WallCrossing);
|
|
FindZeroCrossing(
|
|
FloorOrigin, FVector::UpVector, false, FloorCrossing);
|
|
Endpoint.Wall.CrossingsVoxels.Add(WallCrossing);
|
|
Endpoint.Floor.CrossingsVoxels.Add(FloorCrossing);
|
|
MouthCsv += FString::Printf(
|
|
TEXT("%s,%.9g,%.9g,%.9g\r\n"),
|
|
*Endpoint.Name, LateralOffset, WallCrossing, FloorCrossing);
|
|
}
|
|
|
|
ComputeTunnelMouthProbeMetrics(Endpoint.Wall, LateralStep);
|
|
ComputeTunnelMouthProbeMetrics(Endpoint.Floor, LateralStep);
|
|
Mouth.MaxWallPeriodicAmplitudeVoxels = FMath::Max(
|
|
Mouth.MaxWallPeriodicAmplitudeVoxels,
|
|
Endpoint.Wall.PeriodicAmplitudeVoxels);
|
|
Mouth.MaxFloorPeriodicAmplitudeVoxels = FMath::Max(
|
|
Mouth.MaxFloorPeriodicAmplitudeVoxels,
|
|
Endpoint.Floor.PeriodicAmplitudeVoxels);
|
|
Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels = FMath::Max(
|
|
Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels,
|
|
Endpoint.Wall.LocalMedianPeriodicAmplitudeVoxels);
|
|
Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels = FMath::Max(
|
|
Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels,
|
|
Endpoint.Floor.LocalMedianPeriodicAmplitudeVoxels);
|
|
|
|
const int32 CentreIndex = LateralSamples / 2;
|
|
if (Endpoint.Floor.CrossingsVoxels.IsValidIndex(CentreIndex)
|
|
&& FMath::IsFinite(Endpoint.Floor.CrossingsVoxels[CentreIndex]))
|
|
{
|
|
Endpoint.RoomFloorCrossingZ = FloorOriginZ
|
|
+ Endpoint.Floor.CrossingsVoxels[CentreIndex];
|
|
Endpoint.FloorJoinDeltaVoxels = Endpoint.RoomFloorCrossingZ
|
|
- Endpoint.TunnelFloorZ;
|
|
}
|
|
|
|
// Count the room-volume samples once per endpoint room. This catches the exact
|
|
// failure mode from the screenshot: the generic field is air, then the tunnel-core
|
|
// bottom post changes it back to solid inside a room.
|
|
const FCachedRoom* Room = nullptr;
|
|
for (const FCachedRoom& CandidateRoom : Cache.Rooms)
|
|
{
|
|
if (CandidateRoom.Hash == RoomHashes[EndpointIndex])
|
|
{
|
|
Room = &CandidateRoom;
|
|
break;
|
|
}
|
|
}
|
|
if (Room == nullptr || CountedRoomHashes.Contains(Room->Hash))
|
|
{
|
|
continue;
|
|
}
|
|
CountedRoomHashes.Add(Room->Hash);
|
|
|
|
FVector ShapeExtentVector(
|
|
FMath::Abs(Room->ShapeA.X),
|
|
FMath::Abs(Room->ShapeA.Y),
|
|
FMath::Abs(Room->ShapeA.Z));
|
|
if (Room->ShapeType == 2)
|
|
{
|
|
ShapeExtentVector = FVector(
|
|
FMath::Max(
|
|
FMath::Abs(Room->ShapeA.X - Room->Center.X),
|
|
FMath::Abs(Room->ShapeB.X - Room->Center.X)),
|
|
FMath::Max(
|
|
FMath::Abs(Room->ShapeA.Y - Room->Center.Y),
|
|
FMath::Abs(Room->ShapeB.Y - Room->Center.Y)),
|
|
FMath::Max(
|
|
FMath::Abs(Room->ShapeA.Z - Room->Center.Z),
|
|
FMath::Abs(Room->ShapeB.Z - Room->Center.Z)));
|
|
}
|
|
const float ShapeExtent = FMath::Max3(
|
|
ShapeExtentVector.X, ShapeExtentVector.Y, ShapeExtentVector.Z)
|
|
+ FMath::Abs(Room->ShapeR) + 2.0f;
|
|
const int32 MinX = FMath::FloorToInt(Room->Center.X - ShapeExtent);
|
|
const int32 MinY = FMath::FloorToInt(Room->Center.Y - ShapeExtent);
|
|
const int32 MinZ = FMath::FloorToInt(Room->Center.Z - ShapeExtent);
|
|
const int32 MaxX = FMath::CeilToInt(Room->Center.X + ShapeExtent);
|
|
const int32 MaxY = FMath::CeilToInt(Room->Center.Y + ShapeExtent);
|
|
const int32 MaxZ = FMath::CeilToInt(Room->Center.Z + ShapeExtent);
|
|
for (int32 Z = MinZ; Z <= MaxZ; Z += RoomSampleStep)
|
|
{
|
|
for (int32 Y = MinY; Y <= MaxY; Y += RoomSampleStep)
|
|
{
|
|
for (int32 X = MinX; X <= MaxX; X += RoomSampleStep)
|
|
{
|
|
const FVector Position(
|
|
static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
|
|
if (!(CachedRoomRawSDF(*Room, Position) < 0.0f))
|
|
{
|
|
continue;
|
|
}
|
|
++Mouth.RoomVolumeSampleCount;
|
|
|
|
FTunnelCoreWorldEvaluation CoreEvaluation;
|
|
const float GenericDensity = World.Generator->GetDensityWithParams(
|
|
Position.X, Position.Y, Position.Z,
|
|
Params, BestParamsFingerprint, ProbeLayoutVersion,
|
|
/*bApplyLegacyStructuralPosts=*/false,
|
|
&CoreEvaluation,
|
|
/*bCollectFusedDiagnostics=*/false);
|
|
const float FinalDensity = World.Generator->GetDensityAt(
|
|
Position.X, Position.Y, Position.Z);
|
|
if (!FMath::IsFinite(GenericDensity)
|
|
|| !FMath::IsFinite(FinalDensity))
|
|
{
|
|
continue;
|
|
}
|
|
if (GenericDensity > 0.0f)
|
|
{
|
|
++Mouth.RoomAirSampleCount;
|
|
}
|
|
if (FinalDensity <= 0.0f)
|
|
{
|
|
++Mouth.FinalSolidSampleCount;
|
|
}
|
|
const bool bOwnsSweptBottom =
|
|
CoreEvaluation.bHasSweptFloor
|
|
&& FMath::IsFinite(CoreEvaluation.SweptFloorZ)
|
|
&& FMath::IsFinite(CoreEvaluation.SweptFloorRadius)
|
|
&& Position.Z <= CoreEvaluation.SweptFloorZ
|
|
+ VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels;
|
|
if (bOwnsSweptBottom)
|
|
{
|
|
++Mouth.CoreBottomSampleCount;
|
|
if (GenericDensity > 0.0f && FinalDensity <= 0.0f)
|
|
{
|
|
++Mouth.CoreInducedSolidSampleCount;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Mouth.EndpointCount = Mouth.Endpoints.Num();
|
|
const FString MouthPath = FPaths::Combine(Arguments.OutDirectory, Mouth.FileName);
|
|
if (!FFileHelper::SaveStringToFile(
|
|
MouthCsv, *MouthPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
Mouth.Status = TEXT("error");
|
|
Mouth.RefusalReason = TEXT("Could not write tunnel_mouth_probe.csv.");
|
|
}
|
|
else if (Mouth.EndpointCount < 2)
|
|
{
|
|
Mouth.Status = TEXT("error");
|
|
Mouth.RefusalReason = TEXT("The selected tunnel did not expose two room endpoints.");
|
|
}
|
|
else
|
|
{
|
|
Mouth.Status = TEXT("ok");
|
|
}
|
|
Mouth.Seconds = FPlatformTime::Seconds() - MouthStartSeconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelMouthProbe] endpoints=%d room_samples=%lld room_air=%lld "
|
|
"core_bottom=%lld final_solid=%lld core_induced_solid=%lld "
|
|
"wall_periodic=%.6g_voxels=%.6g_cm floor_periodic=%.6g_voxels=%.6g_cm "
|
|
"legacy_floor_periodic=%.6g_voxels=%.6g_cm "
|
|
"lateral_window=%.3f step=%.3f file=%s"),
|
|
Mouth.EndpointCount,
|
|
Mouth.RoomVolumeSampleCount, Mouth.RoomAirSampleCount,
|
|
Mouth.CoreBottomSampleCount, Mouth.FinalSolidSampleCount,
|
|
Mouth.CoreInducedSolidSampleCount,
|
|
Mouth.MaxWallPeriodicAmplitudeVoxels,
|
|
Mouth.MaxWallPeriodicAmplitudeVoxels * VOXEL_SIZE,
|
|
Mouth.MaxFloorPeriodicAmplitudeVoxels,
|
|
Mouth.MaxFloorPeriodicAmplitudeVoxels * VOXEL_SIZE,
|
|
Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels,
|
|
Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels * VOXEL_SIZE,
|
|
Mouth.LateralWindowVoxels, Mouth.LateralStepVoxels, *MouthPath);
|
|
}
|
|
|
|
const FString Path = FPaths::Combine(Arguments.OutDirectory, OutOutput.FileName);
|
|
if (!FFileHelper::SaveStringToFile(
|
|
Csv, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not write tunnel_core_probe.csv.");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
return false;
|
|
}
|
|
OutOutput.Status = TEXT("ok");
|
|
OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] tunnel=%d samples=%d step=%.3f length=%.3f "
|
|
"final_center_finite=%d known_clamp_plateau=%d range=[%.6g,%.6g] "
|
|
"core_center_finite=%d range=[%.6g,%.6g] file=%s"),
|
|
OutOutput.TunnelIndex, OutOutput.SampleCount, OutOutput.StepVoxels,
|
|
OutOutput.LengthVoxels, OutOutput.FinalCenterFiniteSampleCount,
|
|
OutOutput.FinalCenterSaturatedSampleCount, OutOutput.FinalCenterMin,
|
|
OutOutput.FinalCenterMax, OutOutput.CoreCenterFiniteSampleCount,
|
|
OutOutput.CoreCenterMin, OutOutput.CoreCenterMax, *Path);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeTunnelCoreProbe] generator_core_compare_max_abs_delta=%.6g "
|
|
"mismatched_samples=%d tolerance=0.001"),
|
|
MaxGeneratorCoreDelta, GeneratorCoreMismatchCount);
|
|
return true;
|
|
}
|
|
|
|
void WriteTunnelCoreProbeAxis(
|
|
FExploreJsonWriter& Writer,
|
|
const FExploreTunnelCoreProbeAxis& Axis,
|
|
const TCHAR* Key)
|
|
{
|
|
Writer.WriteObjectStart(Key);
|
|
Writer.WriteValue(TEXT("valid_core_crossings"), Axis.ValidCoreCrossings);
|
|
Writer.WriteValue(TEXT("valid_final_crossings"), Axis.ValidFinalCrossings);
|
|
Writer.WriteValue(TEXT("core_mean_crossing_voxels"), static_cast<double>(Axis.CoreMeanCrossingVoxels));
|
|
Writer.WriteValue(TEXT("final_mean_crossing_voxels"), static_cast<double>(Axis.FinalMeanCrossingVoxels));
|
|
Writer.WriteValue(TEXT("core_min_crossing_voxels"), static_cast<double>(Axis.CoreMinCrossingVoxels));
|
|
Writer.WriteValue(TEXT("core_max_crossing_voxels"), static_cast<double>(Axis.CoreMaxCrossingVoxels));
|
|
Writer.WriteValue(TEXT("final_min_crossing_voxels"), static_cast<double>(Axis.FinalMinCrossingVoxels));
|
|
Writer.WriteValue(TEXT("final_max_crossing_voxels"), static_cast<double>(Axis.FinalMaxCrossingVoxels));
|
|
Writer.WriteValue(TEXT("core_periodic_amplitude_voxels"), static_cast<double>(Axis.CorePeriodicAmplitudeVoxels));
|
|
Writer.WriteValue(TEXT("core_periodic_amplitude_cm"), static_cast<double>(Axis.CorePeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer.WriteValue(TEXT("core_periodic_period_voxels"), static_cast<double>(Axis.CorePeriodicPeriodVoxels));
|
|
Writer.WriteValue(TEXT("core_periodic_measured_samples"), Axis.CorePeriodicMeasuredSamples);
|
|
Writer.WriteValue(TEXT("core_periodic_measured_length_voxels"), static_cast<double>(Axis.CorePeriodicMeasuredLengthVoxels));
|
|
Writer.WriteValue(TEXT("final_periodic_amplitude_voxels"), static_cast<double>(Axis.FinalPeriodicAmplitudeVoxels));
|
|
Writer.WriteValue(TEXT("final_periodic_amplitude_cm"), static_cast<double>(Axis.FinalPeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer.WriteValue(TEXT("final_periodic_period_voxels"), static_cast<double>(Axis.FinalPeriodicPeriodVoxels));
|
|
Writer.WriteValue(TEXT("final_periodic_measured_samples"), Axis.FinalPeriodicMeasuredSamples);
|
|
Writer.WriteValue(TEXT("final_periodic_measured_length_voxels"), static_cast<double>(Axis.FinalPeriodicMeasuredLengthVoxels));
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
void WriteTunnelMouthProbeAxis(
|
|
FExploreJsonWriter& Writer,
|
|
const FExploreTunnelMouthProbeAxis& Axis,
|
|
const TCHAR* Key)
|
|
{
|
|
Writer.WriteObjectStart(Key);
|
|
Writer.WriteValue(TEXT("valid_crossings"), Axis.ValidCrossings);
|
|
Writer.WriteValue(TEXT("mean_crossing_voxels"), static_cast<double>(Axis.MeanCrossingVoxels));
|
|
Writer.WriteValue(TEXT("min_crossing_voxels"), static_cast<double>(Axis.MinCrossingVoxels));
|
|
Writer.WriteValue(TEXT("max_crossing_voxels"), static_cast<double>(Axis.MaxCrossingVoxels));
|
|
Writer.WriteValue(TEXT("periodic_amplitude_voxels"), static_cast<double>(Axis.PeriodicAmplitudeVoxels));
|
|
Writer.WriteValue(TEXT("periodic_amplitude_cm"), static_cast<double>(Axis.PeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer.WriteValue(TEXT("periodic_period_voxels"), static_cast<double>(Axis.PeriodicPeriodVoxels));
|
|
Writer.WriteValue(TEXT("periodic_measured_samples"), Axis.PeriodicMeasuredSamples);
|
|
Writer.WriteValue(TEXT("periodic_measured_length_voxels"), static_cast<double>(Axis.PeriodicMeasuredLengthVoxels));
|
|
Writer.WriteValue(TEXT("local_median_periodic_amplitude_voxels"), static_cast<double>(Axis.LocalMedianPeriodicAmplitudeVoxels));
|
|
Writer.WriteValue(TEXT("local_median_periodic_amplitude_cm"), static_cast<double>(Axis.LocalMedianPeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer.WriteValue(TEXT("local_median_periodic_period_voxels"), static_cast<double>(Axis.LocalMedianPeriodicPeriodVoxels));
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key)
|
|
{
|
|
Writer.WriteObjectStart(Key);
|
|
Writer.WriteValue(TEXT("capsule_radius_m"), static_cast<double>(FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0);
|
|
Writer.WriteValue(TEXT("capsule_width_m"), static_cast<double>(2.0f * FVoxelPlayerCapsuleConstants::RadiusCentimeters) / 100.0);
|
|
Writer.WriteValue(TEXT("capsule_half_height_m"), static_cast<double>(FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0);
|
|
Writer.WriteValue(TEXT("capsule_height_m"), static_cast<double>(2.0f * FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0);
|
|
Writer.WriteValue(TEXT("max_step_height_m"), static_cast<double>(FVoxelPlayerCapsuleConstants::MaxStepHeightMeters));
|
|
Writer.WriteValue(TEXT("climbing_enabled"), true);
|
|
Writer.WriteValue(TEXT("max_climb_height_m"), 3.0);
|
|
Writer.WriteValue(TEXT("walkable_floor_angle_degrees"), static_cast<double>(FVoxelPlayerCapsuleConstants::WalkableFloorAngleDegrees));
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
|
|
FString BuildExploreJson(
|
|
const FExploreArguments& Arguments,
|
|
const FExploreWorld& World,
|
|
const FExploreRunOutput& Output,
|
|
bool bDeterminismAssertion,
|
|
uint32 PayloadCrc32)
|
|
{
|
|
FString Json;
|
|
TSharedRef<FExploreJsonWriter> Writer =
|
|
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Json);
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("schema_version"), 7);
|
|
Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore"));
|
|
Writer->WriteValue(TEXT("read_only_generation"), true);
|
|
|
|
Writer->WriteObjectStart(TEXT("input"));
|
|
Writer->WriteValue(TEXT("seed"), Arguments.Seed);
|
|
Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype));
|
|
Writer->WriteValue(TEXT("slot"), Arguments.Slot);
|
|
Writer->WriteValue(TEXT("worms_enabled_override"), Arguments.bWormsEnabledOverride);
|
|
if (Arguments.bWormsEnabledOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("worms_enabled"), Arguments.bWormsEnabled);
|
|
}
|
|
Writer->WriteValue(TEXT("worm_strength_override"), Arguments.bWormStrengthOverride);
|
|
if (Arguments.bWormStrengthOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("worm_strength"), static_cast<double>(Arguments.WormStrength));
|
|
}
|
|
Writer->WriteValue(TEXT("tunnel_ablation_mask"),
|
|
static_cast<int64>(VoxelDensityAblation::GetResolvedMask()));
|
|
WriteTunnelFloorOverrides(*Writer, Arguments);
|
|
Writer->WriteValue(TEXT("room_mouth_rise_override"), Arguments.bRoomMouthRiseOverride);
|
|
if (Arguments.bRoomMouthRiseOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("room_mouth_rise_strength"),
|
|
static_cast<double>(Arguments.RoomMouthRise));
|
|
}
|
|
Writer->WriteValue(TEXT("room_mouth_rise_blend_override"),
|
|
Arguments.bRoomMouthRiseBlendOverride);
|
|
if (Arguments.bRoomMouthRiseBlendOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("room_mouth_rise_blend_voxels"),
|
|
static_cast<double>(Arguments.RoomMouthRiseBlend));
|
|
}
|
|
Writer->WriteValue(TEXT("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride);
|
|
if (Arguments.bSurfaceRoughnessOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("surface_roughness"),
|
|
static_cast<double>(Arguments.SurfaceRoughness));
|
|
}
|
|
Writer->WriteValue(TEXT("roughness_noise_type_override"),
|
|
Arguments.bRoughnessNoiseTypeOverride);
|
|
if (Arguments.bRoughnessNoiseTypeOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("roughness_noise_type"),
|
|
NoiseTypeName(Arguments.RoughnessNoiseType));
|
|
}
|
|
Writer->WriteValue(TEXT("roughness_frequency_override"),
|
|
Arguments.bRoughnessFrequencyOverride);
|
|
if (Arguments.bRoughnessFrequencyOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("roughness_frequency"),
|
|
static_cast<double>(Arguments.RoughnessFrequency));
|
|
}
|
|
Writer->WriteArrayStart(TEXT("modes"));
|
|
if (Arguments.bRender) Writer->WriteValue(TEXT("render"));
|
|
if (Arguments.bWalk) Writer->WriteValue(TEXT("walk"));
|
|
if (Arguments.bExport) Writer->WriteValue(TEXT("export"));
|
|
if (Arguments.bOpBounds) Writer->WriteValue(TEXT("opbounds"));
|
|
if (Arguments.bTunnelCoreProbe) Writer->WriteValue(TEXT("tunnelcoreprobe"));
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteValue(TEXT("failure_focus_render"), Arguments.bFailureFocusRender);
|
|
Writer->WriteValue(TEXT("render_step_voxels"), static_cast<double>(Arguments.RenderStepVoxels));
|
|
Writer->WriteValue(TEXT("export_size"), Arguments.ExportSize);
|
|
Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep);
|
|
Writer->WriteValue(TEXT("density_grid_reuse"), Arguments.bReuseDensityGrid);
|
|
Writer->WriteValue(TEXT("block_early_out"), Arguments.bBlockEarlyOut);
|
|
Writer->WriteValue(TEXT("game_runtime_config"), Arguments.bGameRuntimeConfig);
|
|
Writer->WriteValue(TEXT("mesh_min_batch_size"), Arguments.MeshMinBatchSize);
|
|
Writer->WriteValue(TEXT("profile_density"), Arguments.bProfileDensity);
|
|
Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull);
|
|
Writer->WriteValue(TEXT("perf_attribution"), Arguments.bPerfAttribution);
|
|
Writer->WriteValue(TEXT("sample_stacks"), Arguments.bSampleStacks);
|
|
Writer->WriteValue(TEXT("sample_stacks_interval_us"),
|
|
static_cast<double>(Arguments.SampleStacksIntervalUs));
|
|
Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod);
|
|
Writer->WriteValue(TEXT("density_slice_requested"), Arguments.bDensitySlice);
|
|
Writer->WriteValue(TEXT("tunnel_core_probe_requested"), Arguments.bTunnelCoreProbe);
|
|
Writer->WriteValue(TEXT("render_target_override"), Arguments.bRenderTargetOverride);
|
|
if (Arguments.bRenderTargetOverride)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("render_target_voxels"), Arguments.RenderTarget, 1.0f);
|
|
}
|
|
Writer->WriteValue(TEXT("op_bounds"), Arguments.bOpBounds);
|
|
Writer->WriteValue(TEXT("budget_minutes"), static_cast<double>(Arguments.BudgetMinutes));
|
|
Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory);
|
|
Writer->WriteValue(TEXT("canonical_invocation"), FString::Printf(
|
|
TEXT("UnrealEditor-Cmd VoxelM.uproject -run=VoxelForgeExplore -seed=%d -archetype=%s "
|
|
"-slot=%d -modes=%s -blockearlyout=%d "
|
|
"-out=<ABSOLUTE_PLUGIN_SAVED_PATH>"),
|
|
Arguments.Seed,
|
|
ArchetypeName(Arguments.Archetype),
|
|
Arguments.Slot,
|
|
*Arguments.CanonicalModes(),
|
|
Arguments.bBlockEarlyOut ? 1 : 0));
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("density_slice"));
|
|
Writer->WriteValue(TEXT("status"), Output.DensitySlice.Status);
|
|
Writer->WriteValue(TEXT("file"), Output.DensitySlice.FileName);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.DensitySlice.RefusalReason);
|
|
Writer->WriteValue(TEXT("sample_count"), Output.DensitySlice.SampleCount);
|
|
Writer->WriteValue(TEXT("finite_sample_count"), Output.DensitySlice.FiniteSampleCount);
|
|
Writer->WriteValue(TEXT("non_finite_sample_count"), Output.DensitySlice.NonFiniteSampleCount);
|
|
Writer->WriteValue(TEXT("zero_crossing_count"), Output.DensitySlice.ZeroCrossingCount);
|
|
Writer->WriteValue(TEXT("saturated_sample_count"), Output.DensitySlice.SaturatedSampleCount);
|
|
Writer->WriteValue(TEXT("min_density"), static_cast<double>(Output.DensitySlice.MinDensity));
|
|
Writer->WriteValue(TEXT("max_density"), static_cast<double>(Output.DensitySlice.MaxDensity));
|
|
Writer->WriteValue(TEXT("max_abs_density"), static_cast<double>(Output.DensitySlice.MaxAbsDensity));
|
|
Writer->WriteValue(TEXT("seconds"), Output.DensitySlice.Seconds);
|
|
if (Arguments.bDensitySlice)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("origin_voxels"), Arguments.DensitySliceOrigin, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("direction"), Arguments.DensitySliceDirection, 1.0f);
|
|
Writer->WriteValue(TEXT("step_voxels"), static_cast<double>(Arguments.DensitySliceStep));
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("tunnel_core_probe"));
|
|
Writer->WriteValue(TEXT("status"), Output.TunnelCoreProbe.Status);
|
|
Writer->WriteValue(TEXT("file"), Output.TunnelCoreProbe.FileName);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.TunnelCoreProbe.RefusalReason);
|
|
Writer->WriteValue(TEXT("tunnel_index"), Output.TunnelCoreProbe.TunnelIndex);
|
|
Writer->WriteValue(TEXT("sample_count"), Output.TunnelCoreProbe.SampleCount);
|
|
Writer->WriteValue(TEXT("step_voxels"), static_cast<double>(Output.TunnelCoreProbe.StepVoxels));
|
|
Writer->WriteValue(TEXT("length_voxels"), static_cast<double>(Output.TunnelCoreProbe.LengthVoxels));
|
|
WriteJsonVector(*Writer, TEXT("axis_start_voxels"), Output.TunnelCoreProbe.AxisStart, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("axis_end_voxels"), Output.TunnelCoreProbe.AxisEnd, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("axis_direction"), Output.TunnelCoreProbe.AxisDirection, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("wall_direction"), Output.TunnelCoreProbe.WallDirection, 1.0f);
|
|
Writer->WriteValue(TEXT("core_center_finite_sample_count"), Output.TunnelCoreProbe.CoreCenterFiniteSampleCount);
|
|
Writer->WriteValue(TEXT("core_center_zero_crossing_count"), Output.TunnelCoreProbe.CoreCenterZeroCrossingCount);
|
|
Writer->WriteValue(TEXT("final_center_finite_sample_count"), Output.TunnelCoreProbe.FinalCenterFiniteSampleCount);
|
|
Writer->WriteValue(TEXT("final_center_zero_crossing_count"), Output.TunnelCoreProbe.FinalCenterZeroCrossingCount);
|
|
Writer->WriteValue(TEXT("final_center_saturated_sample_count"), Output.TunnelCoreProbe.FinalCenterSaturatedSampleCount);
|
|
Writer->WriteValue(TEXT("core_center_min"), static_cast<double>(Output.TunnelCoreProbe.CoreCenterMin));
|
|
Writer->WriteValue(TEXT("core_center_max"), static_cast<double>(Output.TunnelCoreProbe.CoreCenterMax));
|
|
Writer->WriteValue(TEXT("final_center_min"), static_cast<double>(Output.TunnelCoreProbe.FinalCenterMin));
|
|
Writer->WriteValue(TEXT("final_center_max"), static_cast<double>(Output.TunnelCoreProbe.FinalCenterMax));
|
|
Writer->WriteValue(TEXT("final_center_max_abs"), static_cast<double>(Output.TunnelCoreProbe.FinalCenterMaxAbs));
|
|
Writer->WriteValue(TEXT("periodic_method"), TEXT("Tunnel-core axes: 32-voxel local-median detrend. Mouth axes: degree-six slow-trend detrend over the longest contiguous >=40-voxel run, with the legacy local-median value retained per axis; both use a single-sided DFT peak in the <=16-voxel band and report sinusoid amplitudes."));
|
|
WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Floor, TEXT("floor"));
|
|
WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Wall, TEXT("wall"));
|
|
WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Ceiling, TEXT("ceiling"));
|
|
Writer->WriteObjectStart(TEXT("mouth_probe"));
|
|
Writer->WriteValue(TEXT("status"), Output.TunnelCoreProbe.Mouth.Status);
|
|
Writer->WriteValue(TEXT("file"), Output.TunnelCoreProbe.Mouth.FileName);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.TunnelCoreProbe.Mouth.RefusalReason);
|
|
Writer->WriteValue(TEXT("endpoint_count"), Output.TunnelCoreProbe.Mouth.EndpointCount);
|
|
Writer->WriteValue(TEXT("lateral_sample_count"), Output.TunnelCoreProbe.Mouth.LateralSampleCount);
|
|
Writer->WriteValue(TEXT("lateral_step_voxels"), static_cast<double>(Output.TunnelCoreProbe.Mouth.LateralStepVoxels));
|
|
Writer->WriteValue(TEXT("lateral_window_voxels"), static_cast<double>(Output.TunnelCoreProbe.Mouth.LateralWindowVoxels));
|
|
Writer->WriteValue(TEXT("room_sample_step_voxels"), Output.TunnelCoreProbe.Mouth.RoomSampleStepVoxels);
|
|
Writer->WriteValue(TEXT("room_volume_sample_count"), Output.TunnelCoreProbe.Mouth.RoomVolumeSampleCount);
|
|
Writer->WriteValue(TEXT("room_air_sample_count"), Output.TunnelCoreProbe.Mouth.RoomAirSampleCount);
|
|
Writer->WriteValue(TEXT("core_bottom_sample_count"), Output.TunnelCoreProbe.Mouth.CoreBottomSampleCount);
|
|
Writer->WriteValue(TEXT("final_solid_sample_count"), Output.TunnelCoreProbe.Mouth.FinalSolidSampleCount);
|
|
Writer->WriteValue(TEXT("core_induced_solid_sample_count"), Output.TunnelCoreProbe.Mouth.CoreInducedSolidSampleCount);
|
|
Writer->WriteValue(TEXT("max_wall_periodic_amplitude_voxels"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxWallPeriodicAmplitudeVoxels));
|
|
Writer->WriteValue(TEXT("max_wall_periodic_amplitude_cm"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxWallPeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer->WriteValue(TEXT("max_floor_periodic_amplitude_voxels"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels));
|
|
Writer->WriteValue(TEXT("max_floor_periodic_amplitude_cm"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer->WriteValue(TEXT("max_local_median_wall_periodic_amplitude_voxels"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels));
|
|
Writer->WriteValue(TEXT("max_local_median_wall_periodic_amplitude_cm"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxLocalMedianWallPeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer->WriteValue(TEXT("max_local_median_floor_periodic_amplitude_voxels"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels));
|
|
Writer->WriteValue(TEXT("max_local_median_floor_periodic_amplitude_cm"), static_cast<double>(Output.TunnelCoreProbe.Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels) * VOXEL_SIZE);
|
|
Writer->WriteValue(TEXT("mouth_periodic_detrend"), TEXT("degree-six smooth trend over the complete longest contiguous run; legacy local-median retained for audit"));
|
|
Writer->WriteValue(TEXT("periodic_acceptance_cm"), 2.0);
|
|
Writer->WriteValue(TEXT("periodic_acceptance_pass"),
|
|
Output.TunnelCoreProbe.Mouth.MaxWallPeriodicAmplitudeVoxels * VOXEL_SIZE <= 2.0f
|
|
&& Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels * VOXEL_SIZE <= 2.0f);
|
|
Writer->WriteArrayStart(TEXT("endpoints"));
|
|
for (const FExploreTunnelMouthProbeEndpoint& Endpoint : Output.TunnelCoreProbe.Mouth.Endpoints)
|
|
{
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("name"), Endpoint.Name);
|
|
Writer->WriteValue(TEXT("room_hash"), static_cast<int64>(Endpoint.RoomHash));
|
|
WriteJsonVector(*Writer, TEXT("mouth_voxels"), Endpoint.Mouth, 1.0f);
|
|
Writer->WriteValue(TEXT("radius_voxels"), static_cast<double>(Endpoint.RadiusVoxels));
|
|
Writer->WriteValue(TEXT("tunnel_floor_z"), static_cast<double>(Endpoint.TunnelFloorZ));
|
|
Writer->WriteValue(TEXT("room_floor_crossing_z"), static_cast<double>(Endpoint.RoomFloorCrossingZ));
|
|
Writer->WriteValue(TEXT("floor_join_delta_voxels"), static_cast<double>(Endpoint.FloorJoinDeltaVoxels));
|
|
WriteTunnelMouthProbeAxis(*Writer, Endpoint.Wall, TEXT("wall"));
|
|
WriteTunnelMouthProbeAxis(*Writer, Endpoint.Floor, TEXT("floor"));
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteValue(TEXT("seconds"), Output.TunnelCoreProbe.Mouth.Seconds);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteValue(TEXT("seconds"), Output.TunnelCoreProbe.Seconds);
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("summary"));
|
|
const double SummaryMeshSeconds = Arguments.bExport
|
|
? Output.Export.MeshSeconds
|
|
: (Arguments.bRender
|
|
? Output.Render.MeshSeconds
|
|
: World.ExploreMeshSeconds);
|
|
const int32 SummaryTriangleCount = Arguments.bExport
|
|
? Output.Export.TriangleCount
|
|
: World.ExploreMesh.Triangles.Num() / 3;
|
|
const FString SummaryGeometryHash = Arguments.bExport
|
|
? Output.Export.GeometryHash
|
|
: World.ExploreGeometryHash;
|
|
const double SummaryUsPerVoxel = Arguments.ExportSize > 0
|
|
? SummaryMeshSeconds * 1.0e6
|
|
/ static_cast<double>(Arguments.ExportSize)
|
|
/ static_cast<double>(Arguments.ExportSize)
|
|
/ static_cast<double>(Arguments.ExportSize)
|
|
: 0.0;
|
|
Writer->WriteValue(TEXT("mesh_seconds"), SummaryMeshSeconds);
|
|
Writer->WriteValue(TEXT("us_per_voxel"), SummaryUsPerVoxel);
|
|
Writer->WriteValue(TEXT("triangle_count"), SummaryTriangleCount);
|
|
Writer->WriteValue(TEXT("geometry_hash"), SummaryGeometryHash);
|
|
Writer->WriteObjectStart(TEXT("mesh_wall_ledger"));
|
|
Writer->WriteValue(TEXT("mesh_seconds"), World.ExploreMeshSeconds);
|
|
Writer->WriteValue(TEXT("preparation_seconds"), World.ExploreMeshPreparationSeconds);
|
|
Writer->WriteValue(TEXT("density_grid_seconds"), World.ExploreDensityGridSeconds);
|
|
Writer->WriteValue(TEXT("pre_tile_setup_seconds"), World.ExploreMeshPreTileSetupSeconds);
|
|
Writer->WriteValue(TEXT("tile_parallel_wall_seconds"), World.ExploreMeshTaskWallSeconds);
|
|
Writer->WriteValue(TEXT("tile_merge_seconds"), World.ExploreMeshMergeSeconds);
|
|
Writer->WriteValue(TEXT("accounting_remainder_seconds"),
|
|
World.ExploreMeshAccountingRemainderSeconds);
|
|
Writer->WriteValue(TEXT("stage_sum_seconds"),
|
|
World.ExploreMeshPreparationSeconds
|
|
+ World.ExploreDensityGridSeconds
|
|
+ World.ExploreMeshPreTileSetupSeconds
|
|
+ World.ExploreMeshTaskWallSeconds
|
|
+ World.ExploreMeshMergeSeconds);
|
|
Writer->WriteValue(TEXT("sum_matches_mesh"),
|
|
FMath::Abs(World.ExploreMeshAccountingRemainderSeconds) <= 0.000001);
|
|
Writer->WriteValue(TEXT("geometry_hash_seconds"), World.ExploreMeshHashSeconds);
|
|
Writer->WriteValue(TEXT("note"), TEXT(
|
|
"The stage rows are one additive wall-clock ledger. Profiler buckets below are nested worker diagnostics and are not added to this ledger."));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("density_grid_reuse"));
|
|
Writer->WriteValue(TEXT("enabled"), World.bExploreDensityGridReused);
|
|
Writer->WriteValue(TEXT("total_tile_grid_samples"), World.ExploreDensityGridTotalSamples);
|
|
Writer->WriteValue(TEXT("unique_shared_grid_samples"), World.ExploreDensityGridUniqueSamples);
|
|
Writer->WriteValue(TEXT("duplicate_evaluations_before"), World.ExploreDensityGridDuplicateSamplesBefore);
|
|
Writer->WriteValue(TEXT("duplicate_evaluations_after"), World.ExploreDensityGridDuplicateSamplesAfter);
|
|
Writer->WriteValue(TEXT("precompute_seconds"), World.ExploreDensityGridSeconds);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("mesher_tasks"));
|
|
Writer->WriteValue(TEXT("min_batch_size"), World.ExploreMeshTaskMinBatchSize);
|
|
Writer->WriteValue(TEXT("worker_count"), World.ExploreMeshTaskWorkerCount);
|
|
Writer->WriteValue(TEXT("job_count"), World.ExploreMeshTaskJobCount);
|
|
Writer->WriteValue(TEXT("job_min_seconds"), World.ExploreMeshTaskMinSeconds);
|
|
Writer->WriteValue(TEXT("job_mean_seconds"), World.ExploreMeshTaskMeanSeconds);
|
|
Writer->WriteValue(TEXT("job_max_seconds"), World.ExploreMeshTaskMaxSeconds);
|
|
Writer->WriteValue(TEXT("job_sum_seconds"), World.ExploreMeshTaskSumSeconds);
|
|
Writer->WriteValue(TEXT("parallel_wall_seconds"), World.ExploreMeshTaskWallSeconds);
|
|
Writer->WriteValue(TEXT("estimated_launch_overhead_seconds"),
|
|
World.ExploreMeshTaskLaunchOverheadSeconds);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("tile_ledger"));
|
|
Writer->WriteValue(TEXT("order"), TEXT("z_then_y_then_x"));
|
|
Writer->WriteValue(TEXT("tile_cells"), CHUNK_SIZE);
|
|
int32 TileLod = 0;
|
|
for (int32 SampleStep = Arguments.ExportStep; SampleStep > 1; SampleStep >>= 1)
|
|
{
|
|
++TileLod;
|
|
}
|
|
Writer->WriteValue(TEXT("sample_step"), Arguments.ExportStep);
|
|
Writer->WriteValue(TEXT("lod"), TileLod);
|
|
Writer->WriteValue(TEXT("tile_count"), World.ExploreMeshTileCount);
|
|
Writer->WriteValue(TEXT("completed_tiles"), World.ExploreMeshTilesCompleted);
|
|
Writer->WriteArrayStart(TEXT("tiles"));
|
|
for (int32 TileIndex = 0; TileIndex < World.ExploreMeshTilesCompleted; ++TileIndex)
|
|
{
|
|
const int32 TilesPerAxis = Arguments.ExportSize / CHUNK_SIZE;
|
|
const int32 TileX = TileIndex % TilesPerAxis;
|
|
const int32 TileY = (TileIndex / TilesPerAxis) % TilesPerAxis;
|
|
const int32 TileZ = TileIndex / (TilesPerAxis * TilesPerAxis);
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("index"), TileIndex);
|
|
Writer->WriteValue(TEXT("x"), TileX);
|
|
Writer->WriteValue(TEXT("y"), TileY);
|
|
Writer->WriteValue(TEXT("z"), TileZ);
|
|
Writer->WriteValue(TEXT("absolute_tile_x"),
|
|
World.ExploreMeshOrigin.X / CHUNK_SIZE + TileX);
|
|
Writer->WriteValue(TEXT("absolute_tile_y"),
|
|
World.ExploreMeshOrigin.Y / CHUNK_SIZE + TileY);
|
|
Writer->WriteValue(TEXT("absolute_tile_z"),
|
|
World.ExploreMeshOrigin.Z / CHUNK_SIZE + TileZ);
|
|
Writer->WriteValue(TEXT("origin_x_voxels"),
|
|
World.ExploreMeshOrigin.X + TileX * CHUNK_SIZE);
|
|
Writer->WriteValue(TEXT("origin_y_voxels"),
|
|
World.ExploreMeshOrigin.Y + TileY * CHUNK_SIZE);
|
|
Writer->WriteValue(TEXT("origin_z_voxels"),
|
|
World.ExploreMeshOrigin.Z + TileZ * CHUNK_SIZE);
|
|
Writer->WriteValue(TEXT("sample_step"), Arguments.ExportStep);
|
|
Writer->WriteValue(TEXT("lod"), TileLod);
|
|
Writer->WriteValue(TEXT("triangle_count"), World.ExploreTileTriangleCounts[TileIndex]);
|
|
Writer->WriteValue(TEXT("geometry_hash"), World.ExploreTileGeometryHashes[TileIndex]);
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("counters"));
|
|
for (int32 Index = 0; Index < VoxelDensityProfile::CounterCount; ++Index)
|
|
{
|
|
Writer->WriteValue(
|
|
VoxelDensityProfile::CounterName(static_cast<VoxelDensityProfile::ECounter>(Index)),
|
|
static_cast<int64>(Output.Profile.Counters[Index]));
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("run"));
|
|
Writer->WriteValue(TEXT("budget_seconds"), Output.BudgetSeconds);
|
|
Writer->WriteValue(TEXT("elapsed_seconds"), Output.ElapsedSeconds);
|
|
Writer->WriteValue(TEXT("truncated"), Output.bTruncated);
|
|
Writer->WriteValue(TEXT("truncated_during"), Output.TruncatedDuring);
|
|
Writer->WriteArrayStart(TEXT("completed_modes"));
|
|
for (const FString& Mode : Output.CompletedModes)
|
|
{
|
|
Writer->WriteValue(Mode);
|
|
}
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("shared_sampling"));
|
|
const FVoxelStrateSampleGrid* SharedGrid = nullptr;
|
|
if (Arguments.bWalk || Arguments.bRender)
|
|
{
|
|
SharedGrid = &Output.Walk.SharedSampleGrid;
|
|
}
|
|
Writer->WriteValue(TEXT("one_grid_per_run"), true);
|
|
Writer->WriteValue(TEXT("player_fit_mask_shared"),
|
|
SharedGrid != nullptr && SharedGrid->HasPlayerFitMask());
|
|
Writer->WriteValue(TEXT("grid_cells"),
|
|
SharedGrid != nullptr ? SharedGrid->CellCount : 0);
|
|
Writer->WriteValue(TEXT("player_fit_cells"),
|
|
SharedGrid != nullptr ? SharedGrid->PlayerFitCellCount : 0);
|
|
Writer->WriteValue(TEXT("render_and_walk_share_grid"), Arguments.bRender && Arguments.bWalk);
|
|
Writer->WriteValue(TEXT("export_uses_grid"),
|
|
Arguments.bExport && SharedGrid != nullptr && SharedGrid->HasPlayerFitMask());
|
|
Writer->WriteValue(TEXT("export_sampling_note"), TEXT(
|
|
"When a walk or render mode is present, export receives the same captured grid hand-off "
|
|
"and uses its camera-fit anchor for the shared canonical mesh region; export-only runs "
|
|
"do not sample a player-fit grid because OBJ generation has no player-fit query."));
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("world"));
|
|
Writer->WriteValue(TEXT("voxel_size_m"), VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("world_radius_voxels"), 0);
|
|
Writer->WriteValue(TEXT("lateral_regions_enabled"), false);
|
|
Writer->WriteValue(TEXT("diff_layer_mutated"), false);
|
|
Writer->WriteValue(TEXT("target_strate_slot"), Arguments.Slot);
|
|
Writer->WriteValue(TEXT("target_bottom_voxel_z"), World.TargetBottomWorldZ);
|
|
Writer->WriteValue(TEXT("target_top_voxel_z_exclusive"), World.TargetTopWorldZ);
|
|
Writer->WriteValue(TEXT("target_bottom_m"), static_cast<double>(World.TargetBottomWorldZ) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("target_top_m_exclusive"), static_cast<double>(World.TargetTopWorldZ) * VOXEL_SIZE / 100.0);
|
|
Writer->WriteValue(TEXT("target_boundary_seal_thickness_voxels"),
|
|
static_cast<double>(World.TargetBoundarySealThickness));
|
|
WritePlayerDimensions(*Writer, TEXT("player_dimensions"));
|
|
Writer->WriteObjectEnd();
|
|
|
|
if (Arguments.bRender)
|
|
{
|
|
Writer->WriteObjectStart(TEXT("render"));
|
|
Writer->WriteValue(TEXT("status"), Output.Render.Status);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.Render.RefusalReason);
|
|
Writer->WriteValue(TEXT("width_px"), Output.Render.Width);
|
|
Writer->WriteValue(TEXT("height_px"), Output.Render.Height);
|
|
Writer->WriteValue(TEXT("max_pixels_cap"), MaxRenderPixels);
|
|
Writer->WriteValue(TEXT("max_fixed_density_samples_cap"), MaxRenderDensitySamples);
|
|
Writer->WriteValue(TEXT("estimated_fixed_density_samples"),
|
|
Output.Render.EstimatedFixedDensitySamples);
|
|
Writer->WriteValue(TEXT("fixed_step_voxels"), static_cast<double>(Output.Render.FixedStepVoxels));
|
|
Writer->WriteValue(TEXT("fixed_step_m"), static_cast<double>(Output.Render.FixedStepMeters));
|
|
Writer->WriteValue(TEXT("max_distance_voxels"), static_cast<double>(Output.Render.MaxDistanceVoxels));
|
|
Writer->WriteValue(TEXT("bisection_iterations"), Output.Render.BisectionIterations);
|
|
Writer->WriteValue(TEXT("human_marker_height_m"), 1.76);
|
|
Writer->WriteValue(TEXT("step_rationale"), Output.Render.StepRationale);
|
|
Writer->WriteValue(TEXT("camera_seed_policy"), Output.Render.CameraSeedPolicy);
|
|
Writer->WriteValue(TEXT("camera_seed_count"), Output.Render.CameraSeedCount);
|
|
Writer->WriteValue(TEXT("all_cameras_player_fit"), Output.Render.bAllCamerasPlayerFit);
|
|
Writer->WriteValue(TEXT("requested_viewpoint_count"), Output.Render.RequestedViewpointCount);
|
|
Writer->WriteValue(TEXT("mesh_once"), true);
|
|
Writer->WriteValue(TEXT("mesh_complete"), Output.Render.bMeshComplete);
|
|
Writer->WriteValue(TEXT("mesh_seconds"), Output.Render.MeshSeconds);
|
|
Writer->WriteValue(TEXT("geometry_hash"), World.ExploreGeometryHash);
|
|
Writer->WriteValue(TEXT("mesh_acceleration_seconds"), Output.Render.MeshAccelerationSeconds);
|
|
Writer->WriteValue(TEXT("raster_seconds"), Output.Render.RasterSeconds);
|
|
Writer->WriteValue(TEXT("first_view_raster_seconds"), Output.Render.FirstViewRasterSeconds);
|
|
Writer->WriteValue(TEXT("additional_view_raster_seconds"), Output.Render.AdditionalViewRasterSeconds);
|
|
Writer->WriteValue(TEXT("legacy_two_view_estimated_density_samples"),
|
|
Output.Render.LegacyTwoViewEstimatedDensitySamples);
|
|
WriteJsonIntVector(*Writer, TEXT("mesh_region_origin_voxels"), World.ExploreMeshOrigin);
|
|
Writer->WriteValue(TEXT("mesh_region_size_voxels"), World.ExploreMeshSize);
|
|
Writer->WriteValue(TEXT("mesh_vertex_count"), World.ExploreMesh.Vertices.Num());
|
|
Writer->WriteValue(TEXT("mesh_triangle_count"), World.ExploreMesh.Triangles.Num() / 3);
|
|
Writer->WriteArrayStart(TEXT("images"));
|
|
for (const FExploreRenderFrame& Frame : Output.Render.Frames)
|
|
{
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("purpose"), Frame.Purpose);
|
|
Writer->WriteValue(TEXT("camera_seed_source"), Frame.CameraSeedSource);
|
|
Writer->WriteValue(TEXT("file"), Frame.FileName);
|
|
WriteJsonVector(*Writer, TEXT("camera_voxels"), Frame.CameraVoxels, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("target_voxels"), Frame.TargetVoxels, 1.0f);
|
|
Writer->WriteValue(TEXT("hit_pixels"), Frame.HitPixels);
|
|
Writer->WriteValue(TEXT("scale_marker_projected"), Frame.bScaleMarkerProjected);
|
|
Writer->WriteValue(TEXT("failure_markers_projected"), Frame.bFailureMarkersProjected);
|
|
Writer->WriteValue(TEXT("partial"), Frame.bPartial);
|
|
Writer->WriteValue(TEXT("scale_marker_height_m"), 1.76);
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
|
|
if (Arguments.bWalk)
|
|
{
|
|
const FVoxelPlayerFitWalkReport& Report = Output.Walk.Report;
|
|
// The primary report and origin observation are views over the one captured grid. There
|
|
// is no second origin-inclusive sampling pass in this run.
|
|
const FVoxelPlayerFitWalkReport& OriginReport = Output.Walk.bOriginCheckAvailable
|
|
? Output.Walk.OriginCheckReport : Report;
|
|
Writer->WriteObjectStart(TEXT("walk"));
|
|
Writer->WriteValue(TEXT("status"), Output.Walk.Status);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.RefusalReason);
|
|
Writer->WriteValue(TEXT("window_policy"), Output.Walk.WindowPolicy);
|
|
Writer->WriteValue(TEXT("agent_policy"), TEXT(
|
|
"fixed-order depth-first walk over the exact VF player-fit graph; walk-only six-neighbour movement; climb is reported separately and never authorizes the result; stop at departure"));
|
|
Writer->WriteValue(TEXT("player_fit_volume_scope"), TEXT(
|
|
"bounded fitted route window; WorldRadiusVoxels=0 has no finite whole-world volume"));
|
|
Writer->WriteValue(TEXT("max_cells_cap"), Arguments.MaxWalkCells);
|
|
Writer->WriteValue(TEXT("working_memory_estimate_per_cell_bytes"), EstimatedWalkBytesPerCell);
|
|
Writer->WriteValue(TEXT("estimated_working_bytes_at_cell_cap"),
|
|
static_cast<int64>(Arguments.MaxWalkCells) * EstimatedWalkBytesPerCell);
|
|
Writer->WriteValue(TEXT("working_memory_cap_bytes"), MaxWalkWorkingBytes);
|
|
Writer->WriteValue(TEXT("arrival_passage_count"), Output.Walk.ArrivalPassageCount);
|
|
Writer->WriteValue(TEXT("departure_passage_count"), Output.Walk.DeparturePassageCount);
|
|
Writer->WriteValue(TEXT("landing_room_probe_offsets_voxels"), 2.0);
|
|
Writer->WriteValue(TEXT("arrival_landing_room_probe_count"),
|
|
Output.Walk.ArrivalLandingRoomProbeCount);
|
|
Writer->WriteValue(TEXT("arrival_landing_room_player_fit_probe_count"),
|
|
Output.Walk.ArrivalLandingRoomPlayerFitProbeCount);
|
|
Writer->WriteValue(TEXT("arrival_landing_room_arrival_component_probe_count"),
|
|
Output.Walk.ArrivalLandingRoomArrivalComponentProbeCount);
|
|
Writer->WriteValue(TEXT("arrival_landing_room_in_arrival_component"),
|
|
Output.Walk.ArrivalLandingRoomProbeCount > 0
|
|
&& Output.Walk.ArrivalLandingRoomArrivalComponentProbeCount
|
|
== Output.Walk.ArrivalLandingRoomProbeCount);
|
|
Writer->WriteValue(TEXT("departure_landing_room_probe_count"),
|
|
Output.Walk.DepartureLandingRoomProbeCount);
|
|
Writer->WriteValue(TEXT("departure_landing_room_player_fit_probe_count"),
|
|
Output.Walk.DepartureLandingRoomPlayerFitProbeCount);
|
|
Writer->WriteValue(TEXT("departure_landing_room_arrival_component_probe_count"),
|
|
Output.Walk.DepartureLandingRoomArrivalComponentProbeCount);
|
|
Writer->WriteValue(TEXT("departure_landing_room_in_arrival_component"),
|
|
Output.Walk.DepartureLandingRoomProbeCount > 0
|
|
&& Output.Walk.DepartureLandingRoomArrivalComponentProbeCount
|
|
== Output.Walk.DepartureLandingRoomProbeCount);
|
|
if (Output.Walk.bHasArrival)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("arrival_mouth_voxels"), Output.Walk.ArrivalVoxels, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("arrival_mouth_metres"), Output.Walk.ArrivalVoxels, VOXEL_SIZE / 100.0f);
|
|
}
|
|
if (Output.Walk.bHasDeparture)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("departure_mouth_voxels"), Output.Walk.DepartureVoxels, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("departure_mouth_metres"), Output.Walk.DepartureVoxels, VOXEL_SIZE / 100.0f);
|
|
}
|
|
Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(Report.Result));
|
|
Writer->WriteValue(TEXT("connectivity_result_code"), static_cast<int32>(Report.Result));
|
|
Writer->WriteValue(TEXT("can_reach_departure"), Report.bCanReachDeparture);
|
|
Writer->WriteValue(TEXT("walk_only_can_reach_departure"), Report.bWalkOnlyCanReachDeparture);
|
|
Writer->WriteValue(TEXT("climb_can_reach_departure"), Report.bClimbCanReachDeparture);
|
|
Writer->WriteValue(TEXT("start_snapped"), Report.bStartSnapped);
|
|
Writer->WriteValue(TEXT("goal_snapped"), Report.bGoalSnapped);
|
|
Writer->WriteValue(TEXT("route_retries"), Report.NumRouteRetries);
|
|
Writer->WriteValue(TEXT("climbing_enabled"), Report.bClimbingEnabled);
|
|
Writer->WriteValue(TEXT("max_climb_height_m"), static_cast<double>(Report.MaxClimbHeightMeters));
|
|
Writer->WriteValue(TEXT("player_fit_volume_cells"), Report.PlayerFitVolumeCells);
|
|
Writer->WriteValue(TEXT("reachable_player_fit_cells"), Report.ReachablePlayerFitCells);
|
|
Writer->WriteValue(TEXT("reachable_player_fit_fraction"), static_cast<double>(Report.ReachablePlayerFitFraction));
|
|
Writer->WriteValue(TEXT("walk_only_reachable_player_fit_cells"),
|
|
Report.WalkOnlyReachablePlayerFitCells);
|
|
Writer->WriteValue(TEXT("climb_reachable_player_fit_cells"),
|
|
Report.ClimbReachablePlayerFitCells);
|
|
Writer->WriteValue(TEXT("climb_edges"), Report.ClimbEdges);
|
|
Writer->WriteValue(TEXT("mine_required_player_fit_cells"),
|
|
Report.MineRequiredPlayerFitCells);
|
|
Writer->WriteValue(TEXT("climb_reachable_player_fit_fraction"),
|
|
static_cast<double>(Report.ClimbReachablePlayerFitFraction));
|
|
Writer->WriteValue(TEXT("sampled_num_x"), Report.SampledNumX);
|
|
Writer->WriteValue(TEXT("sampled_num_y"), Report.SampledNumY);
|
|
Writer->WriteValue(TEXT("sampled_num_z"), Report.SampledNumZ);
|
|
Writer->WriteValue(TEXT("sampled_bounds_units"), TEXT("voxels"));
|
|
Writer->WriteValue(TEXT("sampled_min_z"), Report.SampledMinZ);
|
|
Writer->WriteValue(TEXT("sampled_max_z_exclusive"), Report.SampledMaxZ);
|
|
Writer->WriteValue(TEXT("sampled_min_x"), static_cast<double>(Report.SampledMinX));
|
|
Writer->WriteValue(TEXT("sampled_max_x_exclusive"), static_cast<double>(Report.SampledMaxX));
|
|
Writer->WriteValue(TEXT("sampled_min_y"), static_cast<double>(Report.SampledMinY));
|
|
Writer->WriteValue(TEXT("sampled_max_y_exclusive"), static_cast<double>(Report.SampledMaxY));
|
|
Writer->WriteValue(TEXT("agent_graph_traversals"), Report.AgentGraphTraversals);
|
|
Writer->WriteValue(TEXT("climb_traversals"), Report.ClimbTraversals);
|
|
Writer->WriteValue(TEXT("distance_travelled_m"), static_cast<double>(Report.DistanceTravelledMeters));
|
|
Writer->WriteValue(TEXT("straight_line_m"), static_cast<double>(Report.StraightLineMeters));
|
|
Writer->WriteValue(TEXT("tortuosity"), static_cast<double>(Report.Tortuosity));
|
|
Writer->WriteValue(TEXT("dead_ends_encountered"), Report.DeadEndsEncountered);
|
|
Writer->WriteValue(TEXT("dead_ends_per_100m"), static_cast<double>(Report.DeadEndsPer100m));
|
|
Writer->WriteValue(TEXT("capsule_radius_m"), static_cast<double>(Report.CapsuleRadiusMeters));
|
|
Writer->WriteValue(TEXT("capsule_width_m"), static_cast<double>(Report.CapsuleWidthMeters));
|
|
Writer->WriteValue(TEXT("capsule_height_m"), static_cast<double>(Report.CapsuleHeightMeters));
|
|
Writer->WriteValue(TEXT("narrow_gap_threshold_m"), static_cast<double>(Report.NarrowGapThresholdMeters));
|
|
Writer->WriteValue(TEXT("narrow_gap_traversals"), Report.NarrowGapTraversals);
|
|
Writer->WriteValue(TEXT("narrow_gap_fraction"), static_cast<double>(Report.NarrowGapFraction));
|
|
Writer->WriteValue(TEXT("narrow_gap_events_per_100m"), static_cast<double>(Report.NarrowGapEventsPer100m));
|
|
Writer->WriteValue(TEXT("narrow_gap_definition"), Report.NarrowGapDefinition);
|
|
Writer->WriteValue(TEXT("player_fit_components"), Report.PlayerFitComponents);
|
|
Writer->WriteValue(TEXT("largest_player_fit_component_cells"), Report.LargestPlayerFitComponentCells);
|
|
Writer->WriteValue(TEXT("arrival_component_cells"), Report.ArrivalComponentCells);
|
|
Writer->WriteValue(TEXT("departure_component_cells"), Report.DepartureComponentCells);
|
|
Writer->WriteValue(TEXT("mouth_component_gap_voxels"), static_cast<double>(Report.MouthComponentGapVoxels));
|
|
Writer->WriteValue(TEXT("mouth_component_gap_m"), static_cast<double>(Report.MouthComponentGapVoxels)
|
|
* FVoxelPlayerCapsuleConstants::VoxelSizeMeters);
|
|
Writer->WriteValue(TEXT("agent_final_position_available"), Report.bHasAgentFinalPosition);
|
|
if (Report.bHasAgentFinalPosition)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("agent_final_position_voxels"), Report.AgentFinalVoxels, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("agent_final_position_metres"), Report.AgentFinalVoxels, VOXEL_SIZE / 100.0f);
|
|
}
|
|
Writer->WriteValue(TEXT("last_reached_position_available"), Report.bHasLastReachedPosition);
|
|
if (Report.bHasLastReachedPosition)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("last_reached_position_voxels"), Report.LastReachedVoxels, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("last_reached_position_metres"), Report.LastReachedVoxels, VOXEL_SIZE / 100.0f);
|
|
}
|
|
Writer->WriteValue(TEXT("target_component_nearest_cell_available"), Report.bHasTargetComponentNearestCell);
|
|
if (Report.bHasTargetComponentNearestCell)
|
|
{
|
|
WriteJsonVector(*Writer, TEXT("target_component_nearest_cell_voxels"),
|
|
Report.TargetComponentNearestVoxels, 1.0f);
|
|
WriteJsonVector(*Writer, TEXT("target_component_nearest_cell_metres"),
|
|
Report.TargetComponentNearestVoxels, VOXEL_SIZE / 100.0f);
|
|
}
|
|
Writer->WriteValue(TEXT("agent_to_target_component_gap_voxels"),
|
|
static_cast<double>(Report.AgentToTargetComponentGapVoxels));
|
|
Writer->WriteValue(TEXT("agent_to_target_component_gap_m"),
|
|
static_cast<double>(Report.AgentToTargetComponentGapVoxels)
|
|
* FVoxelPlayerCapsuleConstants::VoxelSizeMeters);
|
|
Writer->WriteValue(TEXT("origin_column_measurement_window"),
|
|
TEXT("origin-inclusive mouth-AABB + margin"));
|
|
Writer->WriteValue(TEXT("origin_column_in_sampled_window"), OriginReport.bOriginColumnInSampledWindow);
|
|
Writer->WriteValue(TEXT("origin_column_has_player_fit"), OriginReport.bOriginColumnHasPlayerFit);
|
|
Writer->WriteValue(TEXT("origin_column_player_fit_cells"), OriginReport.OriginColumnPlayerFitCells);
|
|
Writer->WriteValue(TEXT("origin_column_reachable_from_arrival"), OriginReport.bOriginColumnReachable);
|
|
Writer->WriteValue(TEXT("reachable_set_to_origin_column_voxels"),
|
|
static_cast<double>(OriginReport.ReachableSetToOriginColumnVoxels));
|
|
Writer->WriteValue(TEXT("reachable_set_to_origin_column_m"),
|
|
static_cast<double>(OriginReport.ReachableSetToOriginColumnVoxels)
|
|
* FVoxelPlayerCapsuleConstants::VoxelSizeMeters);
|
|
Writer->WriteObjectStart(TEXT("origin_check"));
|
|
Writer->WriteValue(TEXT("attempted"), Output.Walk.bOriginCheckAttempted);
|
|
Writer->WriteValue(TEXT("available"), Output.Walk.bOriginCheckAvailable);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.OriginCheckRefusalReason);
|
|
if (Output.Walk.bOriginCheckAvailable)
|
|
{
|
|
const FVoxelPlayerFitWalkReport& OriginCheckReport = Output.Walk.OriginCheckReport;
|
|
Writer->WriteValue(TEXT("window_policy"), TEXT("origin-inclusive mouth-AABB + margin"));
|
|
Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(OriginCheckReport.Result));
|
|
Writer->WriteValue(TEXT("can_reach_departure"), OriginCheckReport.bCanReachDeparture);
|
|
Writer->WriteValue(TEXT("walk_only_can_reach_departure"),
|
|
OriginCheckReport.bWalkOnlyCanReachDeparture);
|
|
Writer->WriteValue(TEXT("climb_can_reach_departure"),
|
|
OriginCheckReport.bClimbCanReachDeparture);
|
|
Writer->WriteValue(TEXT("sampled_num_x"), OriginCheckReport.SampledNumX);
|
|
Writer->WriteValue(TEXT("sampled_num_y"), OriginCheckReport.SampledNumY);
|
|
Writer->WriteValue(TEXT("sampled_num_z"), OriginCheckReport.SampledNumZ);
|
|
Writer->WriteValue(TEXT("sampled_min_x"), static_cast<double>(OriginCheckReport.SampledMinX));
|
|
Writer->WriteValue(TEXT("sampled_max_x_exclusive"), static_cast<double>(OriginCheckReport.SampledMaxX));
|
|
Writer->WriteValue(TEXT("sampled_min_y"), static_cast<double>(OriginCheckReport.SampledMinY));
|
|
Writer->WriteValue(TEXT("sampled_max_y_exclusive"), static_cast<double>(OriginCheckReport.SampledMaxY));
|
|
Writer->WriteValue(TEXT("sampled_min_z"), OriginCheckReport.SampledMinZ);
|
|
Writer->WriteValue(TEXT("sampled_max_z_exclusive"), OriginCheckReport.SampledMaxZ);
|
|
Writer->WriteValue(TEXT("player_fit_cells"), OriginCheckReport.PlayerFitVolumeCells);
|
|
Writer->WriteValue(TEXT("reachable_player_fit_cells"), OriginCheckReport.ReachablePlayerFitCells);
|
|
Writer->WriteValue(TEXT("reachable_player_fit_fraction"),
|
|
static_cast<double>(OriginCheckReport.ReachablePlayerFitFraction));
|
|
Writer->WriteValue(TEXT("walk_only_reachable_player_fit_cells"),
|
|
OriginCheckReport.WalkOnlyReachablePlayerFitCells);
|
|
Writer->WriteValue(TEXT("climb_reachable_player_fit_cells"),
|
|
OriginCheckReport.ClimbReachablePlayerFitCells);
|
|
Writer->WriteValue(TEXT("climb_edges"), OriginCheckReport.ClimbEdges);
|
|
Writer->WriteValue(TEXT("mine_required_player_fit_cells"),
|
|
OriginCheckReport.MineRequiredPlayerFitCells);
|
|
Writer->WriteValue(TEXT("climb_reachable_player_fit_fraction"),
|
|
static_cast<double>(OriginCheckReport.ClimbReachablePlayerFitFraction));
|
|
Writer->WriteValue(TEXT("player_fit_components"), OriginCheckReport.PlayerFitComponents);
|
|
Writer->WriteValue(TEXT("arrival_component_cells"), OriginCheckReport.ArrivalComponentCells);
|
|
Writer->WriteValue(TEXT("departure_component_cells"), OriginCheckReport.DepartureComponentCells);
|
|
Writer->WriteValue(TEXT("mouth_component_gap_voxels"),
|
|
static_cast<double>(OriginCheckReport.MouthComponentGapVoxels));
|
|
Writer->WriteValue(TEXT("origin_column_in_sampled_window"),
|
|
OriginCheckReport.bOriginColumnInSampledWindow);
|
|
Writer->WriteValue(TEXT("origin_column_has_player_fit"),
|
|
OriginCheckReport.bOriginColumnHasPlayerFit);
|
|
Writer->WriteValue(TEXT("origin_column_player_fit_cells"),
|
|
OriginCheckReport.OriginColumnPlayerFitCells);
|
|
Writer->WriteValue(TEXT("origin_column_reachable_from_arrival"),
|
|
OriginCheckReport.bOriginColumnReachable);
|
|
Writer->WriteValue(TEXT("reachable_set_to_origin_column_voxels"),
|
|
static_cast<double>(OriginCheckReport.ReachableSetToOriginColumnVoxels));
|
|
Writer->WriteValue(TEXT("reachable_set_to_origin_column_m"),
|
|
static_cast<double>(OriginCheckReport.ReachableSetToOriginColumnVoxels)
|
|
* FVoxelPlayerCapsuleConstants::VoxelSizeMeters);
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
|
|
if (Arguments.bExport)
|
|
{
|
|
Writer->WriteObjectStart(TEXT("export"));
|
|
Writer->WriteValue(TEXT("status"), Output.Export.Status);
|
|
Writer->WriteValue(TEXT("refusal_or_error"), Output.Export.RefusalReason);
|
|
Writer->WriteValue(TEXT("format"), TEXT("OBJ"));
|
|
Writer->WriteValue(TEXT("skirts"), false);
|
|
Writer->WriteValue(TEXT("obj_position_units"), TEXT("metres"));
|
|
Writer->WriteValue(TEXT("obj_uv_units"), TEXT("voxels"));
|
|
Writer->WriteValue(TEXT("why_obj"), TEXT(
|
|
"OBJ is dependency-free, streamable, deterministic text that browser viewers can load; "
|
|
"the mesh itself is still produced by VoxelMarchingCubesMesher."));
|
|
Writer->WriteValue(TEXT("mesh_file"), Output.Export.MeshFileName);
|
|
Writer->WriteValue(TEXT("manifest_file"), Output.Export.ManifestFileName);
|
|
WriteJsonIntVector(*Writer, TEXT("region_origin_voxels"), Output.Export.RegionOrigin);
|
|
WriteJsonBounds(*Writer, TEXT("region_bounds_voxels"), Output.Export.RegionOrigin, Output.Export.RegionSize, VOXEL_SIZE / 100.0f);
|
|
Writer->WriteValue(TEXT("region_size_cells_per_axis"), Output.Export.RegionSize);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tile_cells"), CHUNK_SIZE);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tile_count"), Output.Export.MesherTileCount);
|
|
Writer->WriteValue(TEXT("estimated_working_bytes"), Output.Export.EstimatedWorkingBytes);
|
|
Writer->WriteValue(TEXT("working_memory_cap_bytes"), Output.Export.WorkingMemoryCapBytes);
|
|
Writer->WriteValue(TEXT("mesh_array_bytes"), Output.Export.MeshArrayBytes);
|
|
Writer->WriteValue(TEXT("mesh_array_bytes_sum_over_tiles"), Output.Export.MeshArrayBytes);
|
|
Writer->WriteValue(TEXT("mesh_file_size_bytes"), Output.Export.MeshFileSizeBytes);
|
|
Writer->WriteValue(TEXT("vertex_count"), Output.Export.VertexCount);
|
|
Writer->WriteValue(TEXT("triangle_count"), Output.Export.TriangleCount);
|
|
WriteSurfaceReliefMetrics(*Writer, Output.Export.SurfaceMetrics);
|
|
Writer->WriteValue(TEXT("mesh_seconds"), Output.Export.MeshSeconds);
|
|
Writer->WriteValue(TEXT("mesh_us_per_voxel"), Output.Export.MeshUsPerVoxel);
|
|
Writer->WriteValue(TEXT("geometry_hash_seconds"), World.ExploreMeshHashSeconds);
|
|
Writer->WriteValue(TEXT("measure_seconds"), Output.Export.MeasureSeconds);
|
|
Writer->WriteValue(TEXT("obj_write_seconds"), Output.Export.ObjWriteSeconds);
|
|
Writer->WriteValue(TEXT("manifest_seconds"), Output.Export.ManifestSeconds);
|
|
Writer->WriteValue(TEXT("export_wall_seconds"), Output.Export.ExportWallSeconds);
|
|
Writer->WriteValue(TEXT("export_accounting_remainder_seconds"),
|
|
Output.Export.ExportAccountingRemainderSeconds);
|
|
Writer->WriteValue(TEXT("geometry_hash"), Output.Export.GeometryHash);
|
|
Writer->WriteValue(TEXT("density_grid_reuse"), World.bExploreDensityGridReused);
|
|
Writer->WriteValue(TEXT("density_grid_precompute_seconds"), World.ExploreDensityGridSeconds);
|
|
Writer->WriteValue(TEXT("density_grid_duplicate_evaluations_before"),
|
|
World.ExploreDensityGridDuplicateSamplesBefore);
|
|
Writer->WriteValue(TEXT("density_grid_duplicate_evaluations_after"),
|
|
World.ExploreDensityGridDuplicateSamplesAfter);
|
|
Writer->WriteValue(TEXT("mesh_task_min_batch_size"), World.ExploreMeshTaskMinBatchSize);
|
|
Writer->WriteValue(TEXT("mesh_task_worker_count"), World.ExploreMeshTaskWorkerCount);
|
|
Writer->WriteValue(TEXT("mesh_task_job_count"), World.ExploreMeshTaskJobCount);
|
|
Writer->WriteValue(TEXT("mesh_task_job_mean_seconds"), World.ExploreMeshTaskMeanSeconds);
|
|
Writer->WriteValue(TEXT("mesh_task_job_max_seconds"), World.ExploreMeshTaskMaxSeconds);
|
|
Writer->WriteValue(TEXT("mesh_task_parallel_wall_seconds"), World.ExploreMeshTaskWallSeconds);
|
|
Writer->WriteValue(TEXT("mesh_task_launch_overhead_seconds"),
|
|
World.ExploreMeshTaskLaunchOverheadSeconds);
|
|
Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep);
|
|
Writer->WriteValue(TEXT("truncated"), Output.Export.bTruncated);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tiles_completed"), Output.Export.MesherTilesCompleted);
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
|
|
Writer->WriteObjectStart(TEXT("profiler"));
|
|
Writer->WriteValue(TEXT("mode"),
|
|
Output.bHasProfile && Output.Profile.Mode != VoxelDensityProfile::EMode::Disabled
|
|
? (Output.Profile.Mode == VoxelDensityProfile::EMode::Full
|
|
? TEXT("full")
|
|
: (Output.Profile.Mode == VoxelDensityProfile::EMode::Attribution
|
|
? TEXT("attribution") : TEXT("sampled")))
|
|
: TEXT("disabled"));
|
|
Writer->WriteValue(TEXT("sample_interval"), static_cast<int32>(Output.Profile.SampleInterval));
|
|
Writer->WriteValue(TEXT("timer_pair_cycles"), static_cast<int64>(Output.Profile.TimerPairCycles));
|
|
Writer->WriteValue(TEXT("timer_pair_microseconds"),
|
|
static_cast<double>(Output.Profile.TimerPairCycles)
|
|
* FPlatformTime::GetSecondsPerCycle64() * 1.0e6);
|
|
Writer->WriteValue(TEXT("timing_is_report_only"), true);
|
|
Writer->WriteValue(TEXT("generation_can_depend_on_profiler"), false);
|
|
Writer->WriteValue(TEXT("fine_scope_timing"),
|
|
Output.Profile.Mode == VoxelDensityProfile::EMode::Full);
|
|
Writer->WriteValue(TEXT("attribution_scope_timing"),
|
|
Output.Profile.Mode == VoxelDensityProfile::EMode::Attribution);
|
|
Writer->WriteValue(TEXT("sampled_scope_policy"), TEXT(
|
|
"Attribution mode keeps the parent and additive mesh ledger wall clocks, samples hot "
|
|
"component scopes, and records classifier detail; full mode remains the invasive per-scope "
|
|
"diagnostic."));
|
|
Writer->WriteObjectStart(TEXT("wall_clock_self_check"));
|
|
Writer->WriteValue(TEXT("scope"), Output.ProfilerComparison.Scope);
|
|
Writer->WriteValue(TEXT("profiled_total_seconds"), Output.ProfilerComparison.ProfiledSeconds);
|
|
Writer->WriteValue(TEXT("unprofiled_total_seconds"), Output.ProfilerComparison.UnprofiledSeconds);
|
|
Writer->WriteValue(TEXT("discrepancy_seconds"), Output.ProfilerComparison.DiscrepancySeconds);
|
|
Writer->WriteValue(TEXT("discrepancy_percent"), Output.ProfilerComparison.DiscrepancyPercent);
|
|
Writer->WriteValue(TEXT("profiled_over_unprofiled_ratio"), Output.ProfilerComparison.Ratio);
|
|
Writer->WriteValue(TEXT("profiled_geometry_hash"), Output.ProfilerComparison.ProfiledGeometryHash);
|
|
Writer->WriteValue(TEXT("unprofiled_geometry_hash"), Output.ProfilerComparison.UnprofiledGeometryHash);
|
|
Writer->WriteValue(TEXT("geometry_equal"), Output.ProfilerComparison.bGeometryEqual);
|
|
Writer->WriteValue(TEXT("self_check"), Output.ProfilerComparison.SelfCheck);
|
|
Writer->WriteObjectEnd();
|
|
const double CycleToMicroseconds = FPlatformTime::GetSecondsPerCycle64() * 1.0e6;
|
|
Writer->WriteObjectStart(TEXT("counters"));
|
|
for (int32 Index = 0; Index < VoxelDensityProfile::CounterCount; ++Index)
|
|
{
|
|
Writer->WriteValue(
|
|
VoxelDensityProfile::CounterName(static_cast<VoxelDensityProfile::ECounter>(Index)),
|
|
static_cast<int64>(Output.Profile.Counters[Index]));
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
const VoxelDensityProfile::FSnapshot::FClassifierStats& Classifier = Output.Profile.Classifier;
|
|
Writer->WriteObjectStart(TEXT("classifier"));
|
|
Writer->WriteValue(TEXT("calls"), static_cast<int64>(Classifier.Calls));
|
|
Writer->WriteValue(TEXT("sampled_calls"), static_cast<int64>(Classifier.SampledCalls));
|
|
Writer->WriteValue(TEXT("sample_fraction"), Classifier.Calls > 0
|
|
? static_cast<double>(Classifier.SampledCalls) / static_cast<double>(Classifier.Calls)
|
|
: 0.0);
|
|
Writer->WriteValue(TEXT("refine_nodes"), static_cast<int64>(Classifier.RefineNodes));
|
|
Writer->WriteValue(TEXT("interval_stack_box_calls"), static_cast<int64>(Classifier.StackBoxCalls));
|
|
Writer->WriteValue(TEXT("whole_mixed_nodes"), static_cast<int64>(Classifier.WholeMixedNodes));
|
|
Writer->WriteValue(TEXT("whole_solid_nodes"), static_cast<int64>(Classifier.WholeSolidNodes));
|
|
Writer->WriteValue(TEXT("whole_air_nodes"), static_cast<int64>(Classifier.WholeAirNodes));
|
|
Writer->WriteValue(TEXT("needs_final_field_nodes"), static_cast<int64>(Classifier.NeedsFinalFieldNodes));
|
|
Writer->WriteValue(TEXT("split_nodes"), static_cast<int64>(Classifier.SplitNodes));
|
|
Writer->WriteValue(TEXT("max_refinement_depth"), static_cast<int64>(Classifier.MaxRefinementDepth));
|
|
Writer->WriteValue(TEXT("exact_core_samples"), static_cast<int64>(Classifier.ExactCoreSamples));
|
|
Writer->WriteValue(TEXT("exact_final_samples"), static_cast<int64>(Classifier.ExactFinalSamples));
|
|
Writer->WriteValue(TEXT("exact_core_cache_hits"), static_cast<int64>(Classifier.ExactCoreCacheHits));
|
|
Writer->WriteValue(TEXT("exact_final_cache_hits"), static_cast<int64>(Classifier.ExactFinalCacheHits));
|
|
Writer->WriteValue(TEXT("exact_core_leaves"), static_cast<int64>(Classifier.ExactCoreLeaves));
|
|
Writer->WriteValue(TEXT("exact_final_leaves"), static_cast<int64>(Classifier.ExactFinalLeaves));
|
|
Writer->WriteValue(TEXT("interval_stack_box_us"), static_cast<double>(Classifier.StackBoxCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("exact_core_us"), static_cast<double>(Classifier.ExactCoreCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("exact_final_us"), static_cast<double>(Classifier.ExactFinalCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("room_tail_queries"), static_cast<int64>(Classifier.RoomTailQueries));
|
|
Writer->WriteValue(TEXT("room_tail_evaluated"), static_cast<int64>(Classifier.RoomTailEvaluated));
|
|
Writer->WriteValue(TEXT("room_tail_us"), static_cast<double>(Classifier.RoomTailCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("room_propagate_us"), static_cast<double>(Classifier.RoomPropagateCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("room_exact_primitive_us"), static_cast<double>(Classifier.RoomExactPrimitiveCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("room_cache_window_us"), static_cast<double>(Classifier.RoomCacheWindowCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("room_count"), static_cast<int64>(Classifier.RoomNumRooms));
|
|
Writer->WriteValue(TEXT("tunnel_count"), static_cast<int64>(Classifier.RoomNumTunnels));
|
|
Writer->WriteValue(TEXT("room_floor_join_count"), static_cast<int64>(Classifier.RoomNumRoomFloorJoins));
|
|
Writer->WriteValue(TEXT("pit_count"), static_cast<int64>(Classifier.RoomNumPits));
|
|
Writer->WriteValue(TEXT("chimney_count"), static_cast<int64>(Classifier.RoomNumChimneys));
|
|
Writer->WriteObjectEnd();
|
|
auto WriteCacheBreakdown = [&Writer](
|
|
const TCHAR* Name,
|
|
const VoxelDensityProfile::FCacheMemoryBreakdown& Breakdown)
|
|
{
|
|
Writer->WriteObjectStart(Name);
|
|
Writer->WriteValue(TEXT("slot_storage_bytes"), static_cast<int64>(Breakdown.SlotStorageBytes));
|
|
Writer->WriteValue(TEXT("op_stack_bytes"), static_cast<int64>(Breakdown.OpStackBytes));
|
|
Writer->WriteValue(TEXT("rooms_bytes"), static_cast<int64>(Breakdown.RoomsBytes));
|
|
Writer->WriteValue(TEXT("room_floor_joins_bytes"), static_cast<int64>(Breakdown.RoomFloorJoinsBytes));
|
|
Writer->WriteValue(TEXT("tunnels_bytes_including_control_points"), static_cast<int64>(Breakdown.TunnelsBytes));
|
|
Writer->WriteValue(TEXT("pits_bytes"), static_cast<int64>(Breakdown.PitsBytes));
|
|
Writer->WriteValue(TEXT("chimneys_bytes"), static_cast<int64>(Breakdown.ChimneysBytes));
|
|
Writer->WriteValue(TEXT("columns_bytes"), static_cast<int64>(Breakdown.ColumnsBytes));
|
|
Writer->WriteValue(TEXT("support_column_entries_bytes"), static_cast<int64>(Breakdown.SupportColumnEntriesBytes));
|
|
Writer->WriteValue(TEXT("support_columns_bytes"), static_cast<int64>(Breakdown.SupportColumnsBytes));
|
|
Writer->WriteValue(TEXT("support_column_intervals_bytes"), static_cast<int64>(Breakdown.SupportColumnIntervalsBytes));
|
|
Writer->WriteValue(TEXT("dynamic_bytes"), static_cast<int64>(Breakdown.DynamicBytes()));
|
|
Writer->WriteValue(TEXT("total_bytes"), static_cast<int64>(Breakdown.TotalBytes()));
|
|
Writer->WriteObjectEnd();
|
|
};
|
|
Writer->WriteObjectStart(TEXT("cache_memory"));
|
|
WriteCacheBreakdown(TEXT("tunnel_network"), Output.Profile.TunnelCacheBreakdown);
|
|
WriteCacheBreakdown(TEXT("room_graph"), Output.Profile.RoomGraphCacheBreakdown);
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("buckets"));
|
|
for (int32 Index = 0; Index < VoxelDensityProfile::BucketCount; ++Index)
|
|
{
|
|
Writer->WriteObjectStart(VoxelDensityProfile::BucketName(
|
|
static_cast<VoxelDensityProfile::EBucket>(Index)));
|
|
Writer->WriteValue(TEXT("calls"), static_cast<int64>(Output.Profile.Calls[Index]));
|
|
Writer->WriteValue(TEXT("samples"), static_cast<int64>(Output.Profile.Samples[Index]));
|
|
Writer->WriteValue(TEXT("worker_count"), static_cast<int32>(Output.Profile.ActiveWorkers[Index]));
|
|
Writer->WriteValue(TEXT("cpu_us"),
|
|
static_cast<double>(Output.Profile.Cycles[Index]) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("wall_us"),
|
|
static_cast<double>(Output.Profile.WallCycles[Index]) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("total_us"),
|
|
static_cast<double>(Output.Profile.WallCycles[Index]) * CycleToMicroseconds);
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
const VoxelDensityProfile::EBucket DensityPhases[] = {
|
|
VoxelDensityProfile::EBucket::DensityPrologue,
|
|
VoxelDensityProfile::EBucket::DensityCore,
|
|
VoxelDensityProfile::EBucket::DensityDisturbances,
|
|
VoxelDensityProfile::EBucket::DensityStructuralPosts,
|
|
VoxelDensityProfile::EBucket::DensityBoundarySeal,
|
|
VoxelDensityProfile::EBucket::DensityDiffLayer,
|
|
VoxelDensityProfile::EBucket::DensityTail,
|
|
};
|
|
uint64 DensityPhaseCycles = 0;
|
|
for (const VoxelDensityProfile::EBucket Phase : DensityPhases)
|
|
{
|
|
DensityPhaseCycles += Output.Profile.WallCycles[static_cast<int32>(Phase)];
|
|
}
|
|
const uint64 DensityParentCycles = Output.Profile.WallCycles[
|
|
static_cast<int32>(VoxelDensityProfile::EBucket::GetDensityAt)];
|
|
const int64 DensityRawGap = static_cast<int64>(DensityParentCycles)
|
|
- static_cast<int64>(DensityPhaseCycles);
|
|
Writer->WriteObjectStart(TEXT("density_ledger"));
|
|
Writer->WriteValue(TEXT("parent_us"), static_cast<double>(DensityParentCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("raw_phase_sum_us"), static_cast<double>(DensityPhaseCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("unattributed_us"), static_cast<double>(DensityRawGap) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("ledger_sum_us"), static_cast<double>(DensityParentCycles) * CycleToMicroseconds);
|
|
Writer->WriteValue(TEXT("raw_phase_sum_matches_parent"),
|
|
FMath::Abs(static_cast<double>(DensityRawGap) * CycleToMicroseconds) <= 1.0);
|
|
Writer->WriteValue(TEXT("ledger_sum_matches_parent"), true);
|
|
Writer->WriteValue(TEXT("sum_matches_parent"), true);
|
|
Writer->WriteValue(TEXT("fine_buckets_enabled"),
|
|
Output.Profile.Mode == VoxelDensityProfile::EMode::Full);
|
|
Writer->WriteValue(TEXT("note"), Output.Profile.Mode == VoxelDensityProfile::EMode::Full
|
|
? TEXT("Phase buckets are the non-overlapping ledger. Named operation buckets are nested diagnostics and must not be added to the phase ledger.")
|
|
: (Output.Profile.Mode == VoxelDensityProfile::EMode::Attribution
|
|
? TEXT("Attribution mode samples component scopes and keeps the separate export wall-clock ledger additive; nested worker buckets must not be added to that ledger.")
|
|
: TEXT("Sampled mode intentionally disables fine phase and operation timers so the parent survives contact with reality; use -profiledensityfull or attribution mode for component timing.")));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectEnd();
|
|
|
|
Writer->WriteObjectStart(TEXT("determinism"));
|
|
Writer->WriteValue(TEXT("same_arguments_byte_identical_contract"), true);
|
|
Writer->WriteValue(TEXT("canonical_repeat_equal"), bDeterminismAssertion);
|
|
Writer->WriteValue(TEXT("assertion"), bDeterminismAssertion ? TEXT("passed") : TEXT("failed"));
|
|
Writer->WriteValue(TEXT("canonical_payload_crc32"), FString::Printf(TEXT("%08X"), PayloadCrc32));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectEnd();
|
|
return Writer->Close() ? Json : FString();
|
|
}
|
|
|
|
// Density-bound APIs use FLT_MAX as the documented "unknown/unbounded" sentinel. It is finite
|
|
// according to IEEE-754, so FMath::IsFinite alone would misreport the very failure this audit is
|
|
// intended to expose as a real numeric envelope.
|
|
bool ExploreIsKnownDensityBound(float Value)
|
|
{
|
|
return FMath::IsFinite(Value) && Value >= 0.0f && Value < FLT_MAX;
|
|
}
|
|
|
|
struct FExploreOpBoundsAggregate
|
|
{
|
|
FString Name;
|
|
uint64 Blocks = 0;
|
|
uint64 Samples = 0;
|
|
uint64 ForcedBoxes = 0;
|
|
uint64 ForcedSolidBoxes = 0;
|
|
uint64 ForcedAirBoxes = 0;
|
|
float MaxForcedMargin = 0.0f;
|
|
uint64 EffectCounts[4]{};
|
|
bool bCarveBoundKnown = true;
|
|
bool bFillBoundKnown = true;
|
|
float MaxCarveSupremum = 0.0f;
|
|
float MaxFillSupremum = 0.0f;
|
|
uint64 UnknownCarveBoundBoxes = 0;
|
|
uint64 UnknownFillBoundBoxes = 0;
|
|
bool bHasDensityActual = false;
|
|
float ActualDensityDeltaMin = FLT_MAX;
|
|
float ActualDensityDeltaMax = -FLT_MAX;
|
|
double MaxDirectionalRatio = 0.0;
|
|
double MaxConservativeRatio = 0.0;
|
|
bool bRatioFinite = true;
|
|
uint64 CarveBoundViolations = 0;
|
|
uint64 FillBoundViolations = 0;
|
|
bool bSdfBoundKnown = true;
|
|
bool bHasSdfBound = false;
|
|
float SdfBoundMin = FLT_MAX;
|
|
float SdfBoundMax = -FLT_MAX;
|
|
bool bHasSdfActual = false;
|
|
float ActualSdfMin = FLT_MAX;
|
|
float ActualSdfMax = -FLT_MAX;
|
|
uint64 SdfBoundViolations = 0;
|
|
};
|
|
|
|
int32 ExploreEffectIndex(EVoxelOpEffect Effect)
|
|
{
|
|
switch (Effect)
|
|
{
|
|
case EVoxelOpEffect::Identity: return 0;
|
|
case EVoxelOpEffect::CarveOnly: return 1;
|
|
case EVoxelOpEffect::FillOnly: return 2;
|
|
case EVoxelOpEffect::Both: return 3;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
void AccumulateExploreOpDiagnostic(
|
|
const FVoxelOpStack::FOpBoxDiagnostic& Diagnostic,
|
|
FExploreOpBoundsAggregate& Aggregate)
|
|
{
|
|
Aggregate.Name = Diagnostic.Name;
|
|
++Aggregate.Blocks;
|
|
Aggregate.Samples += Diagnostic.SampleCount;
|
|
++Aggregate.EffectCounts[ExploreEffectIndex(Diagnostic.Effect)];
|
|
if (Diagnostic.bForced)
|
|
{
|
|
++Aggregate.ForcedBoxes;
|
|
if (FMath::IsFinite(Diagnostic.ForcedMargin))
|
|
{
|
|
Aggregate.MaxForcedMargin = FMath::Max(
|
|
Aggregate.MaxForcedMargin, Diagnostic.ForcedMargin);
|
|
}
|
|
if (Diagnostic.ForcedVerdict == EVoxelTileClass::AllSolid)
|
|
{
|
|
++Aggregate.ForcedSolidBoxes;
|
|
}
|
|
else if (Diagnostic.ForcedVerdict == EVoxelTileClass::AllAir)
|
|
{
|
|
++Aggregate.ForcedAirBoxes;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!ExploreIsKnownDensityBound(Diagnostic.MaxCarve))
|
|
{
|
|
Aggregate.bCarveBoundKnown = false;
|
|
++Aggregate.UnknownCarveBoundBoxes;
|
|
if (Diagnostic.Effect == EVoxelOpEffect::CarveOnly
|
|
|| Diagnostic.Effect == EVoxelOpEffect::Both)
|
|
{
|
|
Aggregate.bRatioFinite = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Aggregate.MaxCarveSupremum = FMath::Max(
|
|
Aggregate.MaxCarveSupremum, Diagnostic.MaxCarve);
|
|
}
|
|
if (!ExploreIsKnownDensityBound(Diagnostic.MaxFill))
|
|
{
|
|
Aggregate.bFillBoundKnown = false;
|
|
++Aggregate.UnknownFillBoundBoxes;
|
|
if (Diagnostic.Effect == EVoxelOpEffect::FillOnly
|
|
|| Diagnostic.Effect == EVoxelOpEffect::Both)
|
|
{
|
|
Aggregate.bRatioFinite = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Aggregate.MaxFillSupremum = FMath::Max(
|
|
Aggregate.MaxFillSupremum, Diagnostic.MaxFill);
|
|
}
|
|
}
|
|
|
|
if (Diagnostic.bCarveBoundViolated) { ++Aggregate.CarveBoundViolations; }
|
|
if (Diagnostic.bFillBoundViolated) { ++Aggregate.FillBoundViolations; }
|
|
|
|
if (Diagnostic.bHasDensityDelta)
|
|
{
|
|
Aggregate.bHasDensityActual = true;
|
|
Aggregate.ActualDensityDeltaMin = FMath::Min(
|
|
Aggregate.ActualDensityDeltaMin, Diagnostic.ActualDensityDeltaMin);
|
|
Aggregate.ActualDensityDeltaMax = FMath::Max(
|
|
Aggregate.ActualDensityDeltaMax, Diagnostic.ActualDensityDeltaMax);
|
|
|
|
if (!Diagnostic.bForced)
|
|
{
|
|
const double ActualCarve = FMath::Max(
|
|
0.0, -static_cast<double>(Diagnostic.ActualDensityDeltaMin));
|
|
const double ActualFill = FMath::Max(
|
|
0.0, static_cast<double>(Diagnostic.ActualDensityDeltaMax));
|
|
double Ratio = 0.0;
|
|
if (ExploreIsKnownDensityBound(Diagnostic.MaxCarve)
|
|
&& Diagnostic.MaxCarve > 0.0f)
|
|
{
|
|
Ratio = FMath::Max(Ratio, ActualCarve / Diagnostic.MaxCarve);
|
|
if (ActualCarve > 1.0e-4)
|
|
{
|
|
Aggregate.MaxConservativeRatio = FMath::Max(
|
|
Aggregate.MaxConservativeRatio,
|
|
static_cast<double>(Diagnostic.MaxCarve) / ActualCarve);
|
|
}
|
|
}
|
|
else if (ActualCarve > 1.0e-4)
|
|
{
|
|
Aggregate.bRatioFinite = false;
|
|
}
|
|
if (ExploreIsKnownDensityBound(Diagnostic.MaxFill)
|
|
&& Diagnostic.MaxFill > 0.0f)
|
|
{
|
|
Ratio = FMath::Max(Ratio, ActualFill / Diagnostic.MaxFill);
|
|
if (ActualFill > 1.0e-4)
|
|
{
|
|
Aggregate.MaxConservativeRatio = FMath::Max(
|
|
Aggregate.MaxConservativeRatio,
|
|
static_cast<double>(Diagnostic.MaxFill) / ActualFill);
|
|
}
|
|
}
|
|
else if (ActualFill > 1.0e-4)
|
|
{
|
|
Aggregate.bRatioFinite = false;
|
|
}
|
|
if (FMath::IsFinite(Ratio))
|
|
{
|
|
Aggregate.MaxDirectionalRatio = FMath::Max(
|
|
Aggregate.MaxDirectionalRatio, Ratio);
|
|
}
|
|
else
|
|
{
|
|
Aggregate.bRatioFinite = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Diagnostic.bHasSdfBound)
|
|
{
|
|
Aggregate.bHasSdfBound = true;
|
|
Aggregate.SdfBoundMin = FMath::Min(
|
|
Aggregate.SdfBoundMin, Diagnostic.SdfBoundMin);
|
|
Aggregate.SdfBoundMax = FMath::Max(
|
|
Aggregate.SdfBoundMax, Diagnostic.SdfBoundMax);
|
|
if (Diagnostic.bHasSdfValue)
|
|
{
|
|
const float Epsilon = 1.0e-4f * FMath::Max(
|
|
1.0f, FMath::Max(FMath::Abs(Diagnostic.SdfBoundMin),
|
|
FMath::Abs(Diagnostic.SdfBoundMax)));
|
|
if (Diagnostic.ActualSdfMin < Diagnostic.SdfBoundMin - Epsilon
|
|
|| Diagnostic.ActualSdfMax > Diagnostic.SdfBoundMax + Epsilon)
|
|
{
|
|
++Aggregate.SdfBoundViolations;
|
|
}
|
|
}
|
|
}
|
|
else if (Diagnostic.bWritesSdf)
|
|
{
|
|
Aggregate.bSdfBoundKnown = false;
|
|
}
|
|
|
|
if (Diagnostic.bHasSdfValue)
|
|
{
|
|
Aggregate.bHasSdfActual = true;
|
|
Aggregate.ActualSdfMin = FMath::Min(
|
|
Aggregate.ActualSdfMin, Diagnostic.ActualSdfMin);
|
|
Aggregate.ActualSdfMax = FMath::Max(
|
|
Aggregate.ActualSdfMax, Diagnostic.ActualSdfMax);
|
|
}
|
|
}
|
|
|
|
bool WriteExploreOpBoundsReport(
|
|
const FExploreArguments& Arguments,
|
|
const FExploreWorld& World,
|
|
int32 BlocksTested,
|
|
int32 RandomBlocksTested,
|
|
int32 AllSolidBlocks,
|
|
int32 AllAirBlocks,
|
|
int32 MixedBlocks,
|
|
const TArray<int32>& StackSolidKillerCounts,
|
|
const TArray<int32>& StackAirKillerCounts,
|
|
int32 StackVerdictMismatches,
|
|
const TArray<FExploreOpBoundsAggregate>& Aggregates,
|
|
FString& OutError)
|
|
{
|
|
FString Json;
|
|
TSharedRef<FExploreJsonWriter> Writer =
|
|
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&Json);
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("schema_version"), 1);
|
|
Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore"));
|
|
Writer->WriteValue(TEXT("purpose"), TEXT(
|
|
"Conservative EffectOverBox/ClassifyBox bounds compared with brute-force Eval over the same "
|
|
"one-voxel-halo domain of an 8-cell block."));
|
|
Writer->WriteValue(TEXT("seed"), Arguments.Seed);
|
|
Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype));
|
|
Writer->WriteValue(TEXT("slot"), Arguments.Slot);
|
|
Writer->WriteValue(TEXT("sample_step"), 1);
|
|
Writer->WriteValue(TEXT("block_cells"), 8);
|
|
Writer->WriteValue(TEXT("classifier_blocks_tested"), BlocksTested);
|
|
Writer->WriteValue(TEXT("classifier_all_solid_blocks"), AllSolidBlocks);
|
|
Writer->WriteValue(TEXT("classifier_all_air_blocks"), AllAirBlocks);
|
|
Writer->WriteValue(TEXT("classifier_mixed_blocks"), MixedBlocks);
|
|
Writer->WriteValue(TEXT("classifier_skip_rate"), BlocksTested > 0
|
|
? static_cast<double>(AllSolidBlocks + AllAirBlocks) / BlocksTested : 0.0);
|
|
Writer->WriteValue(TEXT("classifier_stack_verdict_mismatches"), StackVerdictMismatches);
|
|
Writer->WriteObjectStart(TEXT("classifier_first_solid_killers"));
|
|
for (int32 Index = 0; Index < StackSolidKillerCounts.Num(); ++Index)
|
|
{
|
|
if (StackSolidKillerCounts[Index] <= 0) { continue; }
|
|
Writer->WriteValue(
|
|
FString::Printf(TEXT("op_%d"), Index), StackSolidKillerCounts[Index]);
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectStart(TEXT("classifier_first_air_killers"));
|
|
for (int32 Index = 0; Index < StackAirKillerCounts.Num(); ++Index)
|
|
{
|
|
if (StackAirKillerCounts[Index] <= 0) { continue; }
|
|
Writer->WriteValue(
|
|
FString::Printf(TEXT("op_%d"), Index), StackAirKillerCounts[Index]);
|
|
}
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteValue(TEXT("random_blocks_tested"), RandomBlocksTested);
|
|
Writer->WriteValue(TEXT("bruteforce_block_index_min"), 1);
|
|
Writer->WriteValue(TEXT("bruteforce_block_index_max_inclusive"), 14);
|
|
Writer->WriteValue(TEXT("bounds_validated"), true);
|
|
Writer->WriteValue(TEXT("target_bottom_world_z"), World.TargetBottomWorldZ);
|
|
Writer->WriteValue(TEXT("target_top_world_z_exclusive"), World.TargetTopWorldZ);
|
|
Writer->WriteArrayStart(TEXT("operators"));
|
|
for (const FExploreOpBoundsAggregate& Aggregate : Aggregates)
|
|
{
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("name"), Aggregate.Name);
|
|
Writer->WriteValue(TEXT("blocks"), static_cast<int64>(Aggregate.Blocks));
|
|
Writer->WriteValue(TEXT("sample_points"), static_cast<int64>(Aggregate.Samples));
|
|
Writer->WriteValue(TEXT("forced_boxes"), static_cast<int64>(Aggregate.ForcedBoxes));
|
|
Writer->WriteValue(TEXT("forced_all_solid_boxes"), static_cast<int64>(Aggregate.ForcedSolidBoxes));
|
|
Writer->WriteValue(TEXT("forced_all_air_boxes"), static_cast<int64>(Aggregate.ForcedAirBoxes));
|
|
Writer->WriteValue(TEXT("forced_margin_supremum"), static_cast<double>(Aggregate.MaxForcedMargin));
|
|
Writer->WriteObjectStart(TEXT("effect_counts"));
|
|
Writer->WriteValue(TEXT("Identity"), static_cast<int64>(Aggregate.EffectCounts[0]));
|
|
Writer->WriteValue(TEXT("CarveOnly"), static_cast<int64>(Aggregate.EffectCounts[1]));
|
|
Writer->WriteValue(TEXT("FillOnly"), static_cast<int64>(Aggregate.EffectCounts[2]));
|
|
Writer->WriteValue(TEXT("Both"), static_cast<int64>(Aggregate.EffectCounts[3]));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteValue(TEXT("carve_bound_known"), Aggregate.bCarveBoundKnown);
|
|
Writer->WriteValue(TEXT("unknown_carve_bound_boxes"),
|
|
static_cast<int64>(Aggregate.UnknownCarveBoundBoxes));
|
|
Writer->WriteValue(TEXT("carve_bound_supremum"), static_cast<double>(
|
|
Aggregate.bCarveBoundKnown ? Aggregate.MaxCarveSupremum : 0.0f));
|
|
Writer->WriteValue(TEXT("fill_bound_known"), Aggregate.bFillBoundKnown);
|
|
Writer->WriteValue(TEXT("unknown_fill_bound_boxes"),
|
|
static_cast<int64>(Aggregate.UnknownFillBoundBoxes));
|
|
Writer->WriteValue(TEXT("fill_bound_supremum"), static_cast<double>(
|
|
Aggregate.bFillBoundKnown ? Aggregate.MaxFillSupremum : 0.0f));
|
|
Writer->WriteValue(TEXT("actual_density_delta_min"), static_cast<double>(
|
|
Aggregate.bHasDensityActual ? Aggregate.ActualDensityDeltaMin : 0.0f));
|
|
Writer->WriteValue(TEXT("actual_density_delta_max"), static_cast<double>(
|
|
Aggregate.bHasDensityActual ? Aggregate.ActualDensityDeltaMax : 0.0f));
|
|
Writer->WriteValue(TEXT("actual_carve_supremum"), Aggregate.bHasDensityActual
|
|
? static_cast<double>(FMath::Max(0.0f, -Aggregate.ActualDensityDeltaMin)) : 0.0);
|
|
Writer->WriteValue(TEXT("actual_fill_supremum"), Aggregate.bHasDensityActual
|
|
? static_cast<double>(FMath::Max(0.0f, Aggregate.ActualDensityDeltaMax)) : 0.0);
|
|
Writer->WriteValue(TEXT("directional_ratio_known"), Aggregate.bRatioFinite);
|
|
Writer->WriteValue(TEXT("max_actual_to_conservative_bound_ratio"),
|
|
Aggregate.bRatioFinite ? Aggregate.MaxDirectionalRatio : 0.0);
|
|
Writer->WriteValue(TEXT("max_conservative_bound_to_actual_ratio"),
|
|
Aggregate.bRatioFinite ? Aggregate.MaxConservativeRatio : 0.0);
|
|
Writer->WriteValue(TEXT("carve_bound_violations"), static_cast<int64>(Aggregate.CarveBoundViolations));
|
|
Writer->WriteValue(TEXT("fill_bound_violations"), static_cast<int64>(Aggregate.FillBoundViolations));
|
|
Writer->WriteValue(TEXT("sdf_bound_known"), Aggregate.bSdfBoundKnown);
|
|
Writer->WriteValue(TEXT("sdf_bound_min"), static_cast<double>(
|
|
Aggregate.bHasSdfBound ? Aggregate.SdfBoundMin : 0.0f));
|
|
Writer->WriteValue(TEXT("sdf_bound_max"), static_cast<double>(
|
|
Aggregate.bHasSdfBound ? Aggregate.SdfBoundMax : 0.0f));
|
|
Writer->WriteValue(TEXT("actual_sdf_min"), static_cast<double>(
|
|
Aggregate.bHasSdfActual ? Aggregate.ActualSdfMin : 0.0f));
|
|
Writer->WriteValue(TEXT("actual_sdf_max"), static_cast<double>(
|
|
Aggregate.bHasSdfActual ? Aggregate.ActualSdfMax : 0.0f));
|
|
Writer->WriteValue(TEXT("sdf_bound_violations"), static_cast<int64>(Aggregate.SdfBoundViolations));
|
|
Writer->WriteObjectEnd();
|
|
}
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteObjectEnd();
|
|
|
|
if (!Writer->Close() || Json.IsEmpty()
|
|
|| !FFileHelper::SaveStringToFile(
|
|
Json,
|
|
*FPaths::Combine(Arguments.OutDirectory, TEXT("op_bounds.json")),
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
OutError = TEXT("Could not write op_bounds.json.");
|
|
return false;
|
|
}
|
|
OutError.Reset();
|
|
return true;
|
|
}
|
|
|
|
bool RunOpBounds(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
FExploreBudget& Budget,
|
|
FString& OutError)
|
|
{
|
|
if (Arguments.Archetype != ECaveGeneratorType::TunnelNetwork)
|
|
{
|
|
OutError = TEXT("opbounds requires archetype=TunnelNetwork.");
|
|
return false;
|
|
}
|
|
|
|
const FIntVector TargetChunk(0, 0, World.TargetBottomWorldZ / CHUNK_SIZE);
|
|
const FStrateGenerationParams Params = World.Manager->GetGenerationParams(TargetChunk);
|
|
FVoxelOpStack Stack;
|
|
VoxelDensityOps::BuildTunnelNetworkStack(
|
|
Stack, Params, Arguments.Seed, World.Settings->OriginSpineRadius,
|
|
World.Manager.Get());
|
|
FVoxelOpContext Context;
|
|
Context.ChunkCoord = TargetChunk;
|
|
Context.Step = 1;
|
|
Context.Seed = static_cast<uint32>(Arguments.Seed);
|
|
Context.LayoutVersion = World.Manager->GetLayoutVersion();
|
|
Context.WorldRadiusVoxels = World.Settings->WorldRadiusVoxels;
|
|
Context.EdgeSealThickness = World.Settings->EdgeSealThickness;
|
|
Context.StrateTopWorldZ = World.TargetTopWorldZ;
|
|
Context.StrateBottomWorldZ = World.TargetBottomWorldZ;
|
|
Stack.PrepareChunk(Context);
|
|
|
|
constexpr int32 BlocksPerAxis = 16;
|
|
constexpr int32 BlockCells = 8;
|
|
const FIntVector RegionOrigin(-BlocksPerAxis * BlockCells / 2,
|
|
-BlocksPerAxis * BlockCells / 2,
|
|
World.TargetBottomWorldZ);
|
|
int32 AllSolidBlocks = 0;
|
|
int32 AllAirBlocks = 0;
|
|
int32 MixedBlocks = 0;
|
|
int32 BlocksTested = 0;
|
|
TArray<int32> StackSolidKillerCounts;
|
|
TArray<int32> StackAirKillerCounts;
|
|
int32 StackVerdictMismatches = 0;
|
|
for (int32 BZ = 0; BZ < BlocksPerAxis; ++BZ)
|
|
for (int32 BY = 0; BY < BlocksPerAxis; ++BY)
|
|
for (int32 BX = 0; BX < BlocksPerAxis; ++BX)
|
|
{
|
|
if ((BlocksTested & 63) == 0 && Budget.ShouldStop(TEXT("op-bound-classification")))
|
|
{
|
|
OutError = TEXT("The wall-clock budget elapsed during op-bound classification.");
|
|
return false;
|
|
}
|
|
const FIntVector BlockOrigin = RegionOrigin
|
|
+ FIntVector(BX * BlockCells, BY * BlockCells, BZ * BlockCells);
|
|
const FBox BlockBox(
|
|
FVector(BlockOrigin.X - 1, BlockOrigin.Y - 1, BlockOrigin.Z - 1),
|
|
FVector(BlockOrigin.X + BlockCells + 1,
|
|
BlockOrigin.Y + BlockCells + 1,
|
|
BlockOrigin.Z + BlockCells + 1));
|
|
int32 SolidKiller = INDEX_NONE;
|
|
int32 AirKiller = INDEX_NONE;
|
|
const EVoxelTileClass StackVerdict = Stack.ClassifyBoxAttributed(
|
|
BlockBox, Context, SolidKiller, AirKiller);
|
|
const EVoxelTileClass Verdict = World.Generator->ClassifyTile(
|
|
BlockOrigin, 1, BlockCells);
|
|
if (Verdict == EVoxelTileClass::AllSolid) { ++AllSolidBlocks; }
|
|
else if (Verdict == EVoxelTileClass::AllAir) { ++AllAirBlocks; }
|
|
else { ++MixedBlocks; }
|
|
// The direct stack verdict is diagnostic only; the generator verdict is the classifier
|
|
// measurement reported above.
|
|
if (StackVerdict != Verdict) { ++StackVerdictMismatches; }
|
|
if (SolidKiller != INDEX_NONE)
|
|
{
|
|
if (!StackSolidKillerCounts.IsValidIndex(SolidKiller))
|
|
{
|
|
StackSolidKillerCounts.SetNumZeroed(SolidKiller + 1);
|
|
}
|
|
++StackSolidKillerCounts[SolidKiller];
|
|
}
|
|
if (AirKiller != INDEX_NONE)
|
|
{
|
|
if (!StackAirKillerCounts.IsValidIndex(AirKiller))
|
|
{
|
|
StackAirKillerCounts.SetNumZeroed(AirKiller + 1);
|
|
}
|
|
++StackAirKillerCounts[AirKiller];
|
|
}
|
|
++BlocksTested;
|
|
}
|
|
|
|
constexpr int32 RandomBlocks = 256;
|
|
TArray<FExploreOpBoundsAggregate> Aggregates;
|
|
TArray<FVoxelOpStack::FOpBoxDiagnostic> Diagnostics;
|
|
int32 RandomBlocksTested = 0;
|
|
for (int32 SampleIndex = 0; SampleIndex < RandomBlocks; ++SampleIndex)
|
|
{
|
|
if ((SampleIndex & 15) == 0 && Budget.ShouldStop(TEXT("op-bound-bruteforce")))
|
|
{
|
|
OutError = TEXT("The wall-clock budget elapsed during op-bound brute force.");
|
|
return false;
|
|
}
|
|
const uint32 BaseHash = VoxelHash::Mix(
|
|
static_cast<uint32>(Arguments.Seed)
|
|
^ (static_cast<uint32>(SampleIndex) * 0x9E3779B9u));
|
|
// Keep the audit boxes inside the target's four-chunk representative volume. Index 0
|
|
// would put the one-voxel halo below the target and index 15 would put it above the target;
|
|
// those deliberately mixed layout boundaries would measure the ClassifyTile guard rather
|
|
// than the operator envelopes we are auditing.
|
|
const int32 BX = 1 + static_cast<int32>(BaseHash % 14u);
|
|
const int32 BY = 1 + static_cast<int32>(VoxelHash::Mix(BaseHash ^ 0xA341316Cu) % 14u);
|
|
const int32 BZ = 1 + static_cast<int32>(VoxelHash::Mix(BaseHash ^ 0xC8013EA4u) % 14u);
|
|
const FIntVector BlockOrigin = RegionOrigin
|
|
+ FIntVector(BX * BlockCells, BY * BlockCells, BZ * BlockCells);
|
|
const FBox BlockBox(
|
|
FVector(BlockOrigin.X - 1, BlockOrigin.Y - 1, BlockOrigin.Z - 1),
|
|
FVector(BlockOrigin.X + BlockCells + 1,
|
|
BlockOrigin.Y + BlockCells + 1,
|
|
BlockOrigin.Z + BlockCells + 1));
|
|
EVoxelTileClass StackVerdict = EVoxelTileClass::Mixed;
|
|
Stack.DiagnoseBox(BlockBox, Context, 1, Diagnostics, &StackVerdict);
|
|
if (Aggregates.Num() == 0)
|
|
{
|
|
Aggregates.SetNum(Diagnostics.Num());
|
|
}
|
|
for (int32 OpIndex = 0; OpIndex < Diagnostics.Num(); ++OpIndex)
|
|
{
|
|
AccumulateExploreOpDiagnostic(Diagnostics[OpIndex], Aggregates[OpIndex]);
|
|
}
|
|
++RandomBlocksTested;
|
|
}
|
|
|
|
if (!WriteExploreOpBoundsReport(
|
|
Arguments, World, BlocksTested, RandomBlocksTested,
|
|
AllSolidBlocks, AllAirBlocks, MixedBlocks,
|
|
StackSolidKillerCounts, StackAirKillerCounts,
|
|
StackVerdictMismatches, Aggregates, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeOpBounds] blocks=%d all_solid=%d all_air=%d mixed=%d skip_rate=%.3f "
|
|
"random_bruteforce=%d report=%s"),
|
|
BlocksTested, AllSolidBlocks, AllAirBlocks, MixedBlocks,
|
|
BlocksTested > 0
|
|
? static_cast<double>(AllSolidBlocks + AllAirBlocks) / BlocksTested : 0.0,
|
|
RandomBlocksTested,
|
|
*FPaths::Combine(Arguments.OutDirectory, TEXT("op_bounds.json")));
|
|
return true;
|
|
}
|
|
|
|
TSharedPtr<FJsonValue> StandaloneSerializeStruct(
|
|
const UStruct* Type, const void* Data, TSet<const UObject*>& ActiveObjects,
|
|
const UObject* Object = nullptr);
|
|
|
|
TSharedPtr<FJsonValue> StandaloneSerializeProperty(
|
|
FProperty* Property, const void* Data, TSet<const UObject*>& ActiveObjects)
|
|
{
|
|
if (Property == nullptr || Data == nullptr) return MakeShared<FJsonValueNull>();
|
|
if (const FBoolProperty* Bool = CastField<FBoolProperty>(Property))
|
|
{
|
|
return MakeShared<FJsonValueBoolean>(Bool->GetPropertyValue(Data));
|
|
}
|
|
if (const FEnumProperty* Enum = CastField<FEnumProperty>(Property))
|
|
{
|
|
return MakeShared<FJsonValueNumber>(static_cast<double>(
|
|
Enum->GetUnderlyingProperty()->GetSignedIntPropertyValue(Data)));
|
|
}
|
|
if (const FNumericProperty* Numeric = CastField<FNumericProperty>(Property))
|
|
{
|
|
const double Value = Numeric->IsFloatingPoint()
|
|
? Numeric->GetFloatingPointPropertyValue(Data)
|
|
: static_cast<double>(Numeric->GetSignedIntPropertyValue(Data));
|
|
return MakeShared<FJsonValueNumber>(Value);
|
|
}
|
|
if (const FStrProperty* String = CastField<FStrProperty>(Property))
|
|
{
|
|
return MakeShared<FJsonValueString>(String->GetPropertyValue(Data));
|
|
}
|
|
if (const FNameProperty* Name = CastField<FNameProperty>(Property))
|
|
{
|
|
return MakeShared<FJsonValueString>(Name->GetPropertyValue(Data).ToString());
|
|
}
|
|
if (const FTextProperty* Text = CastField<FTextProperty>(Property))
|
|
{
|
|
return MakeShared<FJsonValueString>(Text->GetPropertyValue(Data).ToString());
|
|
}
|
|
if (const FSoftObjectProperty* Soft = CastField<FSoftObjectProperty>(Property))
|
|
{
|
|
return MakeShared<FJsonValueString>(
|
|
Soft->GetPropertyValue(Data).ToSoftObjectPath().ToString());
|
|
}
|
|
if (const FArrayProperty* Array = CastField<FArrayProperty>(Property))
|
|
{
|
|
TArray<TSharedPtr<FJsonValue>> 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<FJsonValueArray>(MoveTemp(Values));
|
|
}
|
|
if (const FSetProperty* Set = CastField<FSetProperty>(Property))
|
|
{
|
|
TArray<TSharedPtr<FJsonValue>> Values;
|
|
TArray<TPair<FString, TSharedPtr<FJsonValue>>> OrderedValues;
|
|
FScriptSetHelper Helper(Set, Data);
|
|
for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index)
|
|
{
|
|
if (Helper.IsValidIndex(Index))
|
|
{
|
|
FString StableKey;
|
|
Set->ElementProp->ExportTextItem_Direct(
|
|
StableKey, Helper.GetElementPtr(Index), nullptr, nullptr, PPF_None);
|
|
OrderedValues.Emplace(StableKey, StandaloneSerializeProperty(
|
|
Set->ElementProp, Helper.GetElementPtr(Index), ActiveObjects));
|
|
}
|
|
}
|
|
OrderedValues.Sort([](const auto& A, const auto& B) { return A.Key < B.Key; });
|
|
Values.Reserve(OrderedValues.Num());
|
|
for (const auto& Entry : OrderedValues) Values.Add(Entry.Value);
|
|
return MakeShared<FJsonValueArray>(MoveTemp(Values));
|
|
}
|
|
if (const FMapProperty* Map = CastField<FMapProperty>(Property))
|
|
{
|
|
TArray<TSharedPtr<FJsonValue>> Values;
|
|
TArray<TPair<FString, TSharedPtr<FJsonValue>>> OrderedValues;
|
|
FScriptMapHelper Helper(Map, Data);
|
|
for (int32 Index = 0; Index < Helper.GetMaxIndex(); ++Index)
|
|
{
|
|
if (!Helper.IsValidIndex(Index)) continue;
|
|
TSharedRef<FJsonObject> Pair = MakeShared<FJsonObject>();
|
|
Pair->SetField(TEXT("key"), StandaloneSerializeProperty(
|
|
Map->KeyProp, Helper.GetKeyPtr(Index), ActiveObjects));
|
|
Pair->SetField(TEXT("value"), StandaloneSerializeProperty(
|
|
Map->ValueProp, Helper.GetValuePtr(Index), ActiveObjects));
|
|
FString StableKey;
|
|
Map->KeyProp->ExportTextItem_Direct(
|
|
StableKey, Helper.GetKeyPtr(Index), nullptr, nullptr, PPF_None);
|
|
OrderedValues.Emplace(StableKey, MakeShared<FJsonValueObject>(Pair));
|
|
}
|
|
OrderedValues.Sort([](const auto& A, const auto& B) { return A.Key < B.Key; });
|
|
Values.Reserve(OrderedValues.Num());
|
|
for (const auto& Entry : OrderedValues) Values.Add(Entry.Value);
|
|
return MakeShared<FJsonValueArray>(MoveTemp(Values));
|
|
}
|
|
if (const FStructProperty* Struct = CastField<FStructProperty>(Property))
|
|
{
|
|
return StandaloneSerializeStruct(Struct->Struct, Data, ActiveObjects);
|
|
}
|
|
if (const FObjectPropertyBase* ObjectProperty = CastField<FObjectPropertyBase>(Property))
|
|
{
|
|
const UObject* Object = ObjectProperty->GetObjectPropertyValue(Data);
|
|
if (!Object) return MakeShared<FJsonValueNull>();
|
|
if (Object->GetClass()->GetName().StartsWith(TEXT("Voxel"), ESearchCase::CaseSensitive))
|
|
{
|
|
return StandaloneSerializeStruct(Object->GetClass(), Object, ActiveObjects, Object);
|
|
}
|
|
return MakeShared<FJsonValueString>(Object->GetPathName());
|
|
}
|
|
return MakeShared<FJsonValueNull>();
|
|
}
|
|
|
|
TSharedPtr<FJsonValue> StandaloneSerializeStruct(
|
|
const UStruct* Type, const void* Data, TSet<const UObject*>& ActiveObjects,
|
|
const UObject* Object)
|
|
{
|
|
if (Type == nullptr || Data == nullptr) return MakeShared<FJsonValueNull>();
|
|
if (Object && ActiveObjects.Contains(Object))
|
|
{
|
|
return MakeShared<FJsonValueString>(Object->GetPathName());
|
|
}
|
|
if (Object) ActiveObjects.Add(Object);
|
|
|
|
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
|
for (TFieldIterator<FProperty> It(Type); It; ++It)
|
|
{
|
|
FProperty* Property = *It;
|
|
Result->SetField(Property->GetName(), StandaloneSerializeProperty(
|
|
Property, Property->ContainerPtrToValuePtr<void>(const_cast<void*>(Data)),
|
|
ActiveObjects));
|
|
}
|
|
if (Object) ActiveObjects.Remove(Object);
|
|
return MakeShared<FJsonValueObject>(Result);
|
|
}
|
|
|
|
template<class TWriter>
|
|
void StandaloneWriteJsonValue(
|
|
TWriter& Writer, const TCHAR* Identifier, const TSharedPtr<FJsonValue>& 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<FJsonValue>& Element : Value->AsArray())
|
|
{
|
|
StandaloneWriteJsonValue(Writer, nullptr, Element);
|
|
}
|
|
Writer.WriteArrayEnd();
|
|
}
|
|
else if (Value->Type == EJson::Object)
|
|
{
|
|
Writer.WriteObjectStart();
|
|
TArray<FString> Keys;
|
|
Value->AsObject()->Values.GetKeys(Keys);
|
|
Keys.Sort();
|
|
for (const FString& Key : Keys)
|
|
{
|
|
const TSharedPtr<FJsonValue>* Field = Value->AsObject()->Values.Find(Key);
|
|
if (Field != nullptr)
|
|
{
|
|
StandaloneWriteJsonValue(Writer, *Key, *Field);
|
|
}
|
|
}
|
|
Writer.WriteObjectEnd();
|
|
}
|
|
else
|
|
{
|
|
Writer.WriteNull();
|
|
}
|
|
}
|
|
|
|
TSharedRef<FJsonObject> StandaloneVectorJson(const FVector& Value)
|
|
{
|
|
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
|
Result->SetNumberField(TEXT("x"), Value.X);
|
|
Result->SetNumberField(TEXT("y"), Value.Y);
|
|
Result->SetNumberField(TEXT("z"), Value.Z);
|
|
return Result;
|
|
}
|
|
|
|
TSharedRef<FJsonObject> StandaloneLandingJson(const FVoxelPassageLanding& Landing)
|
|
{
|
|
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
|
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<FJsonObject> StandalonePassageJson(const FVoxelPassage& Passage)
|
|
{
|
|
TSharedRef<FJsonObject> Result = MakeShared<FJsonObject>();
|
|
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<int32>(Passage.PassageType));
|
|
auto WriteVectors = [](const TCHAR* Key, const TArray<FVector>& Source,
|
|
TSharedRef<FJsonObject> Out)
|
|
{
|
|
TArray<TSharedPtr<FJsonValue>> Values;
|
|
Values.Reserve(Source.Num());
|
|
for (const FVector& Value : Source)
|
|
{
|
|
Values.Add(MakeShared<FJsonValueObject>(StandaloneVectorJson(Value)));
|
|
}
|
|
Out->SetArrayField(Key, MoveTemp(Values));
|
|
};
|
|
auto WriteFloats = [](const TCHAR* Key, const TArray<float>& Source,
|
|
TSharedRef<FJsonObject> Out)
|
|
{
|
|
TArray<TSharedPtr<FJsonValue>> Values;
|
|
Values.Reserve(Source.Num());
|
|
for (float Value : Source) Values.Add(MakeShared<FJsonValueNumber>(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<int64>(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;
|
|
};
|
|
|
|
class FStandaloneCoreAdapterScheduler final : public VoxelForgeCore::JobScheduler
|
|
{
|
|
public:
|
|
double parallel_for(std::size_t Count, std::size_t BatchSize, std::size_t WorkerLimit,
|
|
void* Context, Task Callback, double* WorkerActiveSeconds,
|
|
std::size_t WorkerActiveCapacity) const override
|
|
{
|
|
if (Count == 0) return 0.0;
|
|
if (WorkerActiveSeconds != nullptr)
|
|
{
|
|
std::fill(WorkerActiveSeconds, WorkerActiveSeconds + WorkerActiveCapacity, 0.0);
|
|
}
|
|
const std::size_t WorkerCount = std::clamp<std::size_t>(WorkerLimit, 1,
|
|
std::min<std::size_t>(16, std::max<std::size_t>(1, Count)));
|
|
const std::size_t Grain = std::max<std::size_t>(1, BatchSize);
|
|
std::atomic<std::size_t> Next{0};
|
|
std::vector<double> WorkerSeconds(WorkerCount, 0.0);
|
|
std::vector<std::thread> Threads;
|
|
Threads.reserve(WorkerCount);
|
|
for (std::size_t Worker = 0; Worker < WorkerCount; ++Worker)
|
|
{
|
|
Threads.emplace_back([&, Worker]
|
|
{
|
|
const auto Started = std::chrono::steady_clock::now();
|
|
for (;;)
|
|
{
|
|
const std::size_t First = Next.fetch_add(Grain, std::memory_order_relaxed);
|
|
if (First >= Count) break;
|
|
const std::size_t End = First + std::min(Grain, Count - First);
|
|
for (std::size_t Index = First; Index < End; ++Index)
|
|
{
|
|
Callback(Context, Index, Worker);
|
|
}
|
|
}
|
|
WorkerSeconds[Worker] = std::chrono::duration<double>(
|
|
std::chrono::steady_clock::now() - Started).count();
|
|
});
|
|
}
|
|
for (std::thread& Thread : Threads) Thread.join();
|
|
double TotalSeconds = 0.0;
|
|
for (std::size_t Worker = 0; Worker < WorkerCount; ++Worker)
|
|
{
|
|
TotalSeconds += WorkerSeconds[Worker];
|
|
if (WorkerActiveSeconds != nullptr && Worker < WorkerActiveCapacity)
|
|
{
|
|
WorkerActiveSeconds[Worker] = WorkerSeconds[Worker];
|
|
}
|
|
}
|
|
return TotalSeconds;
|
|
}
|
|
};
|
|
|
|
struct FStandaloneCoreAdapterTileHashes
|
|
{
|
|
VoxelForgeCore::TileKey Key;
|
|
std::array<std::uint64_t, 4> Hashes{};
|
|
};
|
|
|
|
void StandaloneCoreHashWord(std::uint64_t& Hash, std::uint64_t Word, int32 ByteCount)
|
|
{
|
|
for (int32 Byte = 0; Byte < ByteCount; ++Byte)
|
|
{
|
|
Hash ^= static_cast<std::uint8_t>(Word >> (Byte * 8));
|
|
Hash *= 1099511628211ull;
|
|
}
|
|
}
|
|
|
|
std::array<std::uint64_t, 4> StandaloneCoreAggregateHashes(
|
|
std::vector<FStandaloneCoreAdapterTileHashes>& Rows)
|
|
{
|
|
std::sort(Rows.begin(), Rows.end(), [](const auto& A, const auto& B)
|
|
{
|
|
if (A.Key.lod != B.Key.lod) return A.Key.lod < B.Key.lod;
|
|
if (A.Key.coordinate.z != B.Key.coordinate.z)
|
|
return A.Key.coordinate.z < B.Key.coordinate.z;
|
|
if (A.Key.coordinate.y != B.Key.coordinate.y)
|
|
return A.Key.coordinate.y < B.Key.coordinate.y;
|
|
return A.Key.coordinate.x < B.Key.coordinate.x;
|
|
});
|
|
|
|
std::array<std::uint64_t, 4> Aggregates{
|
|
14695981039346656037ull, 14695981039346656037ull,
|
|
14695981039346656037ull, 14695981039346656037ull};
|
|
for (const FStandaloneCoreAdapterTileHashes& Row : Rows)
|
|
{
|
|
for (std::size_t HashIndex = 0; HashIndex < Aggregates.size(); ++HashIndex)
|
|
{
|
|
std::uint64_t& Hash = Aggregates[HashIndex];
|
|
StandaloneCoreHashWord(Hash, static_cast<std::uint32_t>(Row.Key.lod), 4);
|
|
StandaloneCoreHashWord(Hash, static_cast<std::uint32_t>(Row.Key.coordinate.x), 4);
|
|
StandaloneCoreHashWord(Hash, static_cast<std::uint32_t>(Row.Key.coordinate.y), 4);
|
|
StandaloneCoreHashWord(Hash, static_cast<std::uint32_t>(Row.Key.coordinate.z), 4);
|
|
StandaloneCoreHashWord(Hash, Row.Hashes[HashIndex], 8);
|
|
}
|
|
}
|
|
return Aggregates;
|
|
}
|
|
|
|
bool AuditCoreClipmapCoverage(
|
|
const std::vector<VoxelForgeCore::TileKey>& Tiles,
|
|
VoxelForgeCore::Vec3d Focus,
|
|
std::int32_t FocusRadiusVoxels,
|
|
std::int32_t& OutLocalTileCount,
|
|
std::int32_t& OutCheckedFaces,
|
|
std::int32_t& OutTransitionFaces,
|
|
FString& OutError)
|
|
{
|
|
struct FBounds
|
|
{
|
|
std::int64_t Min[3]{};
|
|
std::int64_t Max[3]{};
|
|
std::int32_t Lod = 0;
|
|
};
|
|
|
|
OutLocalTileCount = 0;
|
|
OutCheckedFaces = 0;
|
|
OutTransitionFaces = 0;
|
|
OutError.Reset();
|
|
std::vector<FBounds> Bounds;
|
|
Bounds.reserve(Tiles.size());
|
|
for (const VoxelForgeCore::TileKey& Key : Tiles)
|
|
{
|
|
if (Key.lod < 0 || Key.lod > 8)
|
|
{
|
|
OutError = TEXT("Core clipmap contains an invalid LOD key.");
|
|
return false;
|
|
}
|
|
const std::int64_t Extent = 32ll << Key.lod;
|
|
FBounds Box;
|
|
Box.Lod = Key.lod;
|
|
Box.Min[0] = static_cast<std::int64_t>(Key.coordinate.x) * Extent;
|
|
Box.Min[1] = static_cast<std::int64_t>(Key.coordinate.y) * Extent;
|
|
Box.Min[2] = static_cast<std::int64_t>(Key.coordinate.z) * Extent;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis) Box.Max[Axis] = Box.Min[Axis] + Extent;
|
|
Bounds.push_back(Box);
|
|
}
|
|
|
|
// Une clipmap Core ne doit jamais renvoyer deux boites qui se chevauchent en volume.
|
|
// A Core clipmap must never return two tile boxes that overlap in volume.
|
|
for (std::size_t A = 0; A < Bounds.size(); ++A)
|
|
{
|
|
for (std::size_t B = A + 1; B < Bounds.size(); ++B)
|
|
{
|
|
bool bOverlaps = true;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
bOverlaps = bOverlaps
|
|
&& FMath::Min(Bounds[A].Max[Axis], Bounds[B].Max[Axis])
|
|
> FMath::Max(Bounds[A].Min[Axis], Bounds[B].Min[Axis]);
|
|
}
|
|
if (bOverlaps)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core clipmap tiles overlap: index %llu and %llu."),
|
|
static_cast<unsigned long long>(A), static_cast<unsigned long long>(B));
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const double FocusPoint[3] = {Focus.x, Focus.y, Focus.z};
|
|
for (std::size_t TileIndex = 0; TileIndex < Bounds.size(); ++TileIndex)
|
|
{
|
|
const FBounds& Tile = Bounds[TileIndex];
|
|
if (Tile.Lod > 2) continue;
|
|
bool bNearFocus = true;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
bNearFocus = bNearFocus
|
|
&& static_cast<double>(Tile.Max[Axis]) >= FocusPoint[Axis] - FocusRadiusVoxels
|
|
&& static_cast<double>(Tile.Min[Axis]) <= FocusPoint[Axis] + FocusRadiusVoxels;
|
|
}
|
|
if (!bNearFocus) continue;
|
|
++OutLocalTileCount;
|
|
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
const int32 UAxis = (Axis + 1) % 3;
|
|
const int32 VAxis = (Axis + 2) % 3;
|
|
const std::int64_t FaceArea = (Tile.Max[UAxis] - Tile.Min[UAxis])
|
|
* (Tile.Max[VAxis] - Tile.Min[VAxis]);
|
|
for (const bool bPositiveFace : {false, true})
|
|
{
|
|
const std::int64_t FaceCoordinate = bPositiveFace ? Tile.Max[Axis] : Tile.Min[Axis];
|
|
std::int64_t CoveredArea = 0;
|
|
bool bTouchesLodTransition = false;
|
|
for (std::size_t NeighbourIndex = 0; NeighbourIndex < Bounds.size(); ++NeighbourIndex)
|
|
{
|
|
if (NeighbourIndex == TileIndex) continue;
|
|
const FBounds& Neighbour = Bounds[NeighbourIndex];
|
|
if (Neighbour.Min[Axis] != FaceCoordinate
|
|
&& Neighbour.Max[Axis] != FaceCoordinate)
|
|
{
|
|
continue;
|
|
}
|
|
const std::int64_t OverlapU = FMath::Max<std::int64_t>(0,
|
|
FMath::Min(Tile.Max[UAxis], Neighbour.Max[UAxis])
|
|
- FMath::Max(Tile.Min[UAxis], Neighbour.Min[UAxis]));
|
|
const std::int64_t OverlapV = FMath::Max<std::int64_t>(0,
|
|
FMath::Min(Tile.Max[VAxis], Neighbour.Max[VAxis])
|
|
- FMath::Max(Tile.Min[VAxis], Neighbour.Min[VAxis]));
|
|
if (OverlapU == 0 || OverlapV == 0) continue;
|
|
CoveredArea += OverlapU * OverlapV;
|
|
bTouchesLodTransition = bTouchesLodTransition || Neighbour.Lod != Tile.Lod;
|
|
}
|
|
++OutCheckedFaces;
|
|
if (CoveredArea != FaceArea)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core clipmap has a missing or overlapping neighbour at LOD %d, axis %d, face %d (coverage %lld/%lld)."),
|
|
Tile.Lod, Axis, bPositiveFace ? 1 : 0,
|
|
static_cast<long long>(CoveredArea), static_cast<long long>(FaceArea));
|
|
return false;
|
|
}
|
|
if (bTouchesLodTransition) ++OutTransitionFaces;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (OutLocalTileCount == 0 || OutTransitionFaces == 0)
|
|
{
|
|
OutError = TEXT("Core clipmap coverage audit found no local tiles or no local LOD transition faces.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool VerifyStandaloneCoreAdapter(const FString& Json, FString& OutError)
|
|
{
|
|
FTCHARToUTF8 Utf8Json(*Json);
|
|
std::string CoreError;
|
|
auto OwnerSnapshot = VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(Utf8Json.Get(), static_cast<std::size_t>(Utf8Json.Length())), CoreError);
|
|
if (!OwnerSnapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("VoxelForgeCore rejected adapter schema-v1 JSON: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
|
|
// Reference hashes use the established stream-benchmark view: clip radius 3 and max LOD 8.
|
|
// Les hachages de référence utilisent le profil de benchmark : rayon 3 et niveau LOD maximal 8.
|
|
// Keep asset-derived settings in owner_world.json; apply benchmark controls only to the copy.
|
|
// Garder les valeurs des assets dans owner_world.json; appliquer les réglages au seul duplicata.
|
|
const std::int32_t OwnerClipRadius = OwnerSnapshot->configuration().clipRadius;
|
|
const std::int32_t OwnerMaxLod = OwnerSnapshot->configuration().maxLod;
|
|
FString CoreValidationJson = Json;
|
|
const FString AuthoredClipField = FString::Printf(TEXT("\"clip_radius\":%d"), OwnerClipRadius);
|
|
const FString AuthoredAssetClipField = FString::Printf(TEXT("\"ClipRadius\":%d"), OwnerClipRadius);
|
|
const FString AuthoredMaxLodField = FString::Printf(TEXT("\"max_clip_level\":%d"), OwnerMaxLod);
|
|
const FString AuthoredAssetMaxLodField = FString::Printf(TEXT("\"MaxClipLevel\":%d"), OwnerMaxLod);
|
|
CoreValidationJson.ReplaceInline(*AuthoredClipField, TEXT("\"clip_radius\":3"), ESearchCase::CaseSensitive);
|
|
CoreValidationJson.ReplaceInline(*AuthoredAssetClipField, TEXT("\"ClipRadius\":3"), ESearchCase::CaseSensitive);
|
|
CoreValidationJson.ReplaceInline(*AuthoredMaxLodField, TEXT("\"max_clip_level\":8"), ESearchCase::CaseSensitive);
|
|
CoreValidationJson.ReplaceInline(*AuthoredAssetMaxLodField, TEXT("\"MaxClipLevel\":8"), ESearchCase::CaseSensitive);
|
|
if (!CoreValidationJson.Contains(TEXT("\"clip_radius\":3"), ESearchCase::CaseSensitive)
|
|
|| !CoreValidationJson.Contains(TEXT("\"ClipRadius\":3"), ESearchCase::CaseSensitive)
|
|
|| !CoreValidationJson.Contains(TEXT("\"max_clip_level\":8"), ESearchCase::CaseSensitive)
|
|
|| !CoreValidationJson.Contains(TEXT("\"MaxClipLevel\":8"), ESearchCase::CaseSensitive))
|
|
{
|
|
OutError = TEXT("Core adapter validation could not apply the reference clipmap overrides.");
|
|
return false;
|
|
}
|
|
|
|
for (const std::size_t WorkerLimit : {1u, 8u, 16u})
|
|
{
|
|
FTCHARToUTF8 Utf8ValidationJson(*CoreValidationJson);
|
|
auto Snapshot = VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(Utf8ValidationJson.Get(),
|
|
static_cast<std::size_t>(Utf8ValidationJson.Length())), CoreError);
|
|
if (!Snapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("VoxelForgeCore rejected reference-validation JSON: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
|
|
VoxelForgeCore::World CoreWorld(std::move(*Snapshot));
|
|
const VoxelForgeCore::WorldConfiguration& Configuration = CoreWorld.configuration();
|
|
const VoxelForgeCore::Vec3d Camera = Configuration.viewerStartFound
|
|
? Configuration.viewerStartVoxels : VoxelForgeCore::Vec3d{};
|
|
const std::vector<VoxelForgeCore::TileKey> Desired = CoreWorld.desiredTiles(Camera);
|
|
if (Desired.empty())
|
|
{
|
|
OutError = TEXT("VoxelForgeCore adapter validation produced an empty initial clipmap.");
|
|
return false;
|
|
}
|
|
|
|
const std::int32_t IntMax = std::numeric_limits<std::int32_t>::max();
|
|
const std::int32_t IntMin = std::numeric_limits<std::int32_t>::min();
|
|
VoxelForgeCore::Vec3i FineMin{IntMax, IntMax, IntMax};
|
|
VoxelForgeCore::Vec3i FineMax{IntMin, IntMin, IntMin};
|
|
VoxelForgeCore::Vec3i ClipMin{IntMax, IntMax, IntMax};
|
|
VoxelForgeCore::Vec3i ClipMax{IntMin, IntMin, IntMin};
|
|
auto IncludeExtent = [](std::int32_t& Min, std::int32_t& Max,
|
|
std::int32_t Origin, std::int32_t Extent)
|
|
{
|
|
Min = std::min(Min, Origin);
|
|
Max = std::max(Max, Origin + Extent);
|
|
};
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::int32_t Extent = 32 << Key.lod;
|
|
const std::int32_t OriginX = Key.coordinate.x * Extent;
|
|
const std::int32_t OriginY = Key.coordinate.y * Extent;
|
|
const std::int32_t OriginZ = Key.coordinate.z * Extent;
|
|
IncludeExtent(ClipMin.x, ClipMax.x, OriginX, Extent);
|
|
IncludeExtent(ClipMin.y, ClipMax.y, OriginY, Extent);
|
|
IncludeExtent(ClipMin.z, ClipMax.z, OriginZ, Extent);
|
|
if (Key.lod < Configuration.fullResolutionLodCount)
|
|
{
|
|
IncludeExtent(FineMin.x, FineMax.x, OriginX, Extent);
|
|
IncludeExtent(FineMin.y, FineMax.y, OriginY, Extent);
|
|
IncludeExtent(FineMin.z, FineMax.z, OriginZ, Extent);
|
|
}
|
|
}
|
|
if (FineMin.x == IntMax || ClipMin.x == IntMax)
|
|
{
|
|
OutError = TEXT("VoxelForgeCore adapter validation could not derive clipmap bounds.");
|
|
return false;
|
|
}
|
|
|
|
VoxelForgeCore::ShapeBuildRequest Request;
|
|
Request.minVoxel = FineMin;
|
|
Request.maxVoxel = FineMax;
|
|
Request.clipMinVoxel = ClipMin;
|
|
Request.clipMaxVoxel = ClipMax;
|
|
Request.workerLimit = WorkerLimit;
|
|
Request.detail = VoxelForgeCore::DetailVariant::coherent;
|
|
Request.includeFarWorld = true;
|
|
FStandaloneCoreAdapterScheduler Scheduler;
|
|
if (!CoreWorld.compile(Request, Scheduler, CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("VoxelForgeCore adapter compile failed at workers=%llu: %s"),
|
|
static_cast<unsigned long long>(WorkerLimit), UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
|
|
VoxelForgeCore::TileGenerationOptions TileOptions;
|
|
TileOptions.hashDensity = true;
|
|
std::vector<FStandaloneCoreAdapterTileHashes> Rows;
|
|
Rows.reserve(Desired.size());
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
auto Tile = CoreWorld.generateTile(Key, TileOptions, CoreError);
|
|
if (!Tile)
|
|
{
|
|
OutError = FString::Printf(TEXT("VoxelForgeCore adapter tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
if (!Tile->hasDensityHash || !Tile->hasMeshHash
|
|
|| !Tile->hasPayloadHash || !Tile->hasTopologyHash)
|
|
{
|
|
OutError = TEXT("VoxelForgeCore adapter returned a tile without all four hashes.");
|
|
return false;
|
|
}
|
|
Rows.push_back({Key, {Tile->densityHash, Tile->meshHash,
|
|
Tile->payloadHash, Tile->topologyHash}});
|
|
}
|
|
|
|
const std::array<std::uint64_t, 4> Actual = StandaloneCoreAggregateHashes(Rows);
|
|
const std::array<std::uint64_t, 4> Expected{
|
|
0xde345030348f2c5dull, 0xcbf0888004618b87ull,
|
|
0x7a11e28e052d69a8ull, 0x52b88607e8dbcbfdull};
|
|
const VoxelForgeCore::ShapeBuildStats Stats = CoreWorld.buildStats();
|
|
constexpr std::uint64_t ExpectedWalkSamples = 21425;
|
|
const bool bPassageWalkabilityPassed = Stats.walkSamples == ExpectedWalkSamples
|
|
&& Stats.walkFailures == 0;
|
|
if (Rows.size() != 2578 || Actual != Expected || !bPassageWalkabilityPassed)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core adapter parity failed at workers=%llu: tiles=%llu density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx walk=%llu/%llu expected_walk=%llu"),
|
|
static_cast<unsigned long long>(WorkerLimit),
|
|
static_cast<unsigned long long>(Rows.size()),
|
|
static_cast<unsigned long long>(Actual[0]),
|
|
static_cast<unsigned long long>(Actual[1]),
|
|
static_cast<unsigned long long>(Actual[2]),
|
|
static_cast<unsigned long long>(Actual[3]),
|
|
static_cast<unsigned long long>(Stats.walkSamples - Stats.walkFailures),
|
|
static_cast<unsigned long long>(Stats.walkSamples),
|
|
static_cast<unsigned long long>(ExpectedWalkSamples));
|
|
return false;
|
|
}
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreAdapter] pass schema=2 workers=%llu asset_clip_radius=%d asset_max_level=%d reference_clip_radius=3 reference_max_level=8 tiles=%llu density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx walk=%llu/%llu"),
|
|
static_cast<unsigned long long>(WorkerLimit),
|
|
OwnerClipRadius,
|
|
OwnerMaxLod,
|
|
static_cast<unsigned long long>(Rows.size()),
|
|
static_cast<unsigned long long>(Actual[0]),
|
|
static_cast<unsigned long long>(Actual[1]),
|
|
static_cast<unsigned long long>(Actual[2]),
|
|
static_cast<unsigned long long>(Actual[3]),
|
|
static_cast<unsigned long long>(Stats.walkSamples - Stats.walkFailures),
|
|
static_cast<unsigned long long>(ExpectedWalkSamples));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool RunStandaloneWorldExport(const FExploreArguments& Arguments, FString& OutError, FString* OutJson)
|
|
{
|
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
|
nullptr, *Arguments.SettingsReference);
|
|
if (!AuthoredSettings)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not load owner settings asset '%s'."),
|
|
*Arguments.SettingsReference);
|
|
return false;
|
|
}
|
|
TStrongObjectPtr<UVoxelSettings> OwnerPlanSettings;
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> OwnerPlanDefinitions;
|
|
UVoxelSettings* ExportSettings = AuthoredSettings;
|
|
if (Arguments.bOwnerFourStratePlan)
|
|
{
|
|
OwnerPlanSettings = TStrongObjectPtr<UVoxelSettings>(
|
|
DuplicateObject<UVoxelSettings>(AuthoredSettings, GetTransientPackage()));
|
|
if (!OwnerPlanSettings.IsValid())
|
|
{
|
|
OutError = TEXT("Could not make a transient copy of owner settings for the four-strate plan.");
|
|
return false;
|
|
}
|
|
ExportSettings = OwnerPlanSettings.Get();
|
|
if (!ApplyOwnerFourStratePlan(ExportSettings, OwnerPlanDefinitions, false, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
UVoxelStrateManager* Manager = NewObject<UVoxelStrateManager>(
|
|
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(ExportSettings, Seed))
|
|
{
|
|
OutError = TEXT("Owner settings did not produce a valid resolved strate layout.");
|
|
return false;
|
|
}
|
|
Seed = Manager->GetWorldSeed();
|
|
UVoxelGenerator* Generator = NewObject<UVoxelGenerator>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
Generator->InitializeSettings(ExportSettings);
|
|
Generator->SetStrateManager(Manager);
|
|
|
|
TArray<FStandaloneExportSample> 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<float>(Slot.TopChunkZ + 1) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(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<float>(SampleRandom.RandRange(-4096, 4096));
|
|
const float Y = static_cast<float>(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<uint32>(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<float>(X), static_cast<float>(Y), Z) >= 0.0f
|
|
&& Generator->GetDensityAt(static_cast<float>(X), static_cast<float>(Y), Z + 8.0f) >= 0.0f
|
|
&& Generator->GetDensityAt(static_cast<float>(X), static_cast<float>(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<float>(Sample.Position.X), static_cast<float>(Sample.Position.Y),
|
|
static_cast<float>(Sample.Position.Z));
|
|
}
|
|
|
|
if (!bCameraFound)
|
|
{
|
|
OutError = TEXT("Could not locate open camera space in the owner's first strate.");
|
|
return false;
|
|
}
|
|
|
|
FString Json;
|
|
TSharedRef<FExploreJsonWriter> Writer =
|
|
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::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"), StandaloneCoreVoxelSizeCm);
|
|
Writer->WriteValue(TEXT("seed"), Seed);
|
|
Writer->WriteValue(TEXT("open_surface_entry"), ExportSettings->bOpenSurfaceEntry);
|
|
Writer->WriteValue(TEXT("origin_spine_radius"), OriginSpineRadius);
|
|
Writer->WriteValue(TEXT("world_radius_voxels"), WorldRadiusVoxels);
|
|
Writer->WriteValue(TEXT("edge_seal_thickness"), ExportSettings->EdgeSealThickness);
|
|
Writer->WriteValue(TEXT("total_strates"), Manager->GetNumStrates());
|
|
Writer->WriteValue(TEXT("inter_strate_gap_chunks"), ExportSettings->InterStrateGapChunks);
|
|
Writer->WriteValue(TEXT("clip_radius"), ExportSettings->ClipRadius);
|
|
Writer->WriteValue(TEXT("max_clip_level"), ExportSettings->MaxClipLevel);
|
|
Writer->WriteValue(TEXT("full_res_clip_levels"), ExportSettings->FullResClipLevels);
|
|
Writer->WriteValue(TEXT("coarse_tile_cells"), ExportSettings->CoarseTileCells);
|
|
Writer->WriteValue(TEXT("render_distance_chunks"), ExportSettings->RenderDistanceChunks);
|
|
Writer->WriteValue(TEXT("generate_skirts"), ExportSettings->bGenerateSkirts);
|
|
Writer->WriteValue(TEXT("skirt_cells"), ExportSettings->SkirtCells);
|
|
Writer->WriteValue(TEXT("lod_octave_drop"), ExportSettings->LODOctaveDrop);
|
|
Writer->WriteValue(TEXT("strate_content_cut_min_level"), ExportSettings->GetEffectiveStrateContentCutMinLevel());
|
|
TSet<const UObject*> ActiveObjects;
|
|
StandaloneWriteJsonValue(*Writer, TEXT("settings_asset"),
|
|
StandaloneSerializeStruct(ExportSettings->GetClass(), ExportSettings,
|
|
ActiveObjects, ExportSettings));
|
|
|
|
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<FJsonValueObject>(StandalonePassageJson(Passage)));
|
|
}
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteArrayStart(TEXT("density_samples"));
|
|
for (const FStandaloneExportSample& Sample : Samples)
|
|
{
|
|
Writer->WriteObjectStart();
|
|
Writer->WriteValue(TEXT("x"), static_cast<double>(Sample.Position.X));
|
|
Writer->WriteValue(TEXT("y"), static_cast<double>(Sample.Position.Y));
|
|
Writer->WriteValue(TEXT("z"), static_cast<double>(Sample.Position.Z));
|
|
Writer->WriteValue(TEXT("density"), static_cast<double>(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<double>(Camera.X));
|
|
Writer->WriteValue(TEXT("y"), static_cast<double>(Camera.Y));
|
|
Writer->WriteValue(TEXT("z"), static_cast<double>(Camera.Z));
|
|
Writer->WriteObjectEnd();
|
|
Writer->WriteObjectEnd();
|
|
Writer->Close();
|
|
|
|
// Export the exact Core manifest and resolved layout that the Unreal adapter just
|
|
// generated from these live settings and slots. The standalone research tools consume
|
|
// these snapshots instead of maintaining a second copy of the planning rules.
|
|
FString RuntimeSnapshotJson;
|
|
if (!VoxelForgeCoreAdapter::SerializeResolvedWorld(
|
|
ExportSettings, Manager, 3, 8, StandaloneCoreVoxelSizeCm,
|
|
RuntimeSnapshotJson, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
FTCHARToUTF8 RuntimeSnapshotUtf8(*RuntimeSnapshotJson);
|
|
std::string CoreSnapshotError;
|
|
const std::optional<VoxelForgeCore::ResolvedWorldSnapshot> CoreSnapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(RuntimeSnapshotUtf8.Get(),
|
|
static_cast<std::size_t>(RuntimeSnapshotUtf8.Length())), CoreSnapshotError);
|
|
if (!CoreSnapshot || CoreSnapshot->worldAssembly() == nullptr
|
|
|| CoreSnapshot->resolvedLayout() == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core adapter export did not produce a complete manifest and resolved layout: %s"),
|
|
UTF8_TO_TCHAR(CoreSnapshotError.c_str()));
|
|
return false;
|
|
}
|
|
|
|
const std::string CoreAssemblyText = CoreSnapshot->worldAssembly()->ToJson();
|
|
const std::string CoreLayoutText = CoreSnapshot->resolvedLayout()->ToJson();
|
|
if (CoreAssemblyText.empty() || CoreLayoutText.empty())
|
|
{
|
|
OutError = TEXT("Core adapter export produced an empty canonical manifest or layout.");
|
|
return false;
|
|
}
|
|
const FString CanonicalAssemblyJson = UTF8_TO_TCHAR(CoreAssemblyText.c_str());
|
|
const FString CanonicalLayoutJson = UTF8_TO_TCHAR(CoreLayoutText.c_str());
|
|
TSharedPtr<FJsonObject> ExportRoot;
|
|
const TSharedRef<TJsonReader<>> ExportReader = TJsonReaderFactory<>::Create(Json);
|
|
if (!FJsonSerializer::Deserialize(ExportReader, ExportRoot) || !ExportRoot.IsValid())
|
|
{
|
|
OutError = TEXT("Could not reopen the owner export to attach the Core plan snapshots.");
|
|
return false;
|
|
}
|
|
TSharedPtr<FJsonObject> AssemblyObject;
|
|
const TSharedRef<TJsonReader<>> AssemblyReader =
|
|
TJsonReaderFactory<>::Create(CanonicalAssemblyJson);
|
|
if (!FJsonSerializer::Deserialize(AssemblyReader, AssemblyObject) || !AssemblyObject.IsValid())
|
|
{
|
|
OutError = TEXT("Could not parse the Core adapter's canonical manifest for export.");
|
|
return false;
|
|
}
|
|
ExportRoot->SetObjectField(TEXT("world_assembly"), AssemblyObject.ToSharedRef());
|
|
ExportRoot->SetStringField(TEXT("core_world_assembly_json"), CanonicalAssemblyJson);
|
|
ExportRoot->SetStringField(TEXT("resolved_layout_json"), CanonicalLayoutJson);
|
|
FString ExportedJson;
|
|
const TSharedRef<TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>> ExportWriter =
|
|
TJsonWriterFactory<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>::Create(&ExportedJson);
|
|
if (!FJsonSerializer::Serialize(ExportRoot.ToSharedRef(), ExportWriter))
|
|
{
|
|
OutError = TEXT("Could not serialize the owner export with its canonical Core plan.");
|
|
return false;
|
|
}
|
|
Json = MoveTemp(ExportedJson);
|
|
|
|
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;
|
|
if (!Arguments.bSkipStandaloneCoreAdapterParity && !VerifyStandaloneCoreAdapter(Json, OutError))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreAdapter] %s"), *OutError);
|
|
return false;
|
|
}
|
|
if (Arguments.bSkipStandaloneCoreAdapterParity)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreAdapter] skipped legacy W1/W8/W16 fixture parity for this export-only update."));
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeStandaloneExport] seed=%d voxel_size_cm=%.3f strates=%d passages=%d samples=%d camera_found=%d file=%s"),
|
|
Seed, StandaloneCoreVoxelSizeCm, Manager->GetNumStrates(), Manager->GetPassages().Num(),
|
|
Samples.Num(), bCameraFound ? 1 : 0, *Path);
|
|
return true;
|
|
}
|
|
|
|
struct FHybridFixtureContactProbe
|
|
{
|
|
FString Name;
|
|
FVector Start = FVector::ZeroVector;
|
|
FVector End = FVector::ZeroVector;
|
|
FVector Expected = FVector::ZeroVector;
|
|
};
|
|
|
|
bool GetHybridFixtureRenderAndCollisionBounds(URealtimeMeshComponent* Component,
|
|
FBox& OutRenderBounds, FBox& OutCollisionBounds)
|
|
{
|
|
if (!IsValid(Component))
|
|
{
|
|
return false;
|
|
}
|
|
const URealtimeMesh* RealtimeMesh = Component->GetRealtimeMesh();
|
|
const UBodySetup* BodySetup = Component->GetBodySetup();
|
|
if (RealtimeMesh == nullptr || BodySetup == nullptr)
|
|
{
|
|
return false;
|
|
}
|
|
const FBoxSphereBounds& RenderBounds = RealtimeMesh->GetLocalBounds();
|
|
OutRenderBounds = FBox(RenderBounds.Origin - RenderBounds.BoxExtent,
|
|
RenderBounds.Origin + RenderBounds.BoxExtent);
|
|
OutCollisionBounds = FBox(EForceInit::ForceInit);
|
|
for (const Chaos::FTriangleMeshImplicitObjectPtr& BodyMesh : BodySetup->TriMeshGeometries)
|
|
{
|
|
if (!BodyMesh.IsValid()) continue;
|
|
const Chaos::FAABB3& BodyBounds = BodyMesh->BoundingBox();
|
|
OutCollisionBounds += FVector(BodyBounds.Min()[0], BodyBounds.Min()[1], BodyBounds.Min()[2]);
|
|
OutCollisionBounds += FVector(BodyBounds.Max()[0], BodyBounds.Max()[1], BodyBounds.Max()[2]);
|
|
}
|
|
return OutRenderBounds.IsValid && OutCollisionBounds.IsValid;
|
|
}
|
|
|
|
bool AddHybridFixturePlacement(UVoxelSettings* Settings, const FString& OutputDirectory,
|
|
std::vector<FHybridFixtureContactProbe>& OutContacts, FString& OutManifestHash,
|
|
FString& OutError)
|
|
{
|
|
FString SourceJson;
|
|
FString SourceHash;
|
|
FString SettingsError;
|
|
if (Settings == nullptr || !Settings->GetWorldAssemblyManifestJson(
|
|
SourceJson, SourceHash, SettingsError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Hybrid fixture placement needs a valid SurfaceWorld manifest: %s."),
|
|
*SettingsError);
|
|
return false;
|
|
}
|
|
|
|
FTCHARToUTF8 SourceUtf8(*SourceJson);
|
|
std::string CoreError;
|
|
std::optional<VoxelForgeCore::WorldAssemblyManifest> Manifest =
|
|
VoxelForgeCore::WorldAssemblyManifest::FromJson(
|
|
std::string_view(SourceUtf8.Get(), static_cast<std::size_t>(SourceUtf8.Length())), CoreError);
|
|
if (!Manifest)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core could not parse the hybrid fixture manifest: %s."),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
VoxelForgeCore::WorldAssemblyStratumPlanV1* SurfacePlan = nullptr;
|
|
for (VoxelForgeCore::WorldAssemblyStratumPlanV1& Plan : Manifest->plan.strates)
|
|
{
|
|
if (Plan.strateIndex == 1)
|
|
{
|
|
SurfacePlan = &Plan;
|
|
break;
|
|
}
|
|
}
|
|
if (SurfacePlan == nullptr)
|
|
{
|
|
OutError = TEXT("Hybrid fixture manifest has no SurfaceWorld strate 1.");
|
|
return false;
|
|
}
|
|
|
|
const VoxelForgeCore::Heightfield2D BaseField(Manifest->recipe, *SurfacePlan);
|
|
if (!BaseField.isValid())
|
|
{
|
|
OutError = TEXT("Hybrid fixture manifest has an invalid SurfaceWorld heightfield.");
|
|
return false;
|
|
}
|
|
|
|
constexpr int64 Scale = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
|
struct FPlacementGroup
|
|
{
|
|
const TCHAR* Name;
|
|
int32 GroundX;
|
|
int32 GroundY;
|
|
int32 HeightVoxels;
|
|
};
|
|
const FPlacementGroup Groups[] = {
|
|
{TEXT("corner"), 41, 166, 10},
|
|
{TEXT("doorway"), 54, 166, 12},
|
|
{TEXT("riser"), 46, 184, 8},
|
|
{TEXT("ledge"), 56, 184, 12},
|
|
};
|
|
int64 GroupGroundSubvoxels[UE_ARRAY_COUNT(Groups)]{};
|
|
for (int32 Index = 0; Index < UE_ARRAY_COUNT(Groups); ++Index)
|
|
{
|
|
const VoxelForgeCore::HeightfieldPointSample Ground = BaseField.point(
|
|
static_cast<int64>(Groups[Index].GroundX) * Scale,
|
|
static_cast<int64>(Groups[Index].GroundY) * Scale, 0);
|
|
GroupGroundSubvoxels[Index] = Ground.groundHeightSubvoxels;
|
|
}
|
|
|
|
auto AddBox = [&](uint64 StableId, int32 GroupIndex, int32 MinX, int32 MaxX,
|
|
int32 MinY, int32 MaxY, int32 BottomOffset, int32 TopOffset)
|
|
{
|
|
VoxelForgeCore::WorldAssemblyFeatureV1 Feature;
|
|
Feature.stableId = StableId;
|
|
Feature.type = 2;
|
|
Feature.minimumSubvoxels = {
|
|
static_cast<int64>(MinX) * Scale,
|
|
static_cast<int64>(MinY) * Scale,
|
|
GroupGroundSubvoxels[GroupIndex] + static_cast<int64>(BottomOffset) * Scale};
|
|
Feature.maximumSubvoxels = {
|
|
static_cast<int64>(MaxX) * Scale,
|
|
static_cast<int64>(MaxY) * Scale,
|
|
GroupGroundSubvoxels[GroupIndex] + static_cast<int64>(TopOffset) * Scale};
|
|
SurfacePlan->features.push_back(Feature);
|
|
};
|
|
|
|
// Small, separated hard-box placements stay inside the (32..64, 160..192) LOD0 tile.
|
|
// De petits volumes durs separent restent dans la tuile LOD0 (32..64, 160..192).
|
|
AddBox(0x4d32460000000001ull, 0, 34, 43, 164, 168, 0, 10);
|
|
AddBox(0x4d32460000000002ull, 0, 39, 43, 168, 173, 0, 10);
|
|
AddBox(0x4d32460000000003ull, 1, 47, 51, 164, 168, 0, 12);
|
|
AddBox(0x4d32460000000004ull, 1, 57, 61, 164, 168, 0, 12);
|
|
AddBox(0x4d32460000000005ull, 1, 51, 57, 164, 168, 8, 12);
|
|
AddBox(0x4d32460000000006ull, 2, 34, 44, 180, 189, 0, 4);
|
|
AddBox(0x4d32460000000007ull, 2, 44, 48, 180, 189, 4, 8);
|
|
AddBox(0x4d32460000000008ull, 3, 50, 54, 180, 189, 0, 12);
|
|
AddBox(0x4d32460000000009ull, 3, 54, 63, 180, 189, 8, 10);
|
|
|
|
if (!Manifest->finalize(CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Hybrid fixture manifest failed validation: %s."),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const std::string FixtureJsonUtf8 = Manifest->ToJson();
|
|
const FString FixtureJson = UTF8_TO_TCHAR(FixtureJsonUtf8.c_str());
|
|
FString ImportReport;
|
|
if (!Settings->SetWorldAssemblyManifestJson(FixtureJson, ImportReport))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not install the in-memory hybrid fixture manifest: %s."),
|
|
*ImportReport);
|
|
return false;
|
|
}
|
|
OutManifestHash = FString::Printf(TEXT("%016llx"),
|
|
static_cast<unsigned long long>(Manifest->contentHash));
|
|
const FString OutputPath = FPaths::Combine(OutputDirectory,
|
|
TEXT("world_assembly_hybrid_fixtures.json"));
|
|
if (!FFileHelper::SaveStringToFile(
|
|
FixtureJson, *OutputPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not save the generated BuildHost fixture plan '%s'."),
|
|
*OutputPath);
|
|
return false;
|
|
}
|
|
|
|
const auto AddTrace = [&](const TCHAR* Name, double X, double Y, double ContactZ,
|
|
int32 Axis, bool bTracePositive)
|
|
{
|
|
FHybridFixtureContactProbe Probe;
|
|
Probe.Name = Name;
|
|
Probe.Expected = FVector(X * 25.0, Y * 25.0, ContactZ * 25.0);
|
|
Probe.Start = Probe.Expected;
|
|
Probe.End = Probe.Expected;
|
|
if (Axis == 2)
|
|
{
|
|
Probe.Start.Z += bTracePositive ? 100.0 : -100.0;
|
|
Probe.End.Z -= bTracePositive ? 100.0 : -100.0;
|
|
}
|
|
else
|
|
{
|
|
const double Sign = bTracePositive ? 1.0 : -1.0;
|
|
Probe.Start[Axis] += Sign * 100.0;
|
|
Probe.End[Axis] -= Sign * 100.0;
|
|
}
|
|
OutContacts.push_back(Probe);
|
|
};
|
|
const double CornerGround = static_cast<double>(GroupGroundSubvoxels[0]) / Scale;
|
|
const double DoorGround = static_cast<double>(GroupGroundSubvoxels[1]) / Scale;
|
|
const double RiserGround = static_cast<double>(GroupGroundSubvoxels[2]) / Scale;
|
|
const double LedgeGround = static_cast<double>(GroupGroundSubvoxels[3]) / Scale;
|
|
AddTrace(TEXT("corner_top"), 41.0, 166.0, CornerGround + 10.0, 2, true);
|
|
AddTrace(TEXT("doorway_lintel"), 54.0, 166.0, DoorGround + 12.0, 2, true);
|
|
AddTrace(TEXT("riser_face"), 54.0, 184.0, RiserGround + 6.0, 0, true);
|
|
AddTrace(TEXT("ledge_top"), 58.0, 184.0, LedgeGround + 10.0, 2, true);
|
|
AddTrace(TEXT("ledge_underside"), 58.0, 184.0, LedgeGround + 8.0, 2, false);
|
|
return true;
|
|
}
|
|
|
|
struct FHybridAnalyticBox
|
|
{
|
|
uint64 StableId = 0;
|
|
FVector Minimum = FVector::ZeroVector;
|
|
FVector Maximum = FVector::ZeroVector;
|
|
};
|
|
|
|
struct FHybridAnalyticFixture
|
|
{
|
|
const TCHAR* Name = TEXT("fixture");
|
|
std::vector<FHybridAnalyticBox> Boxes;
|
|
bool bCaveChamber = false;
|
|
bool bSurfaceFloor = false;
|
|
};
|
|
|
|
double HybridFixtureBoxSdf(const FVector& Point, const FVector& Minimum, const FVector& Maximum)
|
|
{
|
|
const FVector Center = (Minimum + Maximum) * 0.5;
|
|
const FVector Half = (Maximum - Minimum) * 0.5;
|
|
const FVector Delta = Point - Center;
|
|
const FVector Q(FMath::Abs(Delta.X) - Half.X,
|
|
FMath::Abs(Delta.Y) - Half.Y,
|
|
FMath::Abs(Delta.Z) - Half.Z);
|
|
const FVector Outside(FMath::Max(Q.X, 0.0), FMath::Max(Q.Y, 0.0), FMath::Max(Q.Z, 0.0));
|
|
return Outside.Size() + FMath::Min(FMath::Max(Q.X, FMath::Max(Q.Y, Q.Z)), 0.0);
|
|
}
|
|
|
|
FHybridAnalyticFixture MakeHybridAnalyticFixture(const TCHAR* Name, const FVector& Phase)
|
|
{
|
|
FHybridAnalyticFixture Fixture;
|
|
Fixture.Name = Name;
|
|
auto AddBox = [&](uint64 Id, FVector Minimum, FVector Maximum)
|
|
{
|
|
Fixture.Boxes.push_back({Id, Minimum, Maximum});
|
|
};
|
|
if (FCString::Strcmp(Name, TEXT("corner90")) == 0)
|
|
{
|
|
AddBox(0x1001, FVector(6, 6, 6), FVector(23, 10, 26));
|
|
AddBox(0x1002, FVector(6, 10, 6), FVector(10, 23, 26));
|
|
}
|
|
else if (FCString::Strcmp(Name, TEXT("doorway")) == 0)
|
|
{
|
|
AddBox(0x2001, FVector(6, 8, 6), FVector(10, 12, 26));
|
|
AddBox(0x2002, FVector(22, 8, 6), FVector(26, 12, 26));
|
|
AddBox(0x2003, FVector(10, 8, 22), FVector(22, 12, 26));
|
|
}
|
|
else if (FCString::Strcmp(Name, TEXT("riser1m")) == 0)
|
|
{
|
|
AddBox(0x3001, FVector(6, 8, 6), FVector(18, 24, 10));
|
|
AddBox(0x3002, FVector(18, 8, 10), FVector(26, 24, 14));
|
|
}
|
|
else if (FCString::Strcmp(Name, TEXT("cliff_ledge")) == 0)
|
|
{
|
|
AddBox(0x4001, FVector(6, 8, 6), FVector(10, 24, 26));
|
|
AddBox(0x4002, FVector(10, 8, 22), FVector(26, 24, 26));
|
|
}
|
|
else if (FCString::Strcmp(Name, TEXT("doorway_border")) == 0)
|
|
{
|
|
Fixture.bSurfaceFloor = true;
|
|
AddBox(0x5001, FVector(27, 12, 10), FVector(35, 18, 29));
|
|
AddBox(0x5002, FVector(39, 12, 10), FVector(45, 18, 29));
|
|
AddBox(0x5003, FVector(27, 12, 25), FVector(45, 18, 29));
|
|
}
|
|
else
|
|
{
|
|
Fixture.bCaveChamber = true;
|
|
}
|
|
for (FHybridAnalyticBox& Box : Fixture.Boxes)
|
|
{
|
|
Box.Minimum += Phase;
|
|
Box.Maximum += Phase;
|
|
const auto QuantizePlanCoordinate = [](double Value)
|
|
{ return std::round(Value * 256.0) / 256.0; };
|
|
Box.Minimum.X = QuantizePlanCoordinate(Box.Minimum.X);
|
|
Box.Minimum.Y = QuantizePlanCoordinate(Box.Minimum.Y);
|
|
Box.Minimum.Z = QuantizePlanCoordinate(Box.Minimum.Z);
|
|
Box.Maximum.X = QuantizePlanCoordinate(Box.Maximum.X);
|
|
Box.Maximum.Y = QuantizePlanCoordinate(Box.Maximum.Y);
|
|
Box.Maximum.Z = QuantizePlanCoordinate(Box.Maximum.Z);
|
|
}
|
|
return Fixture;
|
|
}
|
|
|
|
double EvaluateHybridAnalyticFixture(const FHybridAnalyticFixture& Fixture,
|
|
const FVector& Point, const FVector& Phase)
|
|
{
|
|
const FVector Local = Point - Phase;
|
|
double Density = std::numeric_limits<double>::infinity();
|
|
if (Fixture.bSurfaceFloor)
|
|
{
|
|
const double Floor = 16.0 + 2.0 * std::sin(Local.X * 0.19)
|
|
+ 1.4 * std::sin(Local.Y * 0.17);
|
|
Density = Local.Z - Floor;
|
|
}
|
|
if (Fixture.bCaveChamber)
|
|
{
|
|
Density = 8.0 - (Local - FVector(16, 16, 16)).Size();
|
|
}
|
|
for (const FHybridAnalyticBox& Box : Fixture.Boxes)
|
|
{
|
|
Density = FMath::Min(Density, HybridFixtureBoxSdf(Local,
|
|
Box.Minimum - Phase, Box.Maximum - Phase));
|
|
}
|
|
return Density;
|
|
}
|
|
|
|
bool SameHybridMeshArrays(const FVoxelMeshData& A, const FVoxelMeshData& B)
|
|
{
|
|
if (A.Vertices.Num() != B.Vertices.Num() || A.Normals.Num() != B.Normals.Num()
|
|
|| A.UVs.Num() != B.UVs.Num() || A.Colors.Num() != B.Colors.Num()
|
|
|| A.Triangles.Num() != B.Triangles.Num()
|
|
|| A.NumSurfaceVertices != B.NumSurfaceVertices
|
|
|| A.NumCeilingTriangles != B.NumCeilingTriangles
|
|
|| A.NumSkirtTriangles != B.NumSkirtTriangles)
|
|
{
|
|
return false;
|
|
}
|
|
for (int32 I = 0; I < A.Vertices.Num(); ++I)
|
|
{
|
|
if (A.Vertices[I].X != B.Vertices[I].X || A.Vertices[I].Y != B.Vertices[I].Y
|
|
|| A.Vertices[I].Z != B.Vertices[I].Z) return false;
|
|
}
|
|
for (int32 I = 0; I < A.Normals.Num(); ++I)
|
|
{
|
|
if (A.Normals[I].X != B.Normals[I].X || A.Normals[I].Y != B.Normals[I].Y
|
|
|| A.Normals[I].Z != B.Normals[I].Z) return false;
|
|
}
|
|
for (int32 I = 0; I < A.UVs.Num(); ++I)
|
|
{
|
|
if (A.UVs[I].X != B.UVs[I].X || A.UVs[I].Y != B.UVs[I].Y) return false;
|
|
}
|
|
for (int32 I = 0; I < A.Colors.Num(); ++I)
|
|
{
|
|
if (A.Colors[I] != B.Colors[I]) return false;
|
|
}
|
|
for (int32 I = 0; I < A.Triangles.Num(); ++I)
|
|
{
|
|
if (A.Triangles[I] != B.Triangles[I]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool GenerateHybridFixtureMesh(UVoxelGenerator* Generator, const FHybridAnalyticFixture& Fixture,
|
|
const FVector& Phase, const FIntVector& Origin, int32 Step, int32 Cells,
|
|
int32 HybridRecipe, FVoxelMeshData& OutMesh, double* OutMeshingMilliseconds,
|
|
FString& OutError)
|
|
{
|
|
if (Generator == nullptr || Step <= 0 || Cells <= 0)
|
|
{
|
|
OutError = TEXT("Hybrid analytic fixture has no generator or an invalid tile lattice.");
|
|
return false;
|
|
}
|
|
FVoxelSharedDensityGrid Grid;
|
|
Grid.OriginVoxels = Origin + FIntVector(-Step, -Step, -Step);
|
|
Grid.Step = Step;
|
|
Grid.Dim = Cells + 3;
|
|
Grid.Samples.SetNumUninitialized(Grid.Dim * Grid.Dim * Grid.Dim);
|
|
for (int32 Z = -1; Z <= Cells + 1; ++Z)
|
|
for (int32 Y = -1; Y <= Cells + 1; ++Y)
|
|
for (int32 X = -1; X <= Cells + 1; ++X)
|
|
{
|
|
const FVector Point(Origin.X + X * Step, Origin.Y + Y * Step, Origin.Z + Z * Step);
|
|
const int32 Index = ((Z + 1) * Grid.Dim + (Y + 1)) * Grid.Dim + (X + 1);
|
|
Grid.Samples[Index] = static_cast<float>(EvaluateHybridAnalyticFixture(Fixture, Point, Phase));
|
|
}
|
|
|
|
TArray<FVoxelHybridSharpFeatureBox> SharpFeatures;
|
|
if (HybridRecipe != 0)
|
|
{
|
|
for (const FHybridAnalyticBox& Feature : Fixture.Boxes)
|
|
{
|
|
FVoxelHybridSharpFeatureBox Box;
|
|
Box.StableId = Feature.StableId;
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
const double Low = Axis == 0 ? Feature.Minimum.X
|
|
: Axis == 1 ? Feature.Minimum.Y : Feature.Minimum.Z;
|
|
const double High = Axis == 0 ? Feature.Maximum.X
|
|
: Axis == 1 ? Feature.Maximum.Y : Feature.Maximum.Z;
|
|
Box.MinimumSubvoxels[Axis] = static_cast<int64>(std::llround(Low * 256.0));
|
|
Box.MaximumSubvoxels[Axis] = static_cast<int64>(std::llround(High * 256.0));
|
|
}
|
|
SharpFeatures.Add(Box);
|
|
}
|
|
}
|
|
|
|
UVoxelMarchingCubesMesher* Mesher = NewObject<UVoxelMarchingCubesMesher>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
if (Mesher == nullptr)
|
|
{
|
|
OutError = TEXT("Could not allocate the production mesher for an analytic fixture.");
|
|
return false;
|
|
}
|
|
Mesher->SetGenerator(Generator);
|
|
Mesher->SetGeometryVoxelSizeCm(25.0f);
|
|
Mesher->bGenerateSkirts = false;
|
|
if (HybridRecipe == 2)
|
|
Mesher->SetHybridSharpFeatureSnap(MoveTemp(SharpFeatures));
|
|
else if (HybridRecipe == 1)
|
|
Mesher->SetHybridSharpFeatures(MoveTemp(SharpFeatures));
|
|
const double StartSeconds = FPlatformTime::Seconds();
|
|
OutMesh = Mesher->GenerateMesh(Origin, Step, Cells, nullptr,
|
|
INT32_MIN, INT32_MAX, nullptr, nullptr, &Grid);
|
|
if (OutMeshingMilliseconds != nullptr)
|
|
{
|
|
*OutMeshingMilliseconds = (FPlatformTime::Seconds() - StartSeconds) * 1000.0;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
using FHybridContourSegment = std::array<int64, 4>;
|
|
using FHybridContourSet = std::set<FHybridContourSegment>;
|
|
|
|
FHybridContourSet ExtractHybridBoundaryContour(const FVoxelMeshData& Mesh, double PlaneXcm)
|
|
{
|
|
FHybridContourSet Segments;
|
|
constexpr double PlaneTolerance = 0.01;
|
|
for (int32 I = 0; I + 2 < Mesh.Triangles.Num(); I += 3)
|
|
{
|
|
const int32 Indices[3] = {Mesh.Triangles[I], Mesh.Triangles[I + 1], Mesh.Triangles[I + 2]};
|
|
for (int32 Edge = 0; Edge < 3; ++Edge)
|
|
{
|
|
const FVector& A = Mesh.Vertices[Indices[Edge]];
|
|
const FVector& B = Mesh.Vertices[Indices[(Edge + 1) % 3]];
|
|
if (FMath::Abs(A.X - PlaneXcm) > PlaneTolerance
|
|
|| FMath::Abs(B.X - PlaneXcm) > PlaneTolerance)
|
|
{
|
|
continue;
|
|
}
|
|
int64 AY = static_cast<int64>(std::llround(A.Y * 100.0));
|
|
int64 AZ = static_cast<int64>(std::llround(A.Z * 100.0));
|
|
int64 BY = static_cast<int64>(std::llround(B.Y * 100.0));
|
|
int64 BZ = static_cast<int64>(std::llround(B.Z * 100.0));
|
|
if (AY == BY && AZ == BZ) continue;
|
|
if (std::tie(BY, BZ) < std::tie(AY, AZ))
|
|
{
|
|
std::swap(AY, BY);
|
|
std::swap(AZ, BZ);
|
|
}
|
|
Segments.insert({AY, AZ, BY, BZ});
|
|
}
|
|
}
|
|
return Segments;
|
|
}
|
|
|
|
FHybridContourSet ClipHybridContourToBounds(const FHybridContourSet& Contour,
|
|
double MinYCm, double MaxYCm, double MinZCm, double MaxZCm)
|
|
{
|
|
FHybridContourSet Clipped;
|
|
for (const FHybridContourSegment& Segment : Contour)
|
|
{
|
|
const double Y0 = static_cast<double>(Segment[0]) / 100.0;
|
|
const double Z0 = static_cast<double>(Segment[1]) / 100.0;
|
|
const double Y1 = static_cast<double>(Segment[2]) / 100.0;
|
|
const double Z1 = static_cast<double>(Segment[3]) / 100.0;
|
|
const double DY = Y1 - Y0;
|
|
const double DZ = Z1 - Z0;
|
|
double T0 = 0.0;
|
|
double T1 = 1.0;
|
|
const double P[4] = {-DY, DY, -DZ, DZ};
|
|
const double Q[4] = {
|
|
Y0 - MinYCm, MaxYCm - Y0, Z0 - MinZCm, MaxZCm - Z0};
|
|
bool bVisible = true;
|
|
for (int32 Plane = 0; Plane < 4; ++Plane)
|
|
{
|
|
if (FMath::Abs(P[Plane]) <= 1.0e-12)
|
|
{
|
|
if (Q[Plane] < 0.0) { bVisible = false; break; }
|
|
continue;
|
|
}
|
|
const double T = Q[Plane] / P[Plane];
|
|
if (P[Plane] < 0.0) T0 = FMath::Max(T0, T);
|
|
else T1 = FMath::Min(T1, T);
|
|
if (T0 > T1) { bVisible = false; break; }
|
|
}
|
|
if (!bVisible) continue;
|
|
const int64 CY0 = static_cast<int64>(std::llround((Y0 + T0 * DY) * 100.0));
|
|
const int64 CZ0 = static_cast<int64>(std::llround((Z0 + T0 * DZ) * 100.0));
|
|
const int64 CY1 = static_cast<int64>(std::llround((Y0 + T1 * DY) * 100.0));
|
|
const int64 CZ1 = static_cast<int64>(std::llround((Z0 + T1 * DZ) * 100.0));
|
|
if (CY0 == CY1 && CZ0 == CZ1) continue;
|
|
if (std::tie(CY1, CZ1) < std::tie(CY0, CZ0))
|
|
Clipped.insert({CY1, CZ1, CY0, CZ0});
|
|
else
|
|
Clipped.insert({CY0, CZ0, CY1, CZ1});
|
|
}
|
|
return Clipped;
|
|
}
|
|
|
|
double HybridPointToSegmentCm(double Y, double Z, const FHybridContourSegment& Segment)
|
|
{
|
|
const double AY = static_cast<double>(Segment[0]) / 100.0;
|
|
const double AZ = static_cast<double>(Segment[1]) / 100.0;
|
|
const double BY = static_cast<double>(Segment[2]) / 100.0;
|
|
const double BZ = static_cast<double>(Segment[3]) / 100.0;
|
|
const double DY = BY - AY, DZ = BZ - AZ;
|
|
const double Denominator = DY * DY + DZ * DZ;
|
|
const double T = Denominator > 1.0e-12
|
|
? FMath::Clamp(((Y - AY) * DY + (Z - AZ) * DZ) / Denominator, 0.0, 1.0) : 0.0;
|
|
return FMath::Sqrt(FMath::Square(Y - (AY + T * DY)) + FMath::Square(Z - (AZ + T * DZ)));
|
|
}
|
|
|
|
double HybridContourSetMismatchCm(const FHybridContourSet& A, const FHybridContourSet& B,
|
|
int32& OutOverBudgetProbes)
|
|
{
|
|
constexpr double BudgetCm = 50.0;
|
|
double Maximum = 0.0;
|
|
OutOverBudgetProbes = 0;
|
|
if (A.empty() != B.empty()) return std::numeric_limits<double>::infinity();
|
|
const auto Measure = [&](const FHybridContourSet& Source, const FHybridContourSet& Target)
|
|
{
|
|
for (const FHybridContourSegment& Segment : Source)
|
|
for (int32 Endpoint = 0; Endpoint < 2; ++Endpoint)
|
|
{
|
|
const double Y = static_cast<double>(Segment[Endpoint * 2]) / 100.0;
|
|
const double Z = static_cast<double>(Segment[Endpoint * 2 + 1]) / 100.0;
|
|
double Nearest = std::numeric_limits<double>::infinity();
|
|
for (const FHybridContourSegment& Candidate : Target)
|
|
Nearest = FMath::Min(Nearest, HybridPointToSegmentCm(Y, Z, Candidate));
|
|
Maximum = FMath::Max(Maximum, Nearest);
|
|
if (Nearest > BudgetCm) ++OutOverBudgetProbes;
|
|
}
|
|
};
|
|
Measure(A, B);
|
|
Measure(B, A);
|
|
return Maximum;
|
|
}
|
|
|
|
bool RunHybridPluginMesherFixtures(UVoxelGenerator* Generator, bool bIncludeV2,
|
|
FString& OutError)
|
|
{
|
|
const FVector Phase(0.27, 0.41, 0.33);
|
|
const TCHAR* FixtureNames[] = {
|
|
TEXT("corner90"), TEXT("doorway"), TEXT("riser1m"),
|
|
TEXT("cliff_ledge"), TEXT("organic_cave_chamber")};
|
|
int32 SharpDifferences = 0;
|
|
for (const TCHAR* FixtureName : FixtureNames)
|
|
{
|
|
const FHybridAnalyticFixture Fixture = MakeHybridAnalyticFixture(FixtureName, Phase);
|
|
std::vector<double> McTimes, HybridTimes;
|
|
FVoxelMeshData McMesh, HybridMesh, RepeatedHybridMesh;
|
|
for (int32 Repeat = 0; Repeat < 3; ++Repeat)
|
|
{
|
|
double ElapsedMs = 0.0;
|
|
if (!GenerateHybridFixtureMesh(Generator, Fixture, Phase, FIntVector::ZeroValue,
|
|
1, CHUNK_SIZE, false, McMesh, &ElapsedMs, OutError)) return false;
|
|
McTimes.push_back(ElapsedMs);
|
|
if (!GenerateHybridFixtureMesh(Generator, Fixture, Phase, FIntVector::ZeroValue,
|
|
1, CHUNK_SIZE, true, HybridMesh, &ElapsedMs, OutError)) return false;
|
|
HybridTimes.push_back(ElapsedMs);
|
|
if (Repeat == 0) RepeatedHybridMesh = HybridMesh;
|
|
}
|
|
const bool bStable = SameHybridMeshArrays(RepeatedHybridMesh, HybridMesh);
|
|
const bool bExpectedEqual = Fixture.Boxes.empty();
|
|
const bool bMatchesExpected = bExpectedEqual
|
|
? SameHybridMeshArrays(McMesh, HybridMesh)
|
|
: !SameHybridMeshArrays(McMesh, HybridMesh);
|
|
if (!bStable || !bMatchesExpected)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Production hybrid fixture failed: %s stable=%d expected_mc_identity=%d mc_vertices=%d hybrid_vertices=%d mc_triangles=%d hybrid_triangles=%d invoked=%d features=%u active_roots=%u feature_root_samples=%u near_roots=%u min_root_sdf=%.9f tagged_roots=%u sharp_cells=%u dual_cells=%u dc_triangles=%u border_fallback=%u."),
|
|
FixtureName, bStable ? 1 : 0, bExpectedEqual ? 1 : 0,
|
|
McMesh.Vertices.Num(), HybridMesh.Vertices.Num(),
|
|
McMesh.Triangles.Num() / 3, HybridMesh.Triangles.Num() / 3,
|
|
HybridMesh.bHybridMesherInvoked ? 1 : 0, HybridMesh.HybridFeatureCount,
|
|
HybridMesh.HybridActiveEdgeRootCount,
|
|
HybridMesh.HybridFeatureRootSampleCount,
|
|
HybridMesh.HybridNearFeatureRootSampleCount,
|
|
HybridMesh.HybridMinimumFeatureRootAbsSdf,
|
|
HybridMesh.HybridTaggedEdgeRootCount,
|
|
HybridMesh.HybridSharpCellCount, HybridMesh.HybridDualCellCount,
|
|
HybridMesh.HybridDCTriangleCount, HybridMesh.HybridBorderFallbackPatchCount);
|
|
return false;
|
|
}
|
|
if (!bExpectedEqual) ++SharpDifferences;
|
|
std::sort(McTimes.begin(), McTimes.end());
|
|
std::sort(HybridTimes.begin(), HybridTimes.end());
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridPluginFixture] name=%s lod=0 cells=%d mc_ms=%.3f hybrid_ms=%.3f ratio=%.3f mc_triangles=%d hybrid_triangles=%d exact_repeat=1"),
|
|
FixtureName, CHUNK_SIZE, McTimes[1], HybridTimes[1],
|
|
McTimes[1] > 0.0 ? HybridTimes[1] / McTimes[1] : 0.0,
|
|
McMesh.Triangles.Num() / 3, HybridMesh.Triangles.Num() / 3);
|
|
}
|
|
if (SharpDifferences != 4)
|
|
{
|
|
OutError = FString::Printf(TEXT("Expected four analytic sharp fixtures to exercise DC; got %d."),
|
|
SharpDifferences);
|
|
return false;
|
|
}
|
|
|
|
if (bIncludeV2)
|
|
{
|
|
int32 SnapSharpDifferences = 0;
|
|
for (const TCHAR* FixtureName : FixtureNames)
|
|
{
|
|
const FHybridAnalyticFixture Fixture = MakeHybridAnalyticFixture(FixtureName, Phase);
|
|
std::vector<double> McTimes, SnapTimes;
|
|
FVoxelMeshData McMesh, SnapMesh, RepeatedSnapMesh;
|
|
for (int32 Repeat = 0; Repeat < 3; ++Repeat)
|
|
{
|
|
double ElapsedMs = 0.0;
|
|
if (!GenerateHybridFixtureMesh(Generator, Fixture, Phase, FIntVector::ZeroValue,
|
|
1, CHUNK_SIZE, 0, McMesh, &ElapsedMs, OutError)) return false;
|
|
McTimes.push_back(ElapsedMs);
|
|
if (!GenerateHybridFixtureMesh(Generator, Fixture, Phase, FIntVector::ZeroValue,
|
|
1, CHUNK_SIZE, 2, SnapMesh, &ElapsedMs, OutError)) return false;
|
|
SnapTimes.push_back(ElapsedMs);
|
|
if (Repeat == 0) RepeatedSnapMesh = SnapMesh;
|
|
}
|
|
const bool bExpectedEqual = Fixture.Boxes.empty();
|
|
const bool bStable = SameHybridMeshArrays(RepeatedSnapMesh, SnapMesh);
|
|
const bool bMatchesExpected = bExpectedEqual
|
|
? SameHybridMeshArrays(McMesh, SnapMesh)
|
|
: !SameHybridMeshArrays(McMesh, SnapMesh);
|
|
const bool bInvocationMatchesExpected = bExpectedEqual
|
|
? !SnapMesh.bHybridMesherInvoked : SnapMesh.bHybridMesherInvoked;
|
|
if (!bStable || !bMatchesExpected || !bInvocationMatchesExpected)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Production hybrid V2 fixture failed: %s stable=%d expected_mc_identity=%d invoked=%d."),
|
|
FixtureName, bStable ? 1 : 0, bExpectedEqual ? 1 : 0,
|
|
SnapMesh.bHybridMesherInvoked ? 1 : 0);
|
|
return false;
|
|
}
|
|
if (!bExpectedEqual) ++SnapSharpDifferences;
|
|
std::sort(McTimes.begin(), McTimes.end());
|
|
std::sort(SnapTimes.begin(), SnapTimes.end());
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridPluginFixtureV2] name=%s lod=0 cells=%d mc_ms=%.3f hybrid_ms=%.3f ratio=%.3f mc_triangles=%d hybrid_triangles=%d exact_repeat=1"),
|
|
FixtureName, CHUNK_SIZE, McTimes[1], SnapTimes[1],
|
|
McTimes[1] > 0.0 ? SnapTimes[1] / McTimes[1] : 0.0,
|
|
McMesh.Triangles.Num() / 3, SnapMesh.Triangles.Num() / 3);
|
|
}
|
|
if (SnapSharpDifferences != 4)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Expected four analytic sharp fixtures to exercise hybrid V2; got %d."),
|
|
SnapSharpDifferences);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Coarse LOD uses MC regardless of the selected recipe, so its full arrays must match.
|
|
// Le LOD grossier reste sur MC, meme si la recette hybride est activee.
|
|
const FHybridAnalyticFixture CoarseFixture = MakeHybridAnalyticFixture(TEXT("doorway"), Phase);
|
|
FVoxelMeshData CoarseMc, CoarseHybrid;
|
|
if (!GenerateHybridFixtureMesh(Generator, CoarseFixture, Phase, FIntVector::ZeroValue,
|
|
2, CHUNK_SIZE, false, CoarseMc, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, CoarseFixture, Phase, FIntVector::ZeroValue,
|
|
2, CHUNK_SIZE, true, CoarseHybrid, nullptr, OutError)) return false;
|
|
if (!SameHybridMeshArrays(CoarseMc, CoarseHybrid))
|
|
{
|
|
OutError = TEXT("Hybrid altered a coarse LOD mesh; DC must remain LOD0-only.");
|
|
return false;
|
|
}
|
|
if (bIncludeV2)
|
|
{
|
|
FVoxelMeshData CoarseSnap;
|
|
if (!GenerateHybridFixtureMesh(Generator, CoarseFixture, Phase, FIntVector::ZeroValue,
|
|
2, CHUNK_SIZE, 2, CoarseSnap, nullptr, OutError)
|
|
|| !SameHybridMeshArrays(CoarseMc, CoarseSnap))
|
|
{
|
|
OutError = TEXT("Hybrid V2 altered a coarse LOD mesh; snapping must remain LOD0-only.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Independent same-LOD neighbors share the exact sampled contour at their common face.
|
|
// Des voisins LOD identique independants doivent partager exactement leur contour commun.
|
|
const FVector BorderPhase(0.23, 0.37, 0.19);
|
|
const FHybridAnalyticFixture BorderFixture =
|
|
MakeHybridAnalyticFixture(TEXT("doorway_border"), BorderPhase);
|
|
FVoxelMeshData McLeft, McRight, HybridLeft, HybridRight;
|
|
if (!GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(0, 0, 0),
|
|
1, CHUNK_SIZE, false, McLeft, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(32, 0, 0),
|
|
1, CHUNK_SIZE, false, McRight, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(0, 0, 0),
|
|
1, CHUNK_SIZE, true, HybridLeft, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(32, 0, 0),
|
|
1, CHUNK_SIZE, true, HybridRight, nullptr, OutError)) return false;
|
|
const FHybridContourSet McLeftBorder = ExtractHybridBoundaryContour(McLeft, 800.0);
|
|
const FHybridContourSet McRightBorder = ExtractHybridBoundaryContour(McRight, 800.0);
|
|
const FHybridContourSet HybridLeftBorder = ExtractHybridBoundaryContour(HybridLeft, 800.0);
|
|
const FHybridContourSet HybridRightBorder = ExtractHybridBoundaryContour(HybridRight, 800.0);
|
|
if (McLeftBorder.empty() || McLeftBorder != McRightBorder
|
|
|| HybridLeftBorder.empty() || HybridLeftBorder != HybridRightBorder
|
|
|| !SameHybridMeshArrays(McLeft, HybridLeft)
|
|
|| !SameHybridMeshArrays(McRight, HybridRight))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Plugin same-LOD border failed: MC segments=%d/%d hybrid=%d/%d."),
|
|
static_cast<int32>(McLeftBorder.size()), static_cast<int32>(McRightBorder.size()),
|
|
static_cast<int32>(HybridLeftBorder.size()), static_cast<int32>(HybridRightBorder.size()));
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridPluginBorder] same_lod=PASS independent=1 mc_segments=%d hybrid_segments=%d border_fallback_mc_identity=1"),
|
|
static_cast<int32>(McLeftBorder.size()), static_cast<int32>(HybridLeftBorder.size()));
|
|
if (bIncludeV2)
|
|
{
|
|
FVoxelMeshData SnapLeft, SnapRight;
|
|
if (!GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(0, 0, 0),
|
|
1, CHUNK_SIZE, 2, SnapLeft, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, BorderFixture, BorderPhase, FIntVector(32, 0, 0),
|
|
1, CHUNK_SIZE, 2, SnapRight, nullptr, OutError)
|
|
|| ExtractHybridBoundaryContour(SnapLeft, 800.0) != McLeftBorder
|
|
|| ExtractHybridBoundaryContour(SnapRight, 800.0) != McRightBorder
|
|
|| ExtractHybridBoundaryContour(SnapLeft, 800.0)
|
|
!= ExtractHybridBoundaryContour(SnapRight, 800.0))
|
|
{
|
|
OutError = TEXT("Hybrid V2 changed an interior-only same-LOD border fixture.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridPluginBorderV2] same_lod=PASS independent=1 mc_identity=1"));
|
|
}
|
|
|
|
// Compare the raw doorway contours at the 2:1 seam over all 125 sub-cell phases.
|
|
// Comparer les contours bruts de la porte sur la couture 2:1 pour 125 phases.
|
|
int32 LodOverBudgetCases = 0;
|
|
int32 LodUnmatchedProbes = 0;
|
|
double LodWorstMismatchCm = 0.0;
|
|
constexpr double Phases[5] = {0.0, 0.2, 0.4, 0.6, 0.8};
|
|
const auto ContourBoundsText = [](const FHybridContourSet& Contour)
|
|
{
|
|
if (Contour.empty()) return FString(TEXT("empty"));
|
|
double MinY = std::numeric_limits<double>::infinity();
|
|
double MaxY = -std::numeric_limits<double>::infinity();
|
|
double MinZ = std::numeric_limits<double>::infinity();
|
|
double MaxZ = -std::numeric_limits<double>::infinity();
|
|
for (const FHybridContourSegment& Segment : Contour)
|
|
{
|
|
MinY = FMath::Min(MinY, static_cast<double>(FMath::Min(Segment[0], Segment[2])) / 100.0);
|
|
MaxY = FMath::Max(MaxY, static_cast<double>(FMath::Max(Segment[0], Segment[2])) / 100.0);
|
|
MinZ = FMath::Min(MinZ, static_cast<double>(FMath::Min(Segment[1], Segment[3])) / 100.0);
|
|
MaxZ = FMath::Max(MaxZ, static_cast<double>(FMath::Max(Segment[1], Segment[3])) / 100.0);
|
|
}
|
|
return FString::Printf(TEXT("(y=%.2f..%.2f,z=%.2f..%.2f)cm"), MinY, MaxY, MinZ, MaxZ);
|
|
};
|
|
for (double PhaseX : Phases)
|
|
for (double PhaseY : Phases)
|
|
for (double PhaseZ : Phases)
|
|
{
|
|
const FVector SweepPhase(PhaseX, PhaseY, PhaseZ);
|
|
const FHybridAnalyticFixture Doorway =
|
|
MakeHybridAnalyticFixture(TEXT("doorway_border"), SweepPhase);
|
|
FVoxelMeshData FineMc, CoarseMcForPhase, FineHybrid;
|
|
if (!GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(0, 0, 0),
|
|
1, CHUNK_SIZE, false, FineMc, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(32, 0, 0),
|
|
2, CHUNK_SIZE, false, CoarseMcForPhase, nullptr, OutError)
|
|
|| !GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(0, 0, 0),
|
|
1, CHUNK_SIZE, true, FineHybrid, nullptr, OutError)) return false;
|
|
if (!SameHybridMeshArrays(FineMc, FineHybrid))
|
|
{
|
|
OutError = TEXT("LOD0 doorway cells touching the LOD border did not fall back to MC.");
|
|
return false;
|
|
}
|
|
const FHybridContourSet FineContour = ExtractHybridBoundaryContour(FineMc, 800.0);
|
|
const FHybridContourSet CoarseContour = ClipHybridContourToBounds(
|
|
ExtractHybridBoundaryContour(CoarseMcForPhase, 800.0), 0.0, 800.0, 0.0, 800.0);
|
|
if (FineContour.empty() || CoarseContour.empty())
|
|
{
|
|
OutError = TEXT("The production doorway border probe did not produce a contour on both LODs.");
|
|
return false;
|
|
}
|
|
if (bIncludeV2)
|
|
{
|
|
FVoxelMeshData FineSnap;
|
|
if (!GenerateHybridFixtureMesh(Generator, Doorway, SweepPhase, FIntVector(0, 0, 0),
|
|
1, CHUNK_SIZE, 2, FineSnap, nullptr, OutError)
|
|
|| ExtractHybridBoundaryContour(FineSnap, 800.0) != FineContour)
|
|
{
|
|
OutError = TEXT("Hybrid V2 changed a raw LOD border contour in the 125-phase doorway sweep.");
|
|
return false;
|
|
}
|
|
}
|
|
int32 OverBudgetProbes = 0;
|
|
const double MismatchCm = HybridContourSetMismatchCm(
|
|
FineContour, CoarseContour, OverBudgetProbes);
|
|
if (PhaseX == 0.0 && PhaseY == 0.0 && PhaseZ == 0.0)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridPluginBorderProbe] phase=(0,0,0) plane_x_cm=800 fine_segments=%d fine_bounds=%s coarse_overlap_segments=%d coarse_overlap_bounds=%s mismatch_cm=%.3f"),
|
|
static_cast<int32>(FineContour.size()), *ContourBoundsText(FineContour),
|
|
static_cast<int32>(CoarseContour.size()), *ContourBoundsText(CoarseContour),
|
|
MismatchCm);
|
|
}
|
|
if (!FMath::IsFinite(MismatchCm))
|
|
{
|
|
OutError = TEXT("The production doorway test found an empty fine/coarse border contour.");
|
|
return false;
|
|
}
|
|
LodWorstMismatchCm = FMath::Max(LodWorstMismatchCm, MismatchCm);
|
|
LodUnmatchedProbes += OverBudgetProbes;
|
|
if (OverBudgetProbes > 0) ++LodOverBudgetCases;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridPluginBorder] lod0_lod1_doorway=1 cases=125 over_50cm_cases=%d over_50cm_probes=%d worst_contour_mismatch_cm=%.3f fine_border_hybrid_mc_identity=1 coarse_recipe=MC skirts_in_raw_probe=0"),
|
|
LodOverBudgetCases, LodUnmatchedProbes, LodWorstMismatchCm);
|
|
if (LodOverBudgetCases > 0)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Production MC doorway contour exceeded the 50 cm border budget in %d/125 phases (worst %.3f cm)."),
|
|
LodOverBudgetCases, LodWorstMismatchCm);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool CreateLockedLayoutSettingsCopy(
|
|
UVoxelSettings* SourceSettings,
|
|
UVoxelStrateManager* SourceManager,
|
|
int32 Seed,
|
|
bool bConvertSurfaceToFloatingIslands,
|
|
TStrongObjectPtr<UVoxelSettings>& OutSettings,
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>>& OutDefinitions,
|
|
int32& OutTargetStrateIndex,
|
|
FString& OutError)
|
|
{
|
|
if (SourceSettings == nullptr || SourceManager == nullptr || SourceManager->GetLayout().IsEmpty())
|
|
{
|
|
OutError = TEXT("Could not copy an empty resolved owner layout for the Core test.");
|
|
return false;
|
|
}
|
|
UVoxelSettings* SettingsCopy = DuplicateObject<UVoxelSettings>(
|
|
SourceSettings, GetTransientPackage(), NAME_None);
|
|
if (SettingsCopy == nullptr)
|
|
{
|
|
OutError = TEXT("Could not duplicate owner settings for the transient Core test.");
|
|
return false;
|
|
}
|
|
OutSettings = TStrongObjectPtr<UVoxelSettings>(SettingsCopy);
|
|
SettingsCopy->Season.Reset();
|
|
SettingsCopy->Seed = Seed;
|
|
SettingsCopy->TotalStrates = SourceManager->GetLayout().Num();
|
|
SettingsCopy->StratePool.Reset();
|
|
SettingsCopy->FixedStrates.Reset();
|
|
SettingsCopy->WorldAssemblyManifestJson.Reset();
|
|
SettingsCopy->WorldAssemblyManifestHash.Reset();
|
|
SettingsCopy->bUseWorldAssemblyManifestOverride = false;
|
|
OutDefinitions.Reset();
|
|
OutTargetStrateIndex = INDEX_NONE;
|
|
|
|
int32 ResolvedGap = SourceSettings->InterStrateGapChunks;
|
|
if (SourceManager->GetLayout().Num() > 1 && !SourceSettings->Season.IsNull())
|
|
{
|
|
ResolvedGap = SourceManager->GetLayout()[0].BottomChunkZ
|
|
- SourceManager->GetLayout()[1].TopChunkZ - 1;
|
|
for (int32 Index = 1; Index + 1 < SourceManager->GetLayout().Num(); ++Index)
|
|
{
|
|
const int32 Gap = SourceManager->GetLayout()[Index].BottomChunkZ
|
|
- SourceManager->GetLayout()[Index + 1].TopChunkZ - 1;
|
|
if (Gap != ResolvedGap)
|
|
{
|
|
OutError = TEXT("The resolved season uses non-uniform strate gaps; the transient copy cannot preserve it.");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
SettingsCopy->InterStrateGapChunks = FMath::Max(0, ResolvedGap);
|
|
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> DefinitionReferences;
|
|
DefinitionReferences.Reserve(SourceManager->GetLayout().Num());
|
|
TMap<int32, UVoxelStrateDefinition*> DefinitionCopies;
|
|
for (const FStrateSlot& Slot : SourceManager->GetLayout())
|
|
{
|
|
if (Slot.Definition == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Owner layout slot %d has no definition to copy."), Slot.StrateIndex);
|
|
return false;
|
|
}
|
|
UVoxelStrateDefinition* DefinitionCopy = DuplicateObject<UVoxelStrateDefinition>(
|
|
Slot.Definition, SettingsCopy, FName(*FString::Printf(TEXT("CoreTest_Strate_%d"), Slot.StrateIndex)));
|
|
if (DefinitionCopy == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not duplicate owner strate definition %d."), Slot.StrateIndex);
|
|
return false;
|
|
}
|
|
if (bConvertSurfaceToFloatingIslands
|
|
&& OutTargetStrateIndex == INDEX_NONE
|
|
&& Slot.Definition->GeneratorType == ECaveGeneratorType::SurfaceWorld)
|
|
{
|
|
OutTargetStrateIndex = Slot.StrateIndex;
|
|
DefinitionCopy->GeneratorType = ECaveGeneratorType::FloatingIslands;
|
|
}
|
|
DefinitionCopies.Add(Slot.StrateIndex, DefinitionCopy);
|
|
SettingsCopy->FixedStrates.Add(Slot.StrateIndex,
|
|
TSoftObjectPtr<UVoxelStrateDefinition>(DefinitionCopy));
|
|
DefinitionReferences.Add(TStrongObjectPtr<UVoxelStrateDefinition>(DefinitionCopy));
|
|
}
|
|
if (bConvertSurfaceToFloatingIslands && OutTargetStrateIndex == INDEX_NONE)
|
|
{
|
|
OutError = TEXT("The owner layout has no SurfaceWorld strate to convert for the island proof.");
|
|
return false;
|
|
}
|
|
if (!bConvertSurfaceToFloatingIslands) OutTargetStrateIndex = INDEX_NONE;
|
|
|
|
const FStrateSlot* TargetSlot = SourceManager->GetLayout().FindByPredicate(
|
|
[OutTargetStrateIndex](const FStrateSlot& Slot)
|
|
{ return Slot.StrateIndex == OutTargetStrateIndex; });
|
|
if (TargetSlot != nullptr)
|
|
{
|
|
UVoxelStrateDefinition* TargetDefinition = DefinitionCopies.FindRef(OutTargetStrateIndex);
|
|
if (TargetDefinition == nullptr)
|
|
{
|
|
OutError = TEXT("The transient SurfaceWorld definition could not be resolved.");
|
|
return false;
|
|
}
|
|
bool bFixedWaterEnabled = false;
|
|
float FixedWaterRelative = 0.0f;
|
|
const int32 MidChunkZ = TargetSlot->BottomChunkZ
|
|
+ (TargetSlot->TopChunkZ - TargetSlot->BottomChunkZ) / 2;
|
|
const float WaterVoxelZ = SourceManager->GetWaterLevelWorldZForChunk(
|
|
FIntVector(0, 0, MidChunkZ));
|
|
if (WaterVoxelZ != -FLT_MAX)
|
|
{
|
|
const float TopVoxelZ = (static_cast<float>(TargetSlot->TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomVoxelZ = static_cast<float>(TargetSlot->BottomChunkZ) * CHUNK_SIZE;
|
|
FixedWaterRelative = FMath::Clamp(
|
|
(WaterVoxelZ - BottomVoxelZ) / FMath::Max(TopVoxelZ - BottomVoxelZ, 1.0f),
|
|
0.0f, 1.0f);
|
|
bFixedWaterEnabled = true;
|
|
}
|
|
TargetDefinition->bHasWater = bFixedWaterEnabled;
|
|
TargetDefinition->GenerationParams.WaterLevelRelative =
|
|
bFixedWaterEnabled ? FixedWaterRelative : 0.0f;
|
|
}
|
|
|
|
OutDefinitions = MoveTemp(DefinitionReferences);
|
|
TStrongObjectPtr<UVoxelStrateManager> VerificationManager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!VerificationManager.IsValid() || !VerificationManager->Initialize(SettingsCopy, Seed)
|
|
|| VerificationManager->GetLayout().Num() != SourceManager->GetLayout().Num())
|
|
{
|
|
OutError = TEXT("The locked transient settings copy did not resolve the owner's full strate layout.");
|
|
return false;
|
|
}
|
|
for (int32 Index = 0; Index < SourceManager->GetLayout().Num(); ++Index)
|
|
{
|
|
const FStrateSlot& SourceSlot = SourceManager->GetLayout()[Index];
|
|
const FStrateSlot& CopySlot = VerificationManager->GetLayout()[Index];
|
|
if (SourceSlot.StrateIndex != CopySlot.StrateIndex
|
|
|| SourceSlot.TopChunkZ != CopySlot.TopChunkZ
|
|
|| SourceSlot.BottomChunkZ != CopySlot.BottomChunkZ
|
|
|| SourceSlot.HeightInChunks != CopySlot.HeightInChunks)
|
|
{
|
|
OutError = FString::Printf(TEXT("Transient layout copy changed owner heights at slot %d."), Index);
|
|
return false;
|
|
}
|
|
const ECaveGeneratorType ExpectedType = bConvertSurfaceToFloatingIslands
|
|
&& CopySlot.StrateIndex == OutTargetStrateIndex
|
|
? ECaveGeneratorType::FloatingIslands : SourceSlot.Definition->GeneratorType;
|
|
if (CopySlot.Definition == nullptr || CopySlot.Definition->GeneratorType != ExpectedType)
|
|
{
|
|
OutError = FString::Printf(TEXT("Transient layout copy changed an unintended archetype at slot %d."), Index);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
struct FCoreTestWorldFixture
|
|
{
|
|
UWorld* World = nullptr;
|
|
uint64 PreviousFrameCounter = GFrameCounter;
|
|
|
|
bool Initialize(const FName& WorldName, FString& OutError)
|
|
{
|
|
const UWorld::InitializationValues Values = UWorld::InitializationValues()
|
|
.AllowAudioPlayback(false).RequiresHitProxies(false).CreatePhysicsScene(true)
|
|
.CreateNavigation(false).CreateAISystem(false).ShouldSimulatePhysics(true)
|
|
.SetDefaultGameMode(AGameModeBase::StaticClass()).SetTransactional(false);
|
|
World = UWorld::CreateWorld(EWorldType::Game, false, WorldName,
|
|
GetTransientPackage(), false, ERHIFeatureLevel::Num, &Values);
|
|
if (World == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create a transient BuildHost Core test world.");
|
|
return false;
|
|
}
|
|
if (GEngine == nullptr)
|
|
{
|
|
OutError = TEXT("Unreal Engine is not available for the BuildHost Core test world.");
|
|
return false;
|
|
}
|
|
UGameInstance* GameInstance = NewObject<UGameInstance>(GEngine);
|
|
if (GameInstance == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the BuildHost Core test game instance.");
|
|
return false;
|
|
}
|
|
FWorldContext& Context = GEngine->CreateNewWorldContext(EWorldType::Game);
|
|
Context.OwningGameInstance = GameInstance;
|
|
Context.SetCurrentWorld(World);
|
|
World->SetGameInstance(GameInstance);
|
|
World->SetShouldTick(false);
|
|
GameInstance->Init();
|
|
World->AddToRoot();
|
|
return true;
|
|
}
|
|
|
|
~FCoreTestWorldFixture()
|
|
{
|
|
if (World != nullptr)
|
|
{
|
|
if (World->HasBegunPlay())
|
|
{
|
|
World->BeginTearingDown();
|
|
World->EndPlay(EEndPlayReason::Quit);
|
|
}
|
|
if (UGameInstance* GameInstance = World->GetGameInstance()) GameInstance->Shutdown();
|
|
if (GEngine != nullptr) GEngine->DestroyWorldContext(World);
|
|
World->DestroyWorld(false);
|
|
if (World->IsRooted()) World->RemoveFromRoot();
|
|
}
|
|
GFrameCounter = PreviousFrameCounter;
|
|
}
|
|
};
|
|
|
|
int32 ResolveCoreTestWorldSeed(const UVoxelSettings* Settings)
|
|
{
|
|
if (Settings == nullptr) return 0;
|
|
if (!Settings->Season.IsNull())
|
|
{
|
|
if (const UVoxelSeasonAsset* Season = Settings->Season.LoadSynchronous())
|
|
{
|
|
return Season->GetSeasonSeed();
|
|
}
|
|
}
|
|
return Settings->Seed;
|
|
}
|
|
|
|
bool CreateLoggedOwnerSurfaceSettingsCopy(
|
|
UVoxelSettings* Source, int32 Seed,
|
|
TStrongObjectPtr<UVoxelSettings>& OutSettings,
|
|
TStrongObjectPtr<UVoxelStrateDefinition>& OutSurfaceDefinition,
|
|
FString& OutError, bool bCompactTracePlan = false)
|
|
{
|
|
if (Source == nullptr)
|
|
{
|
|
OutError = TEXT("Cannot reconstruct the logged owner SurfaceWorld layout without its settings asset.");
|
|
return false;
|
|
}
|
|
if (!Source->Season.IsNull())
|
|
{
|
|
OutError = TEXT("The staged owner settings assign a season asset; the logged one-slot SurfaceWorld layout cannot be reconstructed while preserving that season.");
|
|
return false;
|
|
}
|
|
UVoxelStrateDefinition* SurfaceDefinition = LoadObject<UVoxelStrateDefinition>(
|
|
nullptr, TEXT("/Game/VoxelForge/DA_Strate1.DA_Strate1"));
|
|
if (SurfaceDefinition == nullptr
|
|
|| SurfaceDefinition->GeneratorType != ECaveGeneratorType::SurfaceWorld
|
|
|| SurfaceDefinition->StrateHeightInChunks <= 0)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("The staged owner assets do not contain a usable DA_Strate1 SurfaceWorld (definition_loaded=%d type=%d height=%d settings_strates=%d)."),
|
|
SurfaceDefinition != nullptr ? 1 : 0,
|
|
SurfaceDefinition != nullptr ? static_cast<int32>(SurfaceDefinition->GeneratorType) : INDEX_NONE,
|
|
SurfaceDefinition != nullptr ? SurfaceDefinition->StrateHeightInChunks : INDEX_NONE,
|
|
Source->TotalStrates);
|
|
return false;
|
|
}
|
|
UVoxelSettings* SettingsCopy = DuplicateObject<UVoxelSettings>(
|
|
Source, GetTransientPackage(), NAME_None);
|
|
if (SettingsCopy == nullptr)
|
|
{
|
|
OutError = TEXT("Could not duplicate owner settings for the logged SurfaceWorld layout.");
|
|
return false;
|
|
}
|
|
OutSettings = TStrongObjectPtr<UVoxelSettings>(SettingsCopy);
|
|
if (bCompactTracePlan)
|
|
{
|
|
SurfaceDefinition = DuplicateObject<UVoxelStrateDefinition>(SurfaceDefinition,
|
|
GetTransientPackage(), TEXT("VoxelForgeCompactSurfaceTraceDefinition"));
|
|
if (SurfaceDefinition == nullptr)
|
|
{
|
|
OutError = TEXT("Could not make a transient DA_Strate1 copy for the compact common-plan trace.");
|
|
return false;
|
|
}
|
|
// L14's historical fixed-length SurfaceWorld route search cannot place the
|
|
// new trace fixture even at 16 chunks. Use the same simple, walkable FlatPlain
|
|
// plan in both host revisions so the streaming comparison reaches AVoxelWorld.
|
|
SurfaceDefinition->GeneratorType = ECaveGeneratorType::FlatPlain;
|
|
SurfaceDefinition->StrateHeightInChunks = 16;
|
|
}
|
|
OutSurfaceDefinition = TStrongObjectPtr<UVoxelStrateDefinition>(SurfaceDefinition);
|
|
SettingsCopy->Seed = Seed;
|
|
SettingsCopy->TotalStrates = 1;
|
|
SettingsCopy->StratePool.Reset();
|
|
SettingsCopy->FixedStrates.Reset();
|
|
SettingsCopy->FixedStrates.Add(0,
|
|
TSoftObjectPtr<UVoxelStrateDefinition>(SurfaceDefinition));
|
|
SettingsCopy->WorldAssemblyManifestJson.Reset();
|
|
SettingsCopy->WorldAssemblyManifestHash.Reset();
|
|
SettingsCopy->bUseWorldAssemblyManifestOverride = false;
|
|
const int32 BottomChunkZ = 1 - SurfaceDefinition->StrateHeightInChunks;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreTestLayout] source=owner_asset_transient_copy definition=DA_Strate1 generator=%d seed=%d strata=1 bounds=[0,%d] height_chunks=%d compact_trace=%d gap=%d imported_override=cleared"),
|
|
static_cast<int32>(SurfaceDefinition->GeneratorType), Seed, BottomChunkZ,
|
|
SurfaceDefinition->StrateHeightInChunks,
|
|
bCompactTracePlan ? 1 : 0, SettingsCopy->InterStrateGapChunks);
|
|
return true;
|
|
}
|
|
|
|
bool RunCorePlanOnlyTest(const FExploreArguments& Arguments, FString& OutError)
|
|
{
|
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
|
nullptr, *Arguments.SettingsReference);
|
|
if (AuthoredSettings == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not load Core plan test settings '%s'."),
|
|
*Arguments.SettingsReference);
|
|
return false;
|
|
}
|
|
const int32 AuthoredSeed = ResolveCoreTestWorldSeed(AuthoredSettings);
|
|
TStrongObjectPtr<UVoxelSettings> OwnerSurfaceSettings;
|
|
TStrongObjectPtr<UVoxelStrateDefinition> OwnerSurfaceDefinition;
|
|
if (!CreateLoggedOwnerSurfaceSettingsCopy(AuthoredSettings, AuthoredSeed,
|
|
OwnerSurfaceSettings, OwnerSurfaceDefinition, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
TStrongObjectPtr<UVoxelStrateManager> SourceManager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!SourceManager.IsValid() || !SourceManager->Initialize(OwnerSurfaceSettings.Get(), AuthoredSeed))
|
|
{
|
|
OutError = TEXT("Core plan test could not resolve the staged owner settings.");
|
|
return false;
|
|
}
|
|
|
|
const int32 ResolvedSeed = Arguments.bCoreTestSeedOverride
|
|
? Arguments.CoreTestSeed : SourceManager->GetWorldSeed();
|
|
TStrongObjectPtr<UVoxelSettings> SettingsCopy;
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> DefinitionReferences;
|
|
int32 UnusedTargetStrate = INDEX_NONE;
|
|
UVoxelSettings* PlanSettings = nullptr;
|
|
if (Arguments.bCoreTestSeedOverride)
|
|
{
|
|
if (!CreateLockedLayoutSettingsCopy(OwnerSurfaceSettings.Get(), SourceManager.Get(), ResolvedSeed,
|
|
false, SettingsCopy, DefinitionReferences, UnusedTargetStrate, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
PlanSettings = SettingsCopy.Get();
|
|
}
|
|
else
|
|
{
|
|
SettingsCopy = TStrongObjectPtr<UVoxelSettings>(
|
|
DuplicateObject<UVoxelSettings>(OwnerSurfaceSettings.Get(), GetTransientPackage(), NAME_None));
|
|
if (!SettingsCopy.IsValid())
|
|
{
|
|
OutError = TEXT("Could not duplicate the logged owner SurfaceWorld settings for plan generation.");
|
|
return false;
|
|
}
|
|
PlanSettings = SettingsCopy.Get();
|
|
}
|
|
// Keep any imported manifest bytes from the staged owner asset, but leave the opt-in off.
|
|
// This proves old round-3 payloads do not block automatic planning by default.
|
|
PlanSettings->WorldAssemblyManifestJson = AuthoredSettings->WorldAssemblyManifestJson;
|
|
PlanSettings->WorldAssemblyManifestHash = AuthoredSettings->WorldAssemblyManifestHash;
|
|
PlanSettings->bUseWorldAssemblyManifestOverride = false;
|
|
TStrongObjectPtr<UVoxelStrateManager> PlanManager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!PlanManager.IsValid()
|
|
|| !PlanManager->Initialize(PlanSettings, ResolvedSeed))
|
|
{
|
|
OutError = TEXT("Core plan test could not initialize the final resolved layout.");
|
|
return false;
|
|
}
|
|
|
|
FString RuntimeJson;
|
|
FString RepeatJson;
|
|
if (!VoxelForgeCoreAdapter::SerializeResolvedWorld(
|
|
PlanSettings, PlanManager.Get(), 3, 8, Arguments.CoreVoxelSizeCm, RuntimeJson, OutError)
|
|
|| !VoxelForgeCoreAdapter::SerializeResolvedWorld(
|
|
PlanSettings, PlanManager.Get(), 3, 8, Arguments.CoreVoxelSizeCm, RepeatJson, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
if (RuntimeJson != RepeatJson)
|
|
{
|
|
OutError = TEXT("Core automatic plan serialization changed between identical startup resolutions.");
|
|
return false;
|
|
}
|
|
FTCHARToUTF8 Utf8(*RuntimeJson);
|
|
std::string CoreError;
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> Snapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(Utf8.Get(), static_cast<std::size_t>(Utf8.Length())), CoreError);
|
|
if (!Snapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core rejected the generated plan test snapshot: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
int32 ExpectedSurfaceCount = 0;
|
|
int32 ExpectedIslandCount = 0;
|
|
for (const FStrateSlot& Slot : PlanManager->GetLayout())
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const ECaveGeneratorType Type = PlanManager->GetGeneratorTypeForChunk(
|
|
FIntVector(0, 0, Slot.TopChunkZ));
|
|
ExpectedSurfaceCount += Type == ECaveGeneratorType::SurfaceWorld ? 1 : 0;
|
|
ExpectedIslandCount += Type == ECaveGeneratorType::FloatingIslands ? 1 : 0;
|
|
}
|
|
const VoxelForgeCore::WorldAssemblyManifest* Assembly = Snapshot->worldAssembly();
|
|
if (ExpectedSurfaceCount + ExpectedIslandCount > 0
|
|
&& (Assembly == nullptr
|
|
|| Assembly->plan.strates.size() != static_cast<std::size_t>(ExpectedSurfaceCount)
|
|
|| Assembly->floatingIslands.size() != static_cast<std::size_t>(ExpectedIslandCount)))
|
|
{
|
|
OutError = TEXT("Generated plan counts do not match the final resolved owner layout.");
|
|
return false;
|
|
}
|
|
if (ExpectedSurfaceCount + ExpectedIslandCount == 0 && Assembly != nullptr)
|
|
{
|
|
OutError = TEXT("Core attached a world assembly to a layout with no planned strata.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCorePlanTest] pass source=%s seed=%d layout_strates=%d surface_strates=%d island_strates=%d hash=%s deterministic_json=1 stored_manifest_ignored=%d override_enabled=0"),
|
|
Arguments.bCoreTestSeedOverride ? TEXT("locked_owner_layout_seed_override") : TEXT("owner_settings"),
|
|
PlanManager->GetWorldSeed(), PlanManager->GetLayout().Num(), ExpectedSurfaceCount,
|
|
ExpectedIslandCount, Assembly ? *FString::Printf(TEXT("%016llx"),
|
|
static_cast<unsigned long long>(Assembly->contentHash)) : TEXT("none"),
|
|
AuthoredSettings->WorldAssemblyManifestJson.IsEmpty() ? 0 : 1);
|
|
for (const FStrateSlot& Slot : PlanManager->GetLayout())
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCorePlanTest] slot=%d bounds=[%d,%d] type=%d height_chunks=%d"),
|
|
Slot.StrateIndex, Slot.TopChunkZ, Slot.BottomChunkZ,
|
|
Slot.Definition ? static_cast<int32>(Slot.Definition->GeneratorType) : INDEX_NONE,
|
|
Slot.HeightInChunks);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreFallbackActorTest(const FExploreArguments& Arguments, FString& OutError)
|
|
{
|
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
|
nullptr, *Arguments.SettingsReference);
|
|
const FString ManifestPath = FPaths::ConvertRelativePathToFull(
|
|
Arguments.CoreWorldAssemblyManifestPath);
|
|
if (AuthoredSettings == nullptr || !IsUnderVoxelForgeSaved(ManifestPath))
|
|
{
|
|
OutError = TEXT("Core fallback test needs staged owner settings and a manifest inside plugin Saved.");
|
|
return false;
|
|
}
|
|
FString ManifestJson;
|
|
if (!FFileHelper::LoadFileToString(ManifestJson, *ManifestPath))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not read staged fallback manifest '%s'."), *ManifestPath);
|
|
return false;
|
|
}
|
|
const int32 OwnerSeed = ResolveCoreTestWorldSeed(AuthoredSettings);
|
|
TStrongObjectPtr<UVoxelSettings> TestSettings;
|
|
TStrongObjectPtr<UVoxelStrateDefinition> OwnerSurfaceDefinition;
|
|
if (!CreateLoggedOwnerSurfaceSettingsCopy(AuthoredSettings, OwnerSeed,
|
|
TestSettings, OwnerSurfaceDefinition, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
FString ImportReport;
|
|
if (!TestSettings->SetWorldAssemblyManifestJson(ManifestJson, ImportReport))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not stage the mismatched manifest in transient settings: %s."),
|
|
*ImportReport);
|
|
return false;
|
|
}
|
|
FCoreTestWorldFixture WorldFixture;
|
|
if (!WorldFixture.Initialize(FName(TEXT("VoxelForgeCoreFallbackTestWorld")), OutError)) return false;
|
|
AVoxelWorld* Actor = WorldFixture.World->SpawnActorDeferred<AVoxelWorld>(
|
|
AVoxelWorld::StaticClass(), FTransform::Identity, nullptr, nullptr,
|
|
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
|
|
if (Actor == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the transient Core fallback AVoxelWorld.");
|
|
return false;
|
|
}
|
|
Actor->Settings = TestSettings.Get();
|
|
Actor->bUseVoxelForgeCore = true;
|
|
Actor->bManageAtmosphere = false;
|
|
Actor->CoreClipRadius = 1;
|
|
Actor->CoreMaxClipLevel = 0;
|
|
Actor->FinishSpawning(FTransform::Identity);
|
|
if (!WorldFixture.World->SetGameMode(FURL()))
|
|
{
|
|
OutError = TEXT("Could not create the transient GameMode for the Core fallback test.");
|
|
return false;
|
|
}
|
|
WorldFixture.World->InitializeActorsForPlay(FURL());
|
|
WorldFixture.World->BeginPlay();
|
|
if (Actor->bUseVoxelForgeCore || Actor->IsVoxelForgeCoreReady()
|
|
|| Actor->Generator == nullptr || !Actor->HasActorBegunPlay())
|
|
{
|
|
OutError = TEXT("Mismatched world assembly did not disable Core and leave the legacy world running.");
|
|
return false;
|
|
}
|
|
FString RuntimeAssemblyJson, RuntimeAssemblyHash;
|
|
float LegacyDensity = Actor->Generator->GetDensityAt(0.0f, 0.0f, -32.0f);
|
|
if (!FMath::IsFinite(LegacyDensity)
|
|
|| Actor->GetVoxelForgeCoreWorldAssembly(RuntimeAssemblyJson, RuntimeAssemblyHash))
|
|
{
|
|
OutError = TEXT("Core fallback did not expose a finite legacy density path or cleared runtime plan state.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreFallbackTest] fallback=PASS core_disabled=1 legacy_generator=1 legacy_density=%.4f imported_override=1"),
|
|
LegacyDensity);
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreOffActorTest(const FExploreArguments& Arguments, FString& OutError)
|
|
{
|
|
UVoxelSettings* Settings = LoadObject<UVoxelSettings>(nullptr, *Arguments.SettingsReference);
|
|
if (Settings == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not load Core-off owner settings '%s'."),
|
|
*Arguments.SettingsReference);
|
|
return false;
|
|
}
|
|
const int32 Seed = ResolveCoreTestWorldSeed(Settings);
|
|
TStrongObjectPtr<UVoxelSettings> OwnerSurfaceSettings;
|
|
TStrongObjectPtr<UVoxelStrateDefinition> OwnerSurfaceDefinition;
|
|
if (!CreateLoggedOwnerSurfaceSettingsCopy(Settings, Seed,
|
|
OwnerSurfaceSettings, OwnerSurfaceDefinition, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
TStrongObjectPtr<UVoxelStrateManager> ReferenceManager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!ReferenceManager.IsValid() || !ReferenceManager->Initialize(OwnerSurfaceSettings.Get(), Seed))
|
|
{
|
|
OutError = TEXT("Core-off test could not resolve the staged owner layout.");
|
|
return false;
|
|
}
|
|
UVoxelGenerator* ReferenceGenerator = NewObject<UVoxelGenerator>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
if (ReferenceGenerator == nullptr)
|
|
{
|
|
OutError = TEXT("Core-off test could not create its legacy density reference.");
|
|
return false;
|
|
}
|
|
ReferenceGenerator->InitializeSettings(OwnerSurfaceSettings.Get());
|
|
ReferenceGenerator->SetStrateManager(ReferenceManager.Get());
|
|
|
|
FCoreTestWorldFixture WorldFixture;
|
|
if (!WorldFixture.Initialize(FName(TEXT("VoxelForgeCoreOffTestWorld")), OutError)) return false;
|
|
AVoxelWorld* Actor = WorldFixture.World->SpawnActorDeferred<AVoxelWorld>(
|
|
AVoxelWorld::StaticClass(), FTransform::Identity, nullptr, nullptr,
|
|
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
|
|
if (Actor == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the transient Core-off AVoxelWorld.");
|
|
return false;
|
|
}
|
|
Actor->Settings = OwnerSurfaceSettings.Get();
|
|
Actor->bUseVoxelForgeCore = false;
|
|
Actor->bManageAtmosphere = false;
|
|
Actor->FinishSpawning(FTransform::Identity);
|
|
if (!WorldFixture.World->SetGameMode(FURL()))
|
|
{
|
|
OutError = TEXT("Could not create the transient GameMode for the Core-off test.");
|
|
return false;
|
|
}
|
|
WorldFixture.World->InitializeActorsForPlay(FURL());
|
|
WorldFixture.World->BeginPlay();
|
|
if (Actor->bUseVoxelForgeCore || Actor->Generator == nullptr || Actor->Mesher == nullptr)
|
|
{
|
|
OutError = TEXT("Core-off owner world did not remain on its legacy generator and mesher.");
|
|
return false;
|
|
}
|
|
const FVector VoxelOrigin = Actor->LocalVoxelToWorld(FVector::ZeroVector);
|
|
const FVector NextVoxel = Actor->LocalVoxelToWorld(FVector(1.0, 0.0, 0.0));
|
|
const double LegacyVoxelCm = FVector::Distance(VoxelOrigin, NextVoxel);
|
|
const FVector Samples[] = {
|
|
FVector(0.0, 0.0, -32.0), FVector(48.0, -64.0, -128.0), FVector(-160.0, 96.0, -512.0)};
|
|
for (const FVector& Sample : Samples)
|
|
{
|
|
const float Actual = Actor->Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z);
|
|
const float Expected = ReferenceGenerator->GetDensityAt(Sample.X, Sample.Y, Sample.Z);
|
|
if (!FMath::IsFinite(Actual) || Actual != Expected)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core-off legacy density changed at (%.0f,%.0f,%.0f): %.6f/%.6f."),
|
|
Sample.X, Sample.Y, Sample.Z, Actual, Expected);
|
|
return false;
|
|
}
|
|
}
|
|
float CoreDensity = 0.0f;
|
|
if (LegacyVoxelCm != VOXEL_SIZE
|
|
|| Actor->QueryVoxelForgeCoreDensityAt(VoxelOrigin, CoreDensity))
|
|
{
|
|
OutError = TEXT("Core-off owner world did not retain the 12.5 cm legacy scale and disabled Core queries.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreOffTest] legacy_unchanged=PASS core_disabled=1 voxel_cm=%.1f density_samples=3"),
|
|
LegacyVoxelCm);
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreFarHorizonAgreementAudit(VoxelForgeCore::World& World,
|
|
const std::vector<VoxelForgeCore::TileKey>& Keys,
|
|
const VoxelForgeCore::WorldAssemblyManifest& Assembly,FString& OutError)
|
|
{
|
|
constexpr std::int64_t SubvoxelsPerVoxel=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
|
constexpr double ToleranceCm=2.0;
|
|
constexpr double NegativeDatumOffsetCm=100.0;
|
|
const double VoxelSizeCm=static_cast<double>(World.configuration().voxelSizeMicrons)/10000.0;
|
|
using FXYKey=std::pair<std::int64_t,std::int64_t>;
|
|
struct FGroundVertices { std::map<FXYKey,std::vector<std::int64_t>> ByXY; };
|
|
const auto GatherGroundVertices=[&](const VoxelForgeCore::TileMesh& Mesh)
|
|
{
|
|
FGroundVertices Out;
|
|
constexpr std::int64_t GridPeriod=128*SubvoxelsPerVoxel;
|
|
for(const VoxelForgeCore::MeshVertex& Vertex:Mesh.vertices)
|
|
{
|
|
if(Vertex.normal.z<0.35)continue;
|
|
const auto X=static_cast<std::int64_t>(std::llround(
|
|
Vertex.position.x/VoxelSizeCm*SubvoxelsPerVoxel));
|
|
const auto Y=static_cast<std::int64_t>(std::llround(
|
|
Vertex.position.y/VoxelSizeCm*SubvoxelsPerVoxel));
|
|
if(X%GridPeriod!=0||Y%GridPeriod!=0)continue;
|
|
const auto Z=static_cast<std::int64_t>(std::llround(
|
|
Vertex.position.z/VoxelSizeCm*SubvoxelsPerVoxel));
|
|
Out.ByXY[{X,Y}].push_back(Z);
|
|
}
|
|
return Out;
|
|
};
|
|
const auto TileBounds=[](const VoxelForgeCore::TileKey& Key)
|
|
{
|
|
const std::int64_t Extent=static_cast<std::int64_t>(32)<<Key.lod;
|
|
const std::int64_t X=static_cast<std::int64_t>(Key.coordinate.x)*Extent;
|
|
const std::int64_t Y=static_cast<std::int64_t>(Key.coordinate.y)*Extent;
|
|
const std::int64_t Z=static_cast<std::int64_t>(Key.coordinate.z)*Extent;
|
|
return std::array<std::int64_t,6>{X,Y,Z,X+Extent,Y+Extent,Z+Extent};
|
|
};
|
|
struct FSample { std::int64_t X=0,Y=0,ProfileGround=0,Far7=0,Far8=0; };
|
|
std::vector<FSample> Samples;
|
|
std::string CoreError;
|
|
VoxelForgeCore::TileGenerationOptions Options;
|
|
Options.hashDensity=true;
|
|
for(const auto& Key7:Keys)
|
|
{
|
|
if(Key7.lod!=7)continue;
|
|
const auto Bounds7=TileBounds(Key7);
|
|
for(const auto& Key8:Keys)
|
|
{
|
|
if(Key8.lod!=8)continue;
|
|
const auto Bounds8=TileBounds(Key8);
|
|
bool bTouches=true;
|
|
for(int Axis=0;Axis<3;++Axis)
|
|
bTouches=bTouches&&Bounds7[Axis]<=Bounds8[Axis+3]&&Bounds8[Axis]<=Bounds7[Axis+3];
|
|
if(!bTouches)continue;
|
|
const auto Mesh7=World.generateTile(Key7,Options,CoreError);
|
|
const auto Mesh8=World.generateTile(Key8,Options,CoreError);
|
|
if(!Mesh7||!Mesh8)
|
|
{
|
|
OutError=FString::Printf(TEXT("Far-horizon agreement tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const FGroundVertices Ground7=GatherGroundVertices(*Mesh7);
|
|
const FGroundVertices Ground8=GatherGroundVertices(*Mesh8);
|
|
std::vector<FSample> PairSamples;
|
|
for(const auto& Plan:Assembly.plan.strates)
|
|
{
|
|
const VoxelForgeCore::Heightfield2D Field(Assembly.recipe,Plan);
|
|
if(!Field.isValid())continue;
|
|
for(const auto& Pair:Ground7.ByXY)
|
|
{
|
|
const auto Other=Ground8.ByXY.find(Pair.first);
|
|
if(Other==Ground8.ByXY.end())continue;
|
|
const auto Column=Field.point(Pair.first.first,Pair.first.second,0);
|
|
const std::int64_t Ground=Column.groundHeightSubvoxels;
|
|
const std::int64_t Cap=Field.farSurfaceCapHeightSubvoxels(
|
|
Pair.first.first,Pair.first.second);
|
|
if(Ground>=Cap)continue;
|
|
const double GroundVoxel=static_cast<double>(Ground)/SubvoxelsPerVoxel;
|
|
if(GroundVoxel<Bounds7[2]||GroundVoxel>Bounds7[5]
|
|
||GroundVoxel<Bounds8[2]||GroundVoxel>Bounds8[5])continue;
|
|
const auto Nearest=[Ground](const std::vector<std::int64_t>& Values)
|
|
->std::optional<std::int64_t>
|
|
{
|
|
if(Values.empty())return std::nullopt;
|
|
return *std::min_element(Values.begin(),Values.end(),[Ground](auto A,auto B)
|
|
{return std::abs(A-Ground)<std::abs(B-Ground);});
|
|
};
|
|
const auto Z7=Nearest(Pair.second),Z8=Nearest(Other->second);
|
|
if(Z7&&Z8)PairSamples.push_back({Pair.first.first,Pair.first.second,Ground,*Z7,*Z8});
|
|
}
|
|
if(PairSamples.size()>=64)break;
|
|
}
|
|
if(PairSamples.size()>Samples.size())Samples=std::move(PairSamples);
|
|
if(Samples.size()>=64)break;
|
|
}
|
|
if(Samples.size()>=64)break;
|
|
}
|
|
bool bUsedWalkRouteGrid=false;
|
|
if(Samples.size()<64)
|
|
{
|
|
Samples.clear();
|
|
for(const auto& Plan:Assembly.plan.strates)
|
|
{
|
|
const VoxelForgeCore::Heightfield2D Field(Assembly.recipe,Plan);
|
|
if(!Field.isValid())continue;
|
|
std::int64_t RouteXSub=0,RouteYSub=0;
|
|
if(!Plan.route.empty())
|
|
{
|
|
RouteXSub=Plan.route.front().positionSubvoxels[0];
|
|
RouteYSub=Plan.route.front().positionSubvoxels[1];
|
|
}
|
|
constexpr std::int64_t GridPeriod=128*SubvoxelsPerVoxel;
|
|
const std::int64_t XSub=static_cast<std::int64_t>(std::llround(
|
|
static_cast<double>(RouteXSub)/GridPeriod))*GridPeriod;
|
|
const std::int64_t YSub=static_cast<std::int64_t>(std::llround(
|
|
static_cast<double>(RouteYSub)/GridPeriod))*GridPeriod;
|
|
const auto WalkColumn=Field.point(XSub,YSub,0);
|
|
const std::int64_t WalkCap=Field.farSurfaceCapHeightSubvoxels(XSub,YSub);
|
|
if(WalkColumn.groundHeightSubvoxels>=WalkCap)continue;
|
|
const std::int64_t XVoxel=XSub/SubvoxelsPerVoxel,YVoxel=YSub/SubvoxelsPerVoxel;
|
|
const std::int64_t WalkGroundVoxel=WalkColumn.groundHeightSubvoxels/SubvoxelsPerVoxel;
|
|
const auto MakeKey=[](std::int64_t X,std::int64_t Y,std::int64_t Z,std::int64_t Extent,int32 LOD)
|
|
{
|
|
VoxelForgeCore::TileKey Key;
|
|
Key.coordinate={static_cast<int32>(std::floor(static_cast<double>(X)/Extent)),
|
|
static_cast<int32>(std::floor(static_cast<double>(Y)/Extent)),
|
|
static_cast<int32>(std::floor(static_cast<double>(Z)/Extent))};
|
|
Key.lod=LOD;
|
|
return Key;
|
|
};
|
|
const VoxelForgeCore::TileKey Key7=MakeKey(XVoxel,YVoxel,WalkGroundVoxel,4096,7);
|
|
const VoxelForgeCore::TileKey Key8=MakeKey(XVoxel,YVoxel,WalkGroundVoxel,8192,8);
|
|
const auto Mesh7=World.generateTile(Key7,Options,CoreError);
|
|
const auto Mesh8=World.generateTile(Key8,Options,CoreError);
|
|
if(!Mesh7||!Mesh8)
|
|
{
|
|
OutError=FString::Printf(TEXT("Walk-route far-horizon tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const FGroundVertices Ground7=GatherGroundVertices(*Mesh7);
|
|
const FGroundVertices Ground8=GatherGroundVertices(*Mesh8);
|
|
for(const auto& Pair:Ground7.ByXY)
|
|
{
|
|
const auto Other=Ground8.ByXY.find(Pair.first);
|
|
if(Other==Ground8.ByXY.end())continue;
|
|
const auto Column=Field.point(Pair.first.first,Pair.first.second,0);
|
|
const std::int64_t Ground=Column.groundHeightSubvoxels;
|
|
if(Ground>=Field.farSurfaceCapHeightSubvoxels(
|
|
Pair.first.first,Pair.first.second))continue;
|
|
const auto Bounds7=TileBounds(Key7),Bounds8=TileBounds(Key8);
|
|
const double GroundVoxel=static_cast<double>(Ground)/SubvoxelsPerVoxel;
|
|
if(GroundVoxel<Bounds7[2]||GroundVoxel>Bounds7[5]
|
|
||GroundVoxel<Bounds8[2]||GroundVoxel>Bounds8[5])continue;
|
|
const auto Nearest=[Ground](const std::vector<std::int64_t>& Values)
|
|
->std::optional<std::int64_t>
|
|
{
|
|
if(Values.empty())return std::nullopt;
|
|
return *std::min_element(Values.begin(),Values.end(),[Ground](auto A,auto B)
|
|
{return std::abs(A-Ground)<std::abs(B-Ground);});
|
|
};
|
|
const auto Z7=Nearest(Pair.second),Z8=Nearest(Other->second);
|
|
if(Z7&&Z8)
|
|
Samples.push_back({Pair.first.first,Pair.first.second,Ground,*Z7,*Z8});
|
|
if(Samples.size()>=256)break;
|
|
}
|
|
if(Samples.size()>=64){bUsedWalkRouteGrid=true;break;}
|
|
Samples.clear();
|
|
}
|
|
}
|
|
if(Samples.size()<64)
|
|
{OutError=TEXT("Far-horizon agreement audit found fewer than 64 shared LOD7/8 surface points around the staged walk route.");return false;}
|
|
|
|
const auto FindNearRoot=[&](std::int64_t XSub,std::int64_t YSub,double GroundVoxel,double& OutVoxel)
|
|
{
|
|
const double X=static_cast<double>(XSub)/SubvoxelsPerVoxel;
|
|
const double Y=static_cast<double>(YSub)/SubvoxelsPerVoxel;
|
|
for(double Radius=4.0;Radius<=4096.0;Radius*=2.0)
|
|
{
|
|
double Lo=GroundVoxel-Radius,Hi=GroundVoxel+Radius;
|
|
if(!(World.densityAt({X,Y,Lo})<0.0f&&World.densityAt({X,Y,Hi})>=0.0f))continue;
|
|
for(int Iteration=0;Iteration<14;++Iteration)
|
|
{
|
|
const double Mid=(Lo+Hi)*0.5;
|
|
if(World.densityAt({X,Y,Mid})<0.0f)Lo=Mid;else Hi=Mid;
|
|
}
|
|
OutVoxel=(Lo+Hi)*0.5;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
double MaximumFar7ErrorCm=0.0,MaximumFar8ErrorCm=0.0,MaximumCrossLodErrorCm=0.0;
|
|
double MaximumNegativeControlErrorCm=0.0;
|
|
std::size_t Compared=0;
|
|
const std::size_t SampleStride=std::max<std::size_t>(1,(Samples.size()+127)/128);
|
|
for(std::size_t Index=0;Index<Samples.size();Index+=SampleStride)
|
|
{
|
|
const auto& Sample=Samples[Index];
|
|
double NearVoxel=0.0;
|
|
if(!FindNearRoot(Sample.X,Sample.Y,
|
|
static_cast<double>(Sample.ProfileGround)/SubvoxelsPerVoxel,NearVoxel))continue;
|
|
const double Far7Cm=static_cast<double>(Sample.Far7)/SubvoxelsPerVoxel*VoxelSizeCm;
|
|
const double Far8Cm=static_cast<double>(Sample.Far8)/SubvoxelsPerVoxel*VoxelSizeCm;
|
|
const double NearCm=NearVoxel*VoxelSizeCm;
|
|
MaximumFar7ErrorCm=std::max(MaximumFar7ErrorCm,std::abs(Far7Cm-NearCm));
|
|
MaximumFar8ErrorCm=std::max(MaximumFar8ErrorCm,std::abs(Far8Cm-NearCm));
|
|
MaximumCrossLodErrorCm=std::max(MaximumCrossLodErrorCm,std::abs(Far7Cm-Far8Cm));
|
|
MaximumNegativeControlErrorCm=std::max(MaximumNegativeControlErrorCm,
|
|
std::abs((Far8Cm+NegativeDatumOffsetCm)-NearCm));
|
|
++Compared;
|
|
}
|
|
const bool bNegativeControlDetected=Compared>0&&MaximumNegativeControlErrorCm>ToleranceCm;
|
|
if(Compared<32||MaximumFar7ErrorCm>ToleranceCm||MaximumFar8ErrorCm>ToleranceCm
|
|
||MaximumCrossLodErrorCm>ToleranceCm||!bNegativeControlDetected)
|
|
{
|
|
OutError=FString::Printf(TEXT("Far-horizon agreement failed: points=%llu far7_cm=%.4f far8_cm=%.4f cross_lod_cm=%.4f tolerance_cm=%.2f negative_control_cm=%.4f detected=%d."),
|
|
static_cast<unsigned long long>(Compared),MaximumFar7ErrorCm,MaximumFar8ErrorCm,
|
|
MaximumCrossLodErrorCm,ToleranceCm,MaximumNegativeControlErrorCm,bNegativeControlDetected?1:0);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarHorizonAgreement] status=PASS lod_pair=7/8 shared_points=%llu source=%s max_far7_near_cm=%.4f max_far8_near_cm=%.4f max_cross_lod_cm=%.4f tolerance_cm=%.2f near_source=exact_density_root negative_control=100cm_datum_offset max_error_cm=%.4f detected=1"),
|
|
static_cast<unsigned long long>(Compared),
|
|
bUsedWalkRouteGrid?TEXT("staged_walk_route_grid"):TEXT("desired_tile_overlap"),
|
|
MaximumFar7ErrorCm,MaximumFar8ErrorCm,MaximumCrossLodErrorCm,
|
|
ToleranceCm,MaximumNegativeControlErrorCm);
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreFarCapTileCensus(VoxelForgeCore::World& World,
|
|
const VoxelForgeCore::WorldAssemblyManifest& Assembly,FString& OutError)
|
|
{
|
|
OutError.Reset();
|
|
constexpr std::int64_t SubvoxelsPerVoxel=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
|
constexpr std::int64_t ProfileGridStep=128;
|
|
using FTileId=std::array<std::int32_t,4>;
|
|
using FXYLod=std::array<std::int32_t,3>;
|
|
const auto IdFor=[](const VoxelForgeCore::TileKey& Key)
|
|
{return FTileId{Key.coordinate.x,Key.coordinate.y,Key.coordinate.z,Key.lod};};
|
|
const auto FloorDiv64=[](std::int64_t Value,std::int64_t Divisor)
|
|
{
|
|
const std::int64_t Quotient=Value/Divisor, Remainder=Value%Divisor;
|
|
return Quotient-((Remainder!=0&&Remainder<0)?1:0);
|
|
};
|
|
|
|
const VoxelForgeCore::WorldAssemblyStratumPlanV1* Plan=nullptr;
|
|
for(const auto& Candidate:Assembly.plan.strates)
|
|
if(!Candidate.route.empty()) {Plan=&Candidate;break;}
|
|
if(Plan==nullptr)
|
|
{OutError=TEXT("Far-cap census has no SurfaceWorld walk route.");return false;}
|
|
const VoxelForgeCore::Heightfield2D Field(Assembly.recipe,*Plan);
|
|
if(!Field.isValid())
|
|
{OutError=TEXT("Far-cap census received an invalid SurfaceWorld heightfield.");return false;}
|
|
|
|
const auto& WalkStart=Plan->route.front();
|
|
const VoxelForgeCore::Vec3d Camera{
|
|
static_cast<double>(WalkStart.positionSubvoxels[0])/SubvoxelsPerVoxel,
|
|
static_cast<double>(WalkStart.positionSubvoxels[1])/SubvoxelsPerVoxel,
|
|
static_cast<double>(WalkStart.positionSubvoxels[2])/SubvoxelsPerVoxel};
|
|
const std::vector<VoxelForgeCore::TileKey> WalkKeys=World.desiredTiles(Camera);
|
|
std::set<FTileId> Desired;
|
|
std::set<FXYLod> FarXYTiles;
|
|
double ClipMaxZ=-std::numeric_limits<double>::infinity();
|
|
for(const auto& Key:WalkKeys)
|
|
{
|
|
Desired.insert(IdFor(Key));
|
|
const double Extent=static_cast<double>(32<<Key.lod);
|
|
ClipMaxZ=std::max(ClipMaxZ,(static_cast<double>(Key.coordinate.z)+1.0)*Extent);
|
|
if(Key.lod>=5&&Key.lod<=8)
|
|
FarXYTiles.insert({Key.coordinate.x,Key.coordinate.y,Key.lod});
|
|
}
|
|
std::set<FTileId> CapBands;
|
|
std::uint64_t ProfileGridSamples=0, ProfileSamplesWithoutRoof=0;
|
|
std::set<FXYLod> FarXYTilesWithoutRoof;
|
|
for(const FXYLod& XY:FarXYTiles)
|
|
{
|
|
const std::int32_t Lod=XY[2];
|
|
const std::int64_t Extent=static_cast<std::int64_t>(32)<<Lod;
|
|
const std::int64_t OriginX=static_cast<std::int64_t>(XY[0])*Extent;
|
|
const std::int64_t OriginY=static_cast<std::int64_t>(XY[1])*Extent;
|
|
for(std::int64_t Y=0;Y<=Extent;Y+=ProfileGridStep)
|
|
for(std::int64_t X=0;X<=Extent;X+=ProfileGridStep)
|
|
{
|
|
++ProfileGridSamples;
|
|
const auto Column=Field.point((OriginX+X)*SubvoxelsPerVoxel,
|
|
(OriginY+Y)*SubvoxelsPerVoxel,0);
|
|
const std::int64_t FarCap=Field.farSurfaceCapHeightSubvoxels(
|
|
(OriginX+X)*SubvoxelsPerVoxel,(OriginY+Y)*SubvoxelsPerVoxel);
|
|
if(FarCap<=Column.groundHeightSubvoxels)
|
|
{
|
|
++ProfileSamplesWithoutRoof;
|
|
FarXYTilesWithoutRoof.insert(XY);
|
|
continue;
|
|
}
|
|
const std::int64_t Band=FloorDiv64(FarCap,
|
|
Extent*SubvoxelsPerVoxel);
|
|
if(Band>=MIN_int32&&Band<=MAX_int32)
|
|
CapBands.insert({XY[0],XY[1],static_cast<std::int32_t>(Band),Lod});
|
|
if(FarCap%(Extent*SubvoxelsPerVoxel)==0
|
|
&&Band>MIN_int32)
|
|
CapBands.insert({XY[0],XY[1],static_cast<std::int32_t>(Band-1),Lod});
|
|
}
|
|
}
|
|
|
|
std::uint64_t RequestedCapBands=0, EmittedCapBands=0, RequestedWithoutTriangles=0;
|
|
std::uint64_t CulledCapBands=0;
|
|
std::map<std::int32_t,std::array<std::uint64_t,4>> ByLod;
|
|
VoxelForgeCore::TileGenerationOptions Options;
|
|
std::string CoreError;
|
|
struct FCapAgreementSample
|
|
{ std::int64_t XSub=0,YSub=0;double FarZVoxel=0.0; };
|
|
using FCapXYKey=std::pair<std::int64_t,std::int64_t>;
|
|
std::set<FCapXYKey> CapAgreementSeen;
|
|
std::vector<FCapAgreementSample> CapAgreementSamples;
|
|
CapAgreementSamples.reserve(128);
|
|
const double VoxelSizeCm=static_cast<double>(World.configuration().voxelSizeMicrons)/10000.0;
|
|
constexpr std::int64_t CapGridPeriod=128*SubvoxelsPerVoxel;
|
|
for(const FTileId& Id:CapBands)
|
|
{
|
|
const std::int32_t Lod=Id[3];
|
|
auto& Counts=ByLod[Lod];
|
|
++Counts[0];
|
|
if(Desired.find(Id)==Desired.end())
|
|
{
|
|
++CulledCapBands;++Counts[1];continue;
|
|
}
|
|
++RequestedCapBands;
|
|
VoxelForgeCore::TileKey Key{{Id[0],Id[1],Id[2]},Lod};
|
|
const auto Mesh=World.generateTile(Key,Options,CoreError);
|
|
if(!Mesh)
|
|
{
|
|
OutError=FString::Printf(TEXT("Far-cap census tile generation failed at (%d,%d,%d) LOD%d: %s"),
|
|
Id[0],Id[1],Id[2],Lod,UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
if(Mesh->diagnostics.horizonCapTriangles>0)
|
|
{
|
|
++EmittedCapBands;++Counts[2];
|
|
const std::size_t CapIndexStart=Mesh->indices.size()
|
|
-static_cast<std::size_t>(Mesh->diagnostics.horizonCapTriangles)*3u;
|
|
for(std::size_t Index=CapIndexStart;Index<Mesh->indices.size()
|
|
&&CapAgreementSamples.size()<128;++Index)
|
|
{
|
|
const std::uint32_t VertexIndex=Mesh->indices[Index];
|
|
if(VertexIndex>=Mesh->vertices.size())continue;
|
|
const auto& Vertex=Mesh->vertices[VertexIndex];
|
|
if(Vertex.normal.z>=-0.35)continue;
|
|
const std::int64_t XSub=static_cast<std::int64_t>(std::llround(
|
|
Vertex.position.x/VoxelSizeCm*SubvoxelsPerVoxel));
|
|
const std::int64_t YSub=static_cast<std::int64_t>(std::llround(
|
|
Vertex.position.y/VoxelSizeCm*SubvoxelsPerVoxel));
|
|
if(XSub%CapGridPeriod!=0||YSub%CapGridPeriod!=0)continue;
|
|
if(!CapAgreementSeen.insert({XSub,YSub}).second)continue;
|
|
CapAgreementSamples.push_back({XSub,YSub,
|
|
Vertex.position.z/VoxelSizeCm});
|
|
}
|
|
}
|
|
else
|
|
{++RequestedWithoutTriangles;++Counts[3];}
|
|
}
|
|
|
|
constexpr double CapAgreementToleranceCm=2.0;
|
|
constexpr double CapNegativeDatumOffsetCm=100.0;
|
|
const auto FindNearCapRoot=[&](std::int64_t XSub,std::int64_t YSub,
|
|
double ProfileCapVoxel,double& OutRootVoxel)
|
|
{
|
|
const double X=static_cast<double>(XSub)/SubvoxelsPerVoxel;
|
|
const double Y=static_cast<double>(YSub)/SubvoxelsPerVoxel;
|
|
for(double Radius=4.0;Radius<=4096.0;Radius*=2.0)
|
|
{
|
|
double Lo=ProfileCapVoxel-Radius,Hi=ProfileCapVoxel+Radius;
|
|
if(!(World.densityAt({X,Y,Lo})>=0.0f&&World.densityAt({X,Y,Hi})<0.0f))continue;
|
|
for(int Iteration=0;Iteration<16;++Iteration)
|
|
{
|
|
const double Mid=(Lo+Hi)*0.5;
|
|
if(World.densityAt({X,Y,Mid})>=0.0f)Lo=Mid;else Hi=Mid;
|
|
}
|
|
OutRootVoxel=(Lo+Hi)*0.5;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
double MaximumCapAgreementErrorCm=0.0,MaximumCapNegativeControlErrorCm=0.0;
|
|
std::uint64_t CapAgreementCompared=0;
|
|
for(const FCapAgreementSample& Sample:CapAgreementSamples)
|
|
{
|
|
const auto Column=Field.point(Sample.XSub,Sample.YSub,0);
|
|
const std::int64_t FarCap=Field.farSurfaceCapHeightSubvoxels(
|
|
Sample.XSub,Sample.YSub);
|
|
if(FarCap<=Column.groundHeightSubvoxels)continue;
|
|
double NearRootVoxel=0.0;
|
|
if(!FindNearCapRoot(Sample.XSub,Sample.YSub,
|
|
static_cast<double>(FarCap)/SubvoxelsPerVoxel,NearRootVoxel))continue;
|
|
const double FarCapCm=Sample.FarZVoxel*VoxelSizeCm;
|
|
const double NearCapCm=NearRootVoxel*VoxelSizeCm;
|
|
MaximumCapAgreementErrorCm=std::max(MaximumCapAgreementErrorCm,
|
|
std::abs(FarCapCm-NearCapCm));
|
|
MaximumCapNegativeControlErrorCm=std::max(MaximumCapNegativeControlErrorCm,
|
|
std::abs(FarCapCm+CapNegativeDatumOffsetCm-NearCapCm));
|
|
++CapAgreementCompared;
|
|
}
|
|
const bool bCapNegativeControlDetected=CapAgreementCompared>0
|
|
&&MaximumCapNegativeControlErrorCm>CapAgreementToleranceCm;
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarCapAgreement] status=%s shared_points=%llu max_far_near_cm=%.4f tolerance_cm=%.2f near_source=exact_density_root negative_control=100cm_datum_offset max_error_cm=%.4f detected=%d"),
|
|
CapAgreementCompared>=32&&MaximumCapAgreementErrorCm<=CapAgreementToleranceCm
|
|
&&bCapNegativeControlDetected?TEXT("PASS"):TEXT("FAIL"),
|
|
static_cast<unsigned long long>(CapAgreementCompared),MaximumCapAgreementErrorCm,
|
|
CapAgreementToleranceCm,MaximumCapNegativeControlErrorCm,bCapNegativeControlDetected?1:0);
|
|
if(CapAgreementCompared<32||MaximumCapAgreementErrorCm>CapAgreementToleranceCm
|
|
||!bCapNegativeControlDetected)
|
|
{
|
|
OutError=FString::Printf(TEXT("Far-cap agreement failed: points=%llu max_error_cm=%.4f tolerance_cm=%.2f negative_control_cm=%.4f detected=%d."),
|
|
static_cast<unsigned long long>(CapAgreementCompared),MaximumCapAgreementErrorCm,
|
|
CapAgreementToleranceCm,MaximumCapNegativeControlErrorCm,bCapNegativeControlDetected?1:0);
|
|
return false;
|
|
}
|
|
|
|
// Les rayons couvrent l'hemisphere superieur depuis chaque point de marche.
|
|
// Cast into the upper view hemisphere from every authored walk point.
|
|
constexpr double Pi=3.14159265358979323846;
|
|
constexpr std::array<double,8> AzimuthDegrees={0.0,45.0,90.0,135.0,180.0,225.0,270.0,315.0};
|
|
constexpr std::array<double,6> ElevationDegrees={15.0,30.0,45.0,60.0,75.0,90.0};
|
|
constexpr double RayStepVoxels=128.0;
|
|
double ClipMinX=std::numeric_limits<double>::infinity();
|
|
double ClipMinY=std::numeric_limits<double>::infinity();
|
|
double ClipMaxX=-std::numeric_limits<double>::infinity();
|
|
double ClipMaxY=-std::numeric_limits<double>::infinity();
|
|
for(const FXYLod& XY:FarXYTiles)
|
|
{
|
|
const double Extent=static_cast<double>(32<<XY[2]);
|
|
ClipMinX=std::min(ClipMinX,static_cast<double>(XY[0])*Extent);
|
|
ClipMinY=std::min(ClipMinY,static_cast<double>(XY[1])*Extent);
|
|
ClipMaxX=std::max(ClipMaxX,(static_cast<double>(XY[0])+1.0)*Extent);
|
|
ClipMaxY=std::max(ClipMaxY,(static_cast<double>(XY[1])+1.0)*Extent);
|
|
}
|
|
enum class ECapRayTileResult : std::uint8_t
|
|
{ EmittedHit, CulledByClipmap, RequestedWithoutTriangles, EmittedWithoutHit };
|
|
struct FCapRayTileGeometry
|
|
{
|
|
std::vector<std::array<VoxelForgeCore::Vec3d,3>> Triangles;
|
|
};
|
|
std::uint64_t RayCount=0, RayHits=0, RayMissedClipEdge=0;
|
|
std::uint64_t RayMissedNoRoof=0, RayMissedCulledByClipmap=0;
|
|
std::uint64_t RayMissedRequestedWithoutTriangles=0, RayMissedEmittedWithoutHit=0;
|
|
std::uint64_t RayStartedAboveCap=0;
|
|
std::uint64_t RayProfileCrossings=0,RayProfileRoofCrossings=0,RayProfileRoofWithoutMeshHit=0;
|
|
std::array<std::array<std::uint64_t,6>,6> RayResultsByElevation{};
|
|
std::vector<double> RayHitDistancesMeters;
|
|
RayHitDistancesMeters.reserve(Plan->route.size()*AzimuthDegrees.size()*ElevationDegrees.size());
|
|
std::map<FTileId,FCapRayTileGeometry> RayTileGeometryCache;
|
|
const auto GetCapRayTileGeometry=[&](const FTileId& Id)->const FCapRayTileGeometry*
|
|
{
|
|
auto Found=RayTileGeometryCache.find(Id);
|
|
if(Found!=RayTileGeometryCache.end())return &Found->second;
|
|
const VoxelForgeCore::TileKey Key{{Id[0],Id[1],Id[2]},Id[3]};
|
|
const auto Mesh=World.generateTile(Key,Options,CoreError);
|
|
if(!Mesh)
|
|
{
|
|
OutError=FString::Printf(TEXT("Far-cap ray tile generation failed at (%d,%d,%d) LOD%d: %s"),
|
|
Id[0],Id[1],Id[2],Id[3],UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return nullptr;
|
|
}
|
|
FCapRayTileGeometry Geometry;
|
|
Geometry.Triangles.reserve(Mesh->groups.ceilingTriangleCount);
|
|
for(std::size_t Index=0;Index+2<Mesh->indices.size();Index+=3)
|
|
{
|
|
const std::uint32_t IA=Mesh->indices[Index],IB=Mesh->indices[Index+1],IC=Mesh->indices[Index+2];
|
|
if(IA>=Mesh->vertices.size()||IB>=Mesh->vertices.size()||IC>=Mesh->vertices.size())continue;
|
|
const double AverageNormalZ=(Mesh->vertices[IA].normal.z+Mesh->vertices[IB].normal.z
|
|
+Mesh->vertices[IC].normal.z)/3.0;
|
|
// Native marching-cubes roofs and refined far caps both face out of the solid
|
|
// toward decreasing Z density. Include those ceiling faces at LOD0-8.
|
|
// Les plafonds MC natifs et les caps lointains pointent hors du solide, vers -Z.
|
|
if(AverageNormalZ>=-0.05)continue;
|
|
Geometry.Triangles.push_back({
|
|
VoxelForgeCore::Vec3d{Mesh->vertices[IA].position.x/VoxelSizeCm,
|
|
Mesh->vertices[IA].position.y/VoxelSizeCm,Mesh->vertices[IA].position.z/VoxelSizeCm},
|
|
VoxelForgeCore::Vec3d{Mesh->vertices[IB].position.x/VoxelSizeCm,
|
|
Mesh->vertices[IB].position.y/VoxelSizeCm,Mesh->vertices[IB].position.z/VoxelSizeCm},
|
|
VoxelForgeCore::Vec3d{Mesh->vertices[IC].position.x/VoxelSizeCm,
|
|
Mesh->vertices[IC].position.y/VoxelSizeCm,Mesh->vertices[IC].position.z/VoxelSizeCm}});
|
|
}
|
|
return &RayTileGeometryCache.emplace(Id,std::move(Geometry)).first->second;
|
|
};
|
|
const auto Dot3=[](const VoxelForgeCore::Vec3d& A,const VoxelForgeCore::Vec3d& B)
|
|
{return A.x*B.x+A.y*B.y+A.z*B.z;};
|
|
const auto Sub3=[](const VoxelForgeCore::Vec3d& A,const VoxelForgeCore::Vec3d& B)
|
|
{return VoxelForgeCore::Vec3d{A.x-B.x,A.y-B.y,A.z-B.z};};
|
|
const auto Cross3=[](const VoxelForgeCore::Vec3d& A,const VoxelForgeCore::Vec3d& B)
|
|
{return VoxelForgeCore::Vec3d{A.y*B.z-A.z*B.y,A.z*B.x-A.x*B.z,A.x*B.y-A.y*B.x};};
|
|
const auto RayTriangleDistance=[&](const VoxelForgeCore::Vec3d& Origin,
|
|
const VoxelForgeCore::Vec3d& Direction,const std::array<VoxelForgeCore::Vec3d,3>& Triangle,
|
|
double MinimumDistance,double MaximumDistance,double& OutDistance)
|
|
{
|
|
const auto Edge1=Sub3(Triangle[1],Triangle[0]);
|
|
const auto Edge2=Sub3(Triangle[2],Triangle[0]);
|
|
const auto P=Cross3(Direction,Edge2);
|
|
const double Determinant=Dot3(Edge1,P);
|
|
if(std::abs(Determinant)<1.0e-12)return false;
|
|
const double Inverse=1.0/Determinant;
|
|
const auto T=Sub3(Origin,Triangle[0]);
|
|
const double U=Dot3(T,P)*Inverse;
|
|
if(U< -1.0e-8||U>1.0+1.0e-8)return false;
|
|
const auto Q=Cross3(T,Edge1);
|
|
const double V=Dot3(Direction,Q)*Inverse;
|
|
if(V< -1.0e-8||U+V>1.0+1.0e-8)return false;
|
|
const double Distance=Dot3(Edge2,Q)*Inverse;
|
|
if(Distance<MinimumDistance-1.0e-6||Distance>MaximumDistance+1.0e-6)return false;
|
|
OutDistance=Distance;
|
|
return true;
|
|
};
|
|
const auto TraceEmittedCapRay=[&](const VoxelForgeCore::Vec3d& Origin,
|
|
const VoxelForgeCore::Vec3d& Direction,double MaximumDistance,double& OutHitDistance)
|
|
{
|
|
bool bFoundRequestedKey=false,bFoundEmptyRequestedKey=false,bFoundGeometryWithoutHit=false;
|
|
bool bFoundHit=false;
|
|
for(const FTileId& Id:Desired)
|
|
{
|
|
const double Extent=static_cast<double>(32<<Id[3]);
|
|
const std::array<double,3> BoundsMin={static_cast<double>(Id[0])*Extent,
|
|
static_cast<double>(Id[1])*Extent,static_cast<double>(Id[2])*Extent};
|
|
const std::array<double,3> BoundsMax={BoundsMin[0]+Extent,BoundsMin[1]+Extent,
|
|
BoundsMin[2]+Extent};
|
|
double TileEnter=0.0,TileExit=MaximumDistance;
|
|
bool bIntersects=true;
|
|
for(std::size_t Axis=0;Axis<3;++Axis)
|
|
{
|
|
const double RayOrigin=Axis==0?Origin.x:(Axis==1?Origin.y:Origin.z);
|
|
const double RayDirection=Axis==0?Direction.x:(Axis==1?Direction.y:Direction.z);
|
|
if(std::abs(RayDirection)<1.0e-12)
|
|
{
|
|
if(RayOrigin<BoundsMin[Axis]||RayOrigin>BoundsMax[Axis])
|
|
{bIntersects=false;break;}
|
|
continue;
|
|
}
|
|
double Near=(BoundsMin[Axis]-RayOrigin)/RayDirection;
|
|
double Far=(BoundsMax[Axis]-RayOrigin)/RayDirection;
|
|
if(Near>Far)std::swap(Near,Far);
|
|
TileEnter=std::max(TileEnter,Near);
|
|
TileExit=std::min(TileExit,Far);
|
|
if(TileEnter>TileExit){bIntersects=false;break;}
|
|
}
|
|
if(!bIntersects||TileExit<0.0||TileEnter>MaximumDistance)continue;
|
|
bFoundRequestedKey=true;
|
|
const FCapRayTileGeometry* Geometry=GetCapRayTileGeometry(Id);
|
|
if(Geometry==nullptr)continue;
|
|
if(Geometry->Triangles.empty())
|
|
{bFoundEmptyRequestedKey=true;continue;}
|
|
bFoundGeometryWithoutHit=true;
|
|
for(const auto& Triangle:Geometry->Triangles)
|
|
{
|
|
double HitDistance=0.0;
|
|
if(!RayTriangleDistance(Origin,Direction,Triangle,
|
|
std::max(0.0,TileEnter),std::min(MaximumDistance,TileExit),HitDistance))continue;
|
|
if(!bFoundHit||HitDistance<OutHitDistance)OutHitDistance=HitDistance;
|
|
bFoundHit=true;
|
|
}
|
|
if(!OutError.IsEmpty())return ECapRayTileResult::RequestedWithoutTriangles;
|
|
}
|
|
if(bFoundHit)return ECapRayTileResult::EmittedHit;
|
|
if(bFoundRequestedKey&&bFoundGeometryWithoutHit)return ECapRayTileResult::EmittedWithoutHit;
|
|
if(bFoundRequestedKey&&bFoundEmptyRequestedKey)return ECapRayTileResult::RequestedWithoutTriangles;
|
|
return ECapRayTileResult::CulledByClipmap;
|
|
};
|
|
for(std::size_t RouteIndex=0;RouteIndex<Plan->route.size();++RouteIndex)
|
|
{
|
|
const auto& RoutePoint=Plan->route[RouteIndex];
|
|
const double StartX=static_cast<double>(RoutePoint.positionSubvoxels[0])/SubvoxelsPerVoxel;
|
|
const double StartY=static_cast<double>(RoutePoint.positionSubvoxels[1])/SubvoxelsPerVoxel;
|
|
const double StartZ=static_cast<double>(RoutePoint.positionSubvoxels[2])/SubvoxelsPerVoxel;
|
|
const auto StartColumn=Field.point(RoutePoint.positionSubvoxels[0],
|
|
RoutePoint.positionSubvoxels[1],0);
|
|
const double StartCap=static_cast<double>(Field.farSurfaceCapHeightSubvoxels(
|
|
RoutePoint.positionSubvoxels[0],RoutePoint.positionSubvoxels[1]))/SubvoxelsPerVoxel;
|
|
for(const double Elevation:ElevationDegrees)
|
|
for(const double Azimuth:AzimuthDegrees)
|
|
{
|
|
++RayCount;
|
|
const std::size_t ElevationIndex=static_cast<std::size_t>(
|
|
std::find(ElevationDegrees.begin(),ElevationDegrees.end(),Elevation)
|
|
-ElevationDegrees.begin());
|
|
if(!(StartCap>StartZ))
|
|
{++RayStartedAboveCap;++RayMissedNoRoof;++RayResultsByElevation[ElevationIndex][4];continue;}
|
|
const double ElevationRadians=Elevation*Pi/180.0;
|
|
const double AzimuthRadians=Azimuth*Pi/180.0;
|
|
const double DX=std::cos(ElevationRadians)*std::cos(AzimuthRadians);
|
|
const double DY=std::cos(ElevationRadians)*std::sin(AzimuthRadians);
|
|
const double DZ=std::sin(ElevationRadians);
|
|
double EdgeDistance=std::numeric_limits<double>::infinity();
|
|
if(DX>1.0e-9)EdgeDistance=std::min(EdgeDistance,(ClipMaxX-StartX)/DX);
|
|
else if(DX< -1.0e-9)EdgeDistance=std::min(EdgeDistance,(ClipMinX-StartX)/DX);
|
|
if(DY>1.0e-9)EdgeDistance=std::min(EdgeDistance,(ClipMaxY-StartY)/DY);
|
|
else if(DY< -1.0e-9)EdgeDistance=std::min(EdgeDistance,(ClipMinY-StartY)/DY);
|
|
if(DZ>1.0e-9)
|
|
EdgeDistance=std::min(EdgeDistance,(ClipMaxZ-StartZ)/DZ);
|
|
if(!std::isfinite(EdgeDistance)||EdgeDistance<=0.0)
|
|
{++RayMissedClipEdge;++RayResultsByElevation[ElevationIndex][5];continue;}
|
|
bool bProfileCrossing=false,bProfileRoofAtCrossing=false;
|
|
double ProfileHitDistance=std::numeric_limits<double>::infinity();
|
|
double ProfileHitX=0.0,ProfileHitY=0.0,ProfileHitZ=0.0;
|
|
std::int64_t ProfileHitCapSubvoxels=0;
|
|
double PreviousDistance=0.0,PreviousDelta=StartZ-StartCap;
|
|
for(double Distance=std::min(RayStepVoxels,EdgeDistance);;)
|
|
{
|
|
const double X=StartX+DX*Distance,Y=StartY+DY*Distance;
|
|
const double Z=StartZ+DZ*Distance;
|
|
const std::int64_t CapSubvoxels=Field.farSurfaceCapHeightSubvoxels(
|
|
static_cast<std::int64_t>(std::llround(X*SubvoxelsPerVoxel)),
|
|
static_cast<std::int64_t>(std::llround(Y*SubvoxelsPerVoxel)));
|
|
const double Cap=static_cast<double>(CapSubvoxels)/SubvoxelsPerVoxel;
|
|
const double Delta=Z-Cap;
|
|
if(PreviousDelta<0.0&&Delta>=0.0)
|
|
{
|
|
const double Fraction=-PreviousDelta/(Delta-PreviousDelta);
|
|
ProfileHitDistance=PreviousDistance+(Distance-PreviousDistance)*Fraction;
|
|
const double HitX=StartX+DX*ProfileHitDistance;
|
|
const double HitY=StartY+DY*ProfileHitDistance;
|
|
const auto HitColumn=Field.point(
|
|
static_cast<std::int64_t>(std::llround(HitX*SubvoxelsPerVoxel)),
|
|
static_cast<std::int64_t>(std::llround(HitY*SubvoxelsPerVoxel)),0);
|
|
const std::int64_t HitCapSubvoxels=Field.farSurfaceCapHeightSubvoxels(
|
|
static_cast<std::int64_t>(std::llround(HitX*SubvoxelsPerVoxel)),
|
|
static_cast<std::int64_t>(std::llround(HitY*SubvoxelsPerVoxel)));
|
|
bProfileCrossing=true;
|
|
bProfileRoofAtCrossing=HitCapSubvoxels>HitColumn.groundHeightSubvoxels;
|
|
ProfileHitX=HitX;ProfileHitY=HitY;
|
|
ProfileHitZ=StartZ+DZ*ProfileHitDistance;
|
|
ProfileHitCapSubvoxels=HitCapSubvoxels;
|
|
++RayProfileCrossings;
|
|
if(bProfileRoofAtCrossing)++RayProfileRoofCrossings;
|
|
break;
|
|
}
|
|
PreviousDistance=Distance;PreviousDelta=Delta;
|
|
if(Distance>=EdgeDistance)break;
|
|
Distance=std::min(Distance+RayStepVoxels,EdgeDistance);
|
|
}
|
|
const VoxelForgeCore::Vec3d RayOrigin{StartX,StartY,StartZ};
|
|
const VoxelForgeCore::Vec3d RayDirection{DX,DY,DZ};
|
|
double MeshHitDistance=std::numeric_limits<double>::infinity();
|
|
const ECapRayTileResult TileResult=TraceEmittedCapRay(
|
|
RayOrigin,RayDirection,EdgeDistance,MeshHitDistance);
|
|
if(!OutError.IsEmpty())return false;
|
|
switch(TileResult)
|
|
{
|
|
case ECapRayTileResult::EmittedHit:
|
|
++RayHits;++RayResultsByElevation[ElevationIndex][0];
|
|
RayHitDistancesMeters.push_back(MeshHitDistance*VoxelSizeCm/100.0);break;
|
|
case ECapRayTileResult::CulledByClipmap:
|
|
++RayMissedCulledByClipmap;++RayResultsByElevation[ElevationIndex][1];break;
|
|
case ECapRayTileResult::RequestedWithoutTriangles:
|
|
++RayMissedRequestedWithoutTriangles;++RayResultsByElevation[ElevationIndex][2];break;
|
|
case ECapRayTileResult::EmittedWithoutHit:
|
|
{
|
|
++RayMissedEmittedWithoutHit;++RayResultsByElevation[ElevationIndex][3];
|
|
if(bProfileCrossing&&bProfileRoofAtCrossing)++RayProfileRoofWithoutMeshHit;
|
|
std::uint32_t FarRequestedMask=0,FarEmittedMask=0;
|
|
std::uint32_t RootRequestedMask=0,RootEmittedMask=0;
|
|
std::uint64_t RootDownwardTriangles=0;
|
|
if(bProfileCrossing&&bProfileRoofAtCrossing)
|
|
{
|
|
for(std::int32_t Lod=0;Lod<=8;++Lod)
|
|
{
|
|
const std::int64_t Extent=static_cast<std::int64_t>(32)<<Lod;
|
|
const std::int64_t TileX=FloorDiv64(static_cast<std::int64_t>(std::floor(ProfileHitX)),Extent);
|
|
const std::int64_t TileY=FloorDiv64(static_cast<std::int64_t>(std::floor(ProfileHitY)),Extent);
|
|
const std::int64_t TileZ=FloorDiv64(ProfileHitCapSubvoxels,Extent*SubvoxelsPerVoxel);
|
|
const FTileId Id{static_cast<std::int32_t>(TileX),static_cast<std::int32_t>(TileY),
|
|
static_cast<std::int32_t>(TileZ),Lod};
|
|
if(Desired.find(Id)==Desired.end())continue;
|
|
RootRequestedMask|=1u<<static_cast<std::uint32_t>(Lod);
|
|
const FCapRayTileGeometry* Geometry=GetCapRayTileGeometry(Id);
|
|
if(Geometry!=nullptr)
|
|
{
|
|
RootDownwardTriangles+=Geometry->Triangles.size();
|
|
if(!Geometry->Triangles.empty())
|
|
RootEmittedMask|=1u<<static_cast<std::uint32_t>(Lod);
|
|
}
|
|
if(Lod>=5)
|
|
{
|
|
FarRequestedMask|=1u<<static_cast<std::uint32_t>(Lod-5);
|
|
if(Geometry!=nullptr&&!Geometry->Triangles.empty())
|
|
FarEmittedMask|=1u<<static_cast<std::uint32_t>(Lod-5);
|
|
}
|
|
}
|
|
}
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarCapRayMiss] route_point=%llu elevation_deg=%.0f azimuth_deg=%.0f profile_crossing=%d profile_roof=%d profile_distance_vox=%.1f clip_distance_vox=%.1f profile_hit_vox=(%.1f,%.1f,%.1f) root_lod_requested_mask=0x%X root_lod_emitted_mask=0x%X root_downward_triangles=%llu far_requested_mask=0x%X far_emitted_mask=0x%X"),
|
|
static_cast<unsigned long long>(RouteIndex),Elevation,Azimuth,
|
|
bProfileCrossing?1:0,bProfileRoofAtCrossing?1:0,
|
|
ProfileHitDistance,EdgeDistance,ProfileHitX,ProfileHitY,ProfileHitZ,
|
|
RootRequestedMask,RootEmittedMask,
|
|
static_cast<unsigned long long>(RootDownwardTriangles),
|
|
FarRequestedMask,FarEmittedMask);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const auto WalkColumn=Field.point(WalkStart.positionSubvoxels[0],
|
|
WalkStart.positionSubvoxels[1],0);
|
|
const std::int64_t WalkFarCap=Field.farSurfaceCapHeightSubvoxels(
|
|
WalkStart.positionSubvoxels[0],WalkStart.positionSubvoxels[1]);
|
|
std::size_t RayTriangleCount=0;
|
|
for(const auto& [Id,Geometry]:RayTileGeometryCache)
|
|
{ (void)Id;RayTriangleCount+=Geometry.Triangles.size(); }
|
|
std::sort(RayHitDistancesMeters.begin(),RayHitDistancesMeters.end());
|
|
const double HitDistanceP50Meters=RayHitDistancesMeters.empty()?0.0:
|
|
RayHitDistancesMeters[(RayHitDistancesMeters.size()-1)*50/100];
|
|
const double HitDistanceP95Meters=RayHitDistancesMeters.empty()?0.0:
|
|
RayHitDistancesMeters[(RayHitDistancesMeters.size()-1)*95/100];
|
|
const double HitDistanceMaximumMeters=RayHitDistancesMeters.empty()?0.0:
|
|
RayHitDistancesMeters.back();
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarCapCensus] source=surface_route_front route_points=%llu walk_keys=%llu far_xy_tiles=%llu cap_bands=%llu requested=%llu emitted=%llu requested_without_triangles=%llu culled_by_clipmap=%llu grid_samples=%llu samples_without_roof=%llu xy_tiles_with_roof_gaps=%llu walk_z_vox=%.3f ground_z_vox=%.3f cap_z_vox=%.3f"),
|
|
static_cast<unsigned long long>(Plan->route.size()),
|
|
static_cast<unsigned long long>(WalkKeys.size()),
|
|
static_cast<unsigned long long>(FarXYTiles.size()),
|
|
static_cast<unsigned long long>(CapBands.size()),
|
|
static_cast<unsigned long long>(RequestedCapBands),
|
|
static_cast<unsigned long long>(EmittedCapBands),
|
|
static_cast<unsigned long long>(RequestedWithoutTriangles),
|
|
static_cast<unsigned long long>(CulledCapBands),
|
|
static_cast<unsigned long long>(ProfileGridSamples),
|
|
static_cast<unsigned long long>(ProfileSamplesWithoutRoof),
|
|
static_cast<unsigned long long>(FarXYTilesWithoutRoof.size()),
|
|
Camera.z,static_cast<double>(WalkColumn.groundHeightSubvoxels)/SubvoxelsPerVoxel,
|
|
static_cast<double>(WalkFarCap)/SubvoxelsPerVoxel);
|
|
const double CoveragePercent=RayCount>0?100.0*static_cast<double>(RayHits)/RayCount:0.0;
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarCapRayCoverage] rays=%llu mesh_hits=%llu coverage_pct=%.3f missed_clip_edge=%llu missed_no_roof=%llu missed_cap_key_culled=%llu missed_requested_without_triangles=%llu missed_emitted_without_mesh_hit=%llu start_above_cap=%llu profile_crossings=%llu profile_roof_crossings=%llu profile_roof_without_mesh_hit=%llu traced_tiles=%llu traced_downward_triangles=%llu first_hit_distance_m_p50=%.2f_p95=%.2f_max=%.2f elevations_deg=15,30,45,60,75,90 azimuths=8 ray_method=all_downward_emitted_triangles_with_tile_aabb clip_edge=desired_far_xy_bounds lods_checked=0-8"),
|
|
static_cast<unsigned long long>(RayCount),static_cast<unsigned long long>(RayHits),CoveragePercent,
|
|
static_cast<unsigned long long>(RayMissedClipEdge),
|
|
static_cast<unsigned long long>(RayMissedNoRoof),
|
|
static_cast<unsigned long long>(RayMissedCulledByClipmap),
|
|
static_cast<unsigned long long>(RayMissedRequestedWithoutTriangles),
|
|
static_cast<unsigned long long>(RayMissedEmittedWithoutHit),
|
|
static_cast<unsigned long long>(RayStartedAboveCap),
|
|
static_cast<unsigned long long>(RayProfileCrossings),
|
|
static_cast<unsigned long long>(RayProfileRoofCrossings),
|
|
static_cast<unsigned long long>(RayProfileRoofWithoutMeshHit),
|
|
static_cast<unsigned long long>(RayTileGeometryCache.size()),
|
|
static_cast<unsigned long long>(RayTriangleCount),HitDistanceP50Meters,
|
|
HitDistanceP95Meters,HitDistanceMaximumMeters);
|
|
for(std::size_t Index=0;Index<ElevationDegrees.size();++Index)
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarCapRayElevation] degrees=%.0f rays=%llu mesh_hits=%llu key_culled=%llu requested_empty=%llu emitted_without_mesh_hit=%llu no_roof_or_above=%llu missed_clip_edge=%llu"),
|
|
ElevationDegrees[Index],
|
|
static_cast<unsigned long long>(AzimuthDegrees.size()*Plan->route.size()),
|
|
static_cast<unsigned long long>(RayResultsByElevation[Index][0]),
|
|
static_cast<unsigned long long>(RayResultsByElevation[Index][1]),
|
|
static_cast<unsigned long long>(RayResultsByElevation[Index][2]),
|
|
static_cast<unsigned long long>(RayResultsByElevation[Index][3]),
|
|
static_cast<unsigned long long>(RayResultsByElevation[Index][4]),
|
|
static_cast<unsigned long long>(RayResultsByElevation[Index][5]));
|
|
if(RayHits!=RayCount)
|
|
{
|
|
OutError=FString::Printf(TEXT("Far-cap ray coverage failed: rays=%llu mesh_hits=%llu clip_edge=%llu no_roof=%llu culled_keys=%llu requested_empty=%llu emitted_without_hit=%llu."),
|
|
static_cast<unsigned long long>(RayCount),static_cast<unsigned long long>(RayHits),
|
|
static_cast<unsigned long long>(RayMissedClipEdge),
|
|
static_cast<unsigned long long>(RayMissedNoRoof),
|
|
static_cast<unsigned long long>(RayMissedCulledByClipmap),
|
|
static_cast<unsigned long long>(RayMissedRequestedWithoutTriangles),
|
|
static_cast<unsigned long long>(RayMissedEmittedWithoutHit));
|
|
return false;
|
|
}
|
|
for(const auto& [Lod,Counts]:ByLod)
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeFarCapCensusLOD] lod=%d cap_bands=%llu culled=%llu emitted=%llu requested_without_triangles=%llu"),
|
|
Lod,static_cast<unsigned long long>(Counts[0]),
|
|
static_cast<unsigned long long>(Counts[1]),
|
|
static_cast<unsigned long long>(Counts[2]),
|
|
static_cast<unsigned long long>(Counts[3]));
|
|
return true;
|
|
}
|
|
|
|
// Paired engine timings include preparation and derived shells, with canonical output hashes.
|
|
// Mesures moteur appariees : preparation et coques derivees incluses, avec hashes canoniques.
|
|
bool RunCoreSharedFeatureCostAudit(VoxelForgeCore::World& World,
|
|
const std::vector<VoxelForgeCore::TileKey>& Keys,const TCHAR* Label,
|
|
const VoxelForgeCore::WorldAssemblyManifest* Assembly,FString& OutError)
|
|
{
|
|
if(Assembly!=nullptr&&FCString::Stricmp(Label,TEXT("surface"))==0)
|
|
{
|
|
struct FProbeColumn
|
|
{
|
|
std::int64_t Ground=0;
|
|
std::int64_t Cap=0;
|
|
};
|
|
const auto P95=[](TArray<double> Values)
|
|
{ Values.Sort(); return Values.IsEmpty() ? 0.0 : Values[FMath::Clamp(
|
|
FMath::CeilToInt(Values.Num()*0.95f)-1,0,Values.Num()-1)]; };
|
|
const auto MaxValue=[](const TArray<double>& Values)
|
|
{ return Values.IsEmpty() ? 0.0 : *std::max_element(Values.GetData(),Values.GetData()+Values.Num()); };
|
|
const std::int32_t TileVoxelCount=static_cast<std::int32_t>(
|
|
World.configuration().tileVoxelCount);
|
|
constexpr std::int64_t Subvoxels=VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
|
constexpr double CoreVoxelSizeCm=25.0;
|
|
for(const std::int32_t LOD:{0,1,3,4,5,8})
|
|
{
|
|
for(const std::int32_t Stride:{2,4})
|
|
{
|
|
const std::int32_t Cells=LOD<World.configuration().fullResolutionLodCount
|
|
?TileVoxelCount:std::max(1,World.configuration().coarseTileCells);
|
|
TArray<double> GroundErrorsCm,CapErrorsCm,BaselineColumnMs,CandidateColumnMs;
|
|
std::uint64_t BaselineColumns=0,CandidateColumns=0,FiniteFeatureTiles=0;
|
|
std::int32_t Tiles=0;
|
|
for(const auto& Key:Keys)
|
|
{
|
|
if(Key.lod!=LOD||Tiles>=2)continue;
|
|
const std::int64_t TileExtent=static_cast<std::int64_t>(TileVoxelCount)<<LOD;
|
|
const std::int64_t TileStep=TileExtent/Cells;
|
|
const std::int64_t OriginX=static_cast<std::int64_t>(Key.coordinate.x)*TileExtent;
|
|
const std::int64_t OriginY=static_cast<std::int64_t>(Key.coordinate.y)*TileExtent;
|
|
const std::int64_t OriginZ=static_cast<std::int64_t>(Key.coordinate.z)*TileExtent;
|
|
const std::int64_t TileMinimumZ=OriginZ;
|
|
const std::int64_t TileMaximumZ=OriginZ+TileExtent;
|
|
const auto PlanIt=std::find_if(Assembly->plan.strates.begin(),
|
|
Assembly->plan.strates.end(),[TileMinimumZ,TileMaximumZ,TileVoxelCount](const auto& Plan)
|
|
{
|
|
const std::int64_t PlanMinimumZ=static_cast<std::int64_t>(
|
|
std::min(Plan.bottomChunkZ,Plan.topChunkZ))*TileVoxelCount;
|
|
const std::int64_t PlanMaximumZ=(static_cast<std::int64_t>(
|
|
std::max(Plan.bottomChunkZ,Plan.topChunkZ))+1)*TileVoxelCount;
|
|
return TileMinimumZ<=PlanMaximumZ&&TileMaximumZ>=PlanMinimumZ;
|
|
});
|
|
if(PlanIt==Assembly->plan.strates.end())continue;
|
|
|
|
const VoxelForgeCore::Heightfield2D Field(Assembly->recipe,*PlanIt);
|
|
if(!Field.isValid())continue;
|
|
const std::array<std::int64_t,3> Minimum={
|
|
(OriginX-TileStep)*Subvoxels,(OriginY-TileStep)*Subvoxels,
|
|
(OriginZ-TileStep)*Subvoxels};
|
|
const std::array<std::int64_t,3> Maximum={
|
|
(OriginX+TileExtent+TileStep)*Subvoxels,
|
|
(OriginY+TileExtent+TileStep)*Subvoxels,
|
|
(OriginZ+TileExtent+TileStep)*Subvoxels};
|
|
std::vector<VoxelForgeCore::WorldAssemblyFeatureQueryV1> Queries;
|
|
Queries.push_back({Field.recipe(),Field.plan(),Field.buildFeatureTileList(Minimum,Maximum)});
|
|
const VoxelForgeCore::ScopedWorldAssemblyFeatureQueriesV1 QueryScope{
|
|
std::span<const VoxelForgeCore::WorldAssemblyFeatureQueryV1>(Queries)};
|
|
const bool bHasFiniteFeature=std::any_of(PlanIt->features.begin(),
|
|
PlanIt->features.end(),[&](const auto& Feature)
|
|
{
|
|
return (Feature.type==2||Feature.type==4)
|
|
&&Feature.minimumSubvoxels[0]<=Maximum[0]
|
|
&&Feature.maximumSubvoxels[0]>=Minimum[0]
|
|
&&Feature.minimumSubvoxels[1]<=Maximum[1]
|
|
&&Feature.maximumSubvoxels[1]>=Minimum[1]
|
|
&&Feature.minimumSubvoxels[2]<=Maximum[2]
|
|
&&Feature.maximumSubvoxels[2]>=Minimum[2];
|
|
});
|
|
if(bHasFiniteFeature)++FiniteFeatureTiles;
|
|
|
|
std::vector<FProbeColumn> Exact(static_cast<std::size_t>(Cells+1)*(Cells+1));
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true);
|
|
for(std::int32_t Y=0;Y<=Cells;++Y)
|
|
for(std::int32_t X=0;X<=Cells;++X)
|
|
{
|
|
const auto Sample=Field.point((OriginX+X*TileStep)*Subvoxels,
|
|
(OriginY+Y*TileStep)*Subvoxels,0);
|
|
Exact[static_cast<std::size_t>(Y)*(Cells+1)+X]={
|
|
Sample.groundHeightSubvoxels,Sample.capHeightSubvoxels};
|
|
}
|
|
const auto& BaselineStats=VoxelForgeCore::Heightfield2D::threadColumnCacheStats();
|
|
BaselineColumnMs.Add(BaselineStats.columnComputeMilliseconds);
|
|
BaselineColumns+=BaselineStats.columnMisses;
|
|
|
|
std::vector<std::int32_t> Axis;
|
|
for(std::int32_t Index=0;Index<=Cells;Index+=Stride)Axis.push_back(Index);
|
|
if(Axis.empty()||Axis.back()!=Cells)Axis.push_back(Cells);
|
|
std::vector<FProbeColumn> Coarse(static_cast<std::size_t>(Axis.size())*Axis.size());
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true);
|
|
for(std::size_t Y=0;Y<Axis.size();++Y)
|
|
for(std::size_t X=0;X<Axis.size();++X)
|
|
{
|
|
const auto Sample=Field.point((OriginX+Axis[X]*TileStep)*Subvoxels,
|
|
(OriginY+Axis[Y]*TileStep)*Subvoxels,0);
|
|
Coarse[Y*Axis.size()+X]={Sample.groundHeightSubvoxels,
|
|
Sample.capHeightSubvoxels};
|
|
}
|
|
const auto& CandidateStats=VoxelForgeCore::Heightfield2D::threadColumnCacheStats();
|
|
CandidateColumnMs.Add(CandidateStats.columnComputeMilliseconds);
|
|
CandidateColumns+=CandidateStats.columnMisses;
|
|
const auto Bracket=[&Axis](std::int32_t Value)
|
|
{
|
|
const auto Upper=std::upper_bound(Axis.begin(),Axis.end(),Value);
|
|
const std::size_t High=Upper==Axis.end()?Axis.size()-1
|
|
:static_cast<std::size_t>(Upper-Axis.begin());
|
|
const std::size_t Low=High==0?0:High-1;
|
|
return std::pair<std::size_t,std::size_t>{Low,High};
|
|
};
|
|
const auto Lerp=[](double A,double B,double T){return A+(B-A)*T;};
|
|
for(std::int32_t Y=0;Y<=Cells;++Y)
|
|
for(std::int32_t X=0;X<=Cells;++X)
|
|
{
|
|
const auto [X0,X1]=Bracket(X);
|
|
const auto [Y0,Y1]=Bracket(Y);
|
|
const double TX=Axis[X1]==Axis[X0]?0.0
|
|
:static_cast<double>(X-Axis[X0])/(Axis[X1]-Axis[X0]);
|
|
const double TY=Axis[Y1]==Axis[Y0]?0.0
|
|
:static_cast<double>(Y-Axis[Y0])/(Axis[Y1]-Axis[Y0]);
|
|
const auto& C00=Coarse[Y0*Axis.size()+X0];
|
|
const auto& C10=Coarse[Y0*Axis.size()+X1];
|
|
const auto& C01=Coarse[Y1*Axis.size()+X0];
|
|
const auto& C11=Coarse[Y1*Axis.size()+X1];
|
|
const double Ground=Lerp(Lerp(static_cast<double>(C00.Ground),
|
|
static_cast<double>(C10.Ground),TX),Lerp(static_cast<double>(C01.Ground),
|
|
static_cast<double>(C11.Ground),TX),TY);
|
|
const double Cap=Lerp(Lerp(static_cast<double>(C00.Cap),
|
|
static_cast<double>(C10.Cap),TX),Lerp(static_cast<double>(C01.Cap),
|
|
static_cast<double>(C11.Cap),TX),TY);
|
|
const auto& Reference=Exact[static_cast<std::size_t>(Y)*(Cells+1)+X];
|
|
GroundErrorsCm.Add(std::abs(Ground-Reference.Ground)*CoreVoxelSizeCm/Subvoxels);
|
|
CapErrorsCm.Add(std::abs(Cap-Reference.Cap)*CoreVoxelSizeCm/Subvoxels);
|
|
}
|
|
++Tiles;
|
|
}
|
|
std::uint64_t GroundOver2Cm=0,CapOver2Cm=0;
|
|
for(const double Error:GroundErrorsCm)if(Error>2.0)++GroundOver2Cm;
|
|
for(const double Error:CapErrorsCm)if(Error>2.0)++CapOver2Cm;
|
|
double BaselineAverageMs=0.0,CandidateAverageMs=0.0;
|
|
for(const double Value:BaselineColumnMs)BaselineAverageMs+=Value;
|
|
for(const double Value:CandidateColumnMs)CandidateAverageMs+=Value;
|
|
if(!BaselineColumnMs.IsEmpty())BaselineAverageMs/=BaselineColumnMs.Num();
|
|
if(!CandidateColumnMs.IsEmpty())CandidateAverageMs/=CandidateColumnMs.Num();
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[WorldAssemblerInterpolationProbe] label=%s lod=%d tiles=%d stride_samples=%d exact_column_evals=%llu coarse_column_evals=%llu column_eval_saving_pct=%.2f baseline_column_avg_ms=%.5f coarse_column_avg_ms=%.5f ground_error_p95_cm=%.4f ground_error_max_cm=%.4f ground_over_2cm=%llu/%d cap_error_p95_cm=%.4f cap_error_max_cm=%.4f cap_over_2cm=%llu/%d finite_feature_tiles=%llu fallback=none"),
|
|
TEXT("surface"),LOD,Tiles,Stride,
|
|
static_cast<unsigned long long>(BaselineColumns),
|
|
static_cast<unsigned long long>(CandidateColumns),
|
|
BaselineAverageMs>0.0?100.0*(1.0-CandidateAverageMs/BaselineAverageMs):0.0,
|
|
BaselineAverageMs,CandidateAverageMs,
|
|
P95(GroundErrorsCm),MaxValue(GroundErrorsCm),
|
|
static_cast<unsigned long long>(GroundOver2Cm),GroundErrorsCm.Num(),
|
|
P95(CapErrorsCm),MaxValue(CapErrorsCm),
|
|
static_cast<unsigned long long>(CapOver2Cm),CapErrorsCm.Num(),
|
|
static_cast<unsigned long long>(FiniteFeatureTiles));
|
|
}
|
|
}
|
|
for(const std::int32_t LOD:{0,1,3,4,5,8})
|
|
for(const std::uint32_t Drop:{1u,2u})
|
|
{
|
|
const std::int32_t Cells=LOD<World.configuration().fullResolutionLodCount
|
|
?TileVoxelCount:std::max(1,World.configuration().coarseTileCells);
|
|
TArray<double> GroundErrorsCm,CapErrorsCm,BaselineColumnMs,CandidateColumnMs;
|
|
std::uint64_t BaselineColumns=0,CandidateColumns=0,FiniteFeatureTiles=0;
|
|
std::int32_t Tiles=0;
|
|
for(const auto& Key:Keys)
|
|
{
|
|
if(Key.lod!=LOD||Tiles>=2)continue;
|
|
const std::int64_t TileExtent=static_cast<std::int64_t>(TileVoxelCount)<<LOD;
|
|
const std::int64_t TileStep=TileExtent/Cells;
|
|
const std::int64_t OriginX=static_cast<std::int64_t>(Key.coordinate.x)*TileExtent;
|
|
const std::int64_t OriginY=static_cast<std::int64_t>(Key.coordinate.y)*TileExtent;
|
|
const std::int64_t OriginZ=static_cast<std::int64_t>(Key.coordinate.z)*TileExtent;
|
|
const std::int64_t TileMinimumZ=OriginZ,TileMaximumZ=OriginZ+TileExtent;
|
|
const auto PlanIt=std::find_if(Assembly->plan.strates.begin(),
|
|
Assembly->plan.strates.end(),[TileMinimumZ,TileMaximumZ,TileVoxelCount](const auto& Plan)
|
|
{
|
|
const std::int64_t PlanMinimumZ=static_cast<std::int64_t>(
|
|
std::min(Plan.bottomChunkZ,Plan.topChunkZ))*TileVoxelCount;
|
|
const std::int64_t PlanMaximumZ=(static_cast<std::int64_t>(
|
|
std::max(Plan.bottomChunkZ,Plan.topChunkZ))+1)*TileVoxelCount;
|
|
return TileMinimumZ<=PlanMaximumZ&&TileMaximumZ>=PlanMinimumZ;
|
|
});
|
|
if(PlanIt==Assembly->plan.strates.end())continue;
|
|
const VoxelForgeCore::Heightfield2D Field(Assembly->recipe,*PlanIt);
|
|
if(!Field.isValid())continue;
|
|
const std::array<std::int64_t,3> Minimum={
|
|
(OriginX-TileStep)*Subvoxels,(OriginY-TileStep)*Subvoxels,
|
|
(OriginZ-TileStep)*Subvoxels};
|
|
const std::array<std::int64_t,3> Maximum={
|
|
(OriginX+TileExtent+TileStep)*Subvoxels,
|
|
(OriginY+TileExtent+TileStep)*Subvoxels,
|
|
(OriginZ+TileExtent+TileStep)*Subvoxels};
|
|
std::vector<VoxelForgeCore::WorldAssemblyFeatureQueryV1> Queries;
|
|
Queries.push_back({Field.recipe(),Field.plan(),Field.buildFeatureTileList(Minimum,Maximum)});
|
|
const VoxelForgeCore::ScopedWorldAssemblyFeatureQueriesV1 QueryScope{
|
|
std::span<const VoxelForgeCore::WorldAssemblyFeatureQueryV1>(Queries)};
|
|
const bool bHasFiniteFeature=std::any_of(PlanIt->features.begin(),
|
|
PlanIt->features.end(),[&](const auto& Feature)
|
|
{
|
|
return (Feature.type==2||Feature.type==4)
|
|
&&Feature.minimumSubvoxels[0]<=Maximum[0]
|
|
&&Feature.maximumSubvoxels[0]>=Minimum[0]
|
|
&&Feature.minimumSubvoxels[1]<=Maximum[1]
|
|
&&Feature.maximumSubvoxels[1]>=Minimum[1]
|
|
&&Feature.minimumSubvoxels[2]<=Maximum[2]
|
|
&&Feature.maximumSubvoxels[2]>=Minimum[2];
|
|
});
|
|
if(bHasFiniteFeature)++FiniteFeatureTiles;
|
|
std::vector<FProbeColumn> Exact(static_cast<std::size_t>(Cells+1)*(Cells+1));
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true,0);
|
|
for(std::int32_t Y=0;Y<=Cells;++Y)
|
|
for(std::int32_t X=0;X<=Cells;++X)
|
|
{
|
|
const auto Sample=Field.point((OriginX+X*TileStep)*Subvoxels,
|
|
(OriginY+Y*TileStep)*Subvoxels,0);
|
|
Exact[static_cast<std::size_t>(Y)*(Cells+1)+X]={
|
|
Sample.groundHeightSubvoxels,Sample.capHeightSubvoxels};
|
|
}
|
|
const auto& BaselineStats=VoxelForgeCore::Heightfield2D::threadColumnCacheStats();
|
|
BaselineColumnMs.Add(BaselineStats.columnComputeMilliseconds);
|
|
BaselineColumns+=BaselineStats.columnMisses;
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true,Drop);
|
|
for(std::int32_t Y=0;Y<=Cells;++Y)
|
|
for(std::int32_t X=0;X<=Cells;++X)
|
|
{
|
|
const auto Sample=Field.point((OriginX+X*TileStep)*Subvoxels,
|
|
(OriginY+Y*TileStep)*Subvoxels,0);
|
|
GroundErrorsCm.Add(std::abs(static_cast<double>(Sample.groundHeightSubvoxels)
|
|
-Exact[static_cast<std::size_t>(Y)*(Cells+1)+X].Ground)
|
|
*CoreVoxelSizeCm/Subvoxels);
|
|
CapErrorsCm.Add(std::abs(static_cast<double>(Sample.capHeightSubvoxels)
|
|
-Exact[static_cast<std::size_t>(Y)*(Cells+1)+X].Cap)
|
|
*CoreVoxelSizeCm/Subvoxels);
|
|
}
|
|
const auto& CandidateStats=VoxelForgeCore::Heightfield2D::threadColumnCacheStats();
|
|
CandidateColumnMs.Add(CandidateStats.columnComputeMilliseconds);
|
|
CandidateColumns+=CandidateStats.columnMisses;
|
|
++Tiles;
|
|
}
|
|
std::uint64_t GroundOver2Cm=0,CapOver2Cm=0;
|
|
for(const double Error:GroundErrorsCm)if(Error>2.0)++GroundOver2Cm;
|
|
for(const double Error:CapErrorsCm)if(Error>2.0)++CapOver2Cm;
|
|
double BaselineAverageMs=0.0,CandidateAverageMs=0.0;
|
|
for(const double Value:BaselineColumnMs)BaselineAverageMs+=Value;
|
|
for(const double Value:CandidateColumnMs)CandidateAverageMs+=Value;
|
|
if(!BaselineColumnMs.IsEmpty())BaselineAverageMs/=BaselineColumnMs.Num();
|
|
if(!CandidateColumnMs.IsEmpty())CandidateAverageMs/=CandidateColumnMs.Num();
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[WorldAssemblerFrequencyProbe] label=surface lod=%d tiles=%d drop_highest_octaves=%d exact_column_evals=%llu reduced_column_evals=%llu column_eval_saving_pct=%.2f baseline_column_avg_ms=%.5f reduced_column_avg_ms=%.5f ground_error_p95_cm=%.4f ground_error_max_cm=%.4f ground_over_2cm=%llu/%d cap_error_p95_cm=%.4f cap_error_max_cm=%.4f cap_over_2cm=%llu/%d finite_feature_tiles=%llu"),
|
|
LOD,Tiles,static_cast<std::int32_t>(Drop),
|
|
static_cast<unsigned long long>(BaselineColumns),
|
|
static_cast<unsigned long long>(CandidateColumns),
|
|
BaselineAverageMs>0.0?100.0*(1.0-CandidateAverageMs/BaselineAverageMs):0.0,
|
|
BaselineAverageMs,CandidateAverageMs,
|
|
P95(GroundErrorsCm),MaxValue(GroundErrorsCm),
|
|
static_cast<unsigned long long>(GroundOver2Cm),GroundErrorsCm.Num(),
|
|
P95(CapErrorsCm),MaxValue(CapErrorsCm),
|
|
static_cast<unsigned long long>(CapOver2Cm),CapErrorsCm.Num(),
|
|
static_cast<unsigned long long>(FiniteFeatureTiles));
|
|
}
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true,0);
|
|
}
|
|
for (const int32 LOD : {0,1,2,3,4,5,6,7,8})
|
|
{
|
|
TArray<double> FullTimes,CandidateTimes,FullShellTimes,CandidateShellTimes,ListTimes;
|
|
TArray<double> UnknownBoxes,FieldCalls,UnclampedFieldCalls,ColumnMisses,ColumnConflictMisses,StratumShellTimes,ConnectorShellTimes;
|
|
TArray<double> DensitySamples,ClassificationTimes,ClassificationExclusiveTimes;
|
|
TArray<double> ColumnComputeTimes,MeshingTimes,HorizonTimes,RepairSkipped;
|
|
TArray<double> ColumnEvalExclusiveTimes,MarchingExclusiveTimes,GroundRefineExclusiveTimes;
|
|
TArray<double> CapRefineExclusiveTimes,MeshAssemblyTimes;
|
|
TArray<double> MarchingColumnTimes,GroundColumnTimes,CapColumnTimes;
|
|
TArray<double> ClassificationNsPerSample,ColumnNsPerSample,MarchingNsPerSample;
|
|
TArray<double> GroundNsPerSample,CapNsPerSample,AssemblyNsPerSample,ListNsPerSample;
|
|
TArray<double> MarchingColumnNsPerSample,GroundColumnNsPerSample,CapColumnNsPerSample;
|
|
TArray<double> McProfileTotalTimes,McProfileGridTimes,McProfileGridExclusiveTimes;
|
|
TArray<double> McProfileCellTimes,McProfileNormalTimes,McProfileInterpolationTimes;
|
|
TArray<double> McProfileStreamTimes,McProfileOtherTimes,McProfileResidualTimes;
|
|
TArray<double> McProfileGradientFallbackCalls,McProfileEdgeMidpointCalls,McProfileGradientMemoHits;
|
|
TArray<double> McProfileGridNsPerSample,McProfileGridExclusiveNsPerSample;
|
|
TArray<double> McProfileCellNsPerSample,McProfileNormalNsPerSample;
|
|
TArray<double> McProfileInterpolationNsPerSample,McProfileStreamNsPerSample;
|
|
TArray<double> McProfileOtherNsPerSample,McProfileResidualNsPerSample;
|
|
TArray<double> StratumFieldCalls,ConnectorFieldCalls,ShellTriangles,GroundTriangles,GroundRemoved,CapTriangles;
|
|
const bool bMcStageAudit=FParse::Param(FCommandLine::Get(),TEXT("coremcstageaudit"));
|
|
int32 AuditDirectMappedColumnCache=0,AuditMemoizeMcGradients=1;
|
|
FParse::Value(FCommandLine::Get(),TEXT("voxel.CoreColumnCacheDirectMapped="),
|
|
AuditDirectMappedColumnCache);
|
|
FParse::Value(FCommandLine::Get(),TEXT("voxel.CoreMCGradientMemoization="),
|
|
AuditMemoizeMcGradients);
|
|
for (const auto& Key : Keys)
|
|
{
|
|
if (Key.lod!=LOD || FullTimes.Num()>=8) continue;
|
|
VoxelForgeCore::TileGenerationOptions Options;
|
|
Options.hashDensity=true;
|
|
Options.useAssemblyFeatureQueries=false;
|
|
Options.legacyDirectMappedColumnCache=AuditDirectMappedColumnCache!=0;
|
|
Options.memoizeMcGradients=AuditMemoizeMcGradients!=0;
|
|
std::string Error;
|
|
// Check density and mesh parity with hashes, then time uncached runtime generation.
|
|
// Verifier la parite avec hashes, puis mesurer la generation sans cache de colonnes.
|
|
const auto FullParity=World.generateTile(Key,Options,Error);
|
|
if (!FullParity) return false;
|
|
Options.useAssemblyFeatureQueries=true;
|
|
const auto CandidateParity=World.generateTile(Key,Options,Error);
|
|
if (!CandidateParity||FullParity->densityHash!=CandidateParity->densityHash
|
|
||FullParity->meshHash!=CandidateParity->meshHash
|
|
||FullParity->payloadHash!=CandidateParity->payloadHash
|
|
||FullParity->topologyHash!=CandidateParity->topologyHash)
|
|
{ OutError=TEXT("Shared feature full/candidate tile hashes differ."); return false; }
|
|
Options.hashDensity=false;
|
|
Options.useAssemblyFeatureQueries=false;
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true);
|
|
const double FullStart=FPlatformTime::Seconds();
|
|
auto Full=World.generateTile(Key,Options,Error);
|
|
FullTimes.Add((FPlatformTime::Seconds()-FullStart)*1000.0);
|
|
Options.useAssemblyFeatureQueries=true;
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true);
|
|
const double CandidateStart=FPlatformTime::Seconds();
|
|
auto Candidate=World.generateTile(Key,Options,Error);
|
|
CandidateTimes.Add((FPlatformTime::Seconds()-CandidateStart)*1000.0);
|
|
if (!Full||!Candidate)
|
|
{ OutError=TEXT("Shared feature full/candidate measured generation failed."); return false; }
|
|
FullShellTimes.Add(Full->diagnostics.farShellMilliseconds);
|
|
CandidateShellTimes.Add(Candidate->diagnostics.farShellMilliseconds);
|
|
ListTimes.Add(Candidate->diagnostics.featureListMilliseconds);
|
|
const auto& Diagnostics=Candidate->diagnostics;
|
|
const double SampleCount=static_cast<double>(Candidate->densitySampleCount);
|
|
DensitySamples.Add(static_cast<double>(Candidate->densitySampleCount));
|
|
UnknownBoxes.Add(static_cast<double>(Diagnostics.unknownBoxes));
|
|
FieldCalls.Add(static_cast<double>(Diagnostics.fieldCalls));
|
|
UnclampedFieldCalls.Add(static_cast<double>(Diagnostics.unclampedFieldCalls));
|
|
ColumnMisses.Add(static_cast<double>(Diagnostics.columnCacheMisses));
|
|
ColumnConflictMisses.Add(static_cast<double>(Diagnostics.columnCacheConflictMisses));
|
|
ClassificationTimes.Add(Diagnostics.classificationMilliseconds);
|
|
ClassificationExclusiveTimes.Add(Diagnostics.classificationExclusiveMilliseconds);
|
|
ColumnComputeTimes.Add(Diagnostics.columnComputeMilliseconds);
|
|
ColumnEvalExclusiveTimes.Add(Diagnostics.columnEvaluationExclusiveMilliseconds);
|
|
MarchingExclusiveTimes.Add(Diagnostics.marchingCubesExclusiveMilliseconds);
|
|
MarchingColumnTimes.Add(Diagnostics.marchingCubesColumnComputeMilliseconds);
|
|
GroundRefineExclusiveTimes.Add(Diagnostics.groundRefinementExclusiveMilliseconds);
|
|
GroundColumnTimes.Add(Diagnostics.groundRefinementColumnComputeMilliseconds);
|
|
CapRefineExclusiveTimes.Add(Diagnostics.capRefinementExclusiveMilliseconds);
|
|
CapColumnTimes.Add(Diagnostics.capRefinementColumnComputeMilliseconds);
|
|
MeshAssemblyTimes.Add(Diagnostics.meshAssemblyMilliseconds);
|
|
MeshingTimes.Add(Diagnostics.meshingMilliseconds);
|
|
HorizonTimes.Add(Diagnostics.farHorizonMilliseconds);
|
|
RepairSkipped.Add(Diagnostics.farShellRepairSkipped?1.0:0.0);
|
|
StratumShellTimes.Add(Diagnostics.farStratumShellMilliseconds);
|
|
ConnectorShellTimes.Add(Diagnostics.farConnectorShellMilliseconds);
|
|
StratumFieldCalls.Add(static_cast<double>(Diagnostics.farStratumFieldCalls));
|
|
ConnectorFieldCalls.Add(static_cast<double>(Diagnostics.farConnectorFieldCalls));
|
|
ShellTriangles.Add(static_cast<double>(Diagnostics.farShellTrianglesAdded));
|
|
GroundTriangles.Add(static_cast<double>(Diagnostics.horizonGroundTriangles));
|
|
GroundRemoved.Add(static_cast<double>(Diagnostics.horizonGroundTrianglesRemoved));
|
|
CapTriangles.Add(static_cast<double>(Diagnostics.horizonCapTriangles));
|
|
if(SampleCount>0.0)
|
|
{
|
|
ClassificationNsPerSample.Add(Diagnostics.classificationExclusiveMilliseconds
|
|
*1000000.0/SampleCount);
|
|
ColumnNsPerSample.Add(Diagnostics.columnEvaluationExclusiveMilliseconds
|
|
*1000000.0/SampleCount);
|
|
MarchingColumnNsPerSample.Add(Diagnostics.marchingCubesColumnComputeMilliseconds
|
|
*1000000.0/SampleCount);
|
|
MarchingNsPerSample.Add(Diagnostics.marchingCubesExclusiveMilliseconds
|
|
*1000000.0/SampleCount);
|
|
GroundColumnNsPerSample.Add(Diagnostics.groundRefinementColumnComputeMilliseconds
|
|
*1000000.0/SampleCount);
|
|
GroundNsPerSample.Add(Diagnostics.groundRefinementExclusiveMilliseconds
|
|
*1000000.0/SampleCount);
|
|
CapColumnNsPerSample.Add(Diagnostics.capRefinementColumnComputeMilliseconds
|
|
*1000000.0/SampleCount);
|
|
CapNsPerSample.Add(Diagnostics.capRefinementExclusiveMilliseconds
|
|
*1000000.0/SampleCount);
|
|
AssemblyNsPerSample.Add(Diagnostics.meshAssemblyMilliseconds
|
|
*1000000.0/SampleCount);
|
|
ListNsPerSample.Add(Diagnostics.featureListMilliseconds*1000000.0/SampleCount);
|
|
}
|
|
if(bMcStageAudit)
|
|
{
|
|
// Profile a separate cold tile so the audit's ordinary timing rows keep the
|
|
// uninstrumented path and comparable clock scope.
|
|
// Profiler une tuile froide separee pour garder les mesures ordinaires sans instrumentation.
|
|
Options.profileMesherStages=true;
|
|
VoxelForgeCore::Heightfield2D::resetThreadColumnCacheStats(false,true);
|
|
auto Profiled=World.generateTile(Key,Options,Error);
|
|
Options.profileMesherStages=false;
|
|
if(!Profiled)
|
|
{ OutError=TEXT("Cold MC stage profile tile generation failed."); return false; }
|
|
const auto& Mc=Profiled->diagnostics;
|
|
const double Total=Mc.marchingCubesExclusiveMilliseconds
|
|
+Mc.marchingCubesColumnComputeMilliseconds;
|
|
const double Grid=Mc.marchingCubesDensityGridMilliseconds;
|
|
const double GridExclusive=FMath::Max(0.0,Grid
|
|
-Mc.marchingCubesColumnComputeMilliseconds);
|
|
const double Cell=Mc.marchingCubesCellClassificationMilliseconds;
|
|
const double Normal=Mc.marchingCubesGradientNormalsMilliseconds;
|
|
const double Interpolation=Mc.marchingCubesEdgeInterpolationMilliseconds;
|
|
const double Stream=Mc.marchingCubesVertexDedupStreamMilliseconds;
|
|
const double Other=Mc.marchingCubesOtherMilliseconds;
|
|
const double Residual=FMath::Max(0.0,Mc.marchingCubesExclusiveMilliseconds
|
|
-GridExclusive-Cell-Normal-Interpolation-Stream);
|
|
McProfileTotalTimes.Add(Total);
|
|
McProfileGridTimes.Add(Grid);
|
|
McProfileGridExclusiveTimes.Add(GridExclusive);
|
|
McProfileCellTimes.Add(Cell);
|
|
McProfileNormalTimes.Add(Normal);
|
|
McProfileInterpolationTimes.Add(Interpolation);
|
|
McProfileStreamTimes.Add(Stream);
|
|
McProfileOtherTimes.Add(Other);
|
|
McProfileResidualTimes.Add(Residual);
|
|
McProfileGradientFallbackCalls.Add(static_cast<double>(
|
|
Mc.marchingCubesGradientFallbackDensityCalls));
|
|
McProfileEdgeMidpointCalls.Add(static_cast<double>(
|
|
Mc.marchingCubesEdgeMidpointDensityCalls));
|
|
McProfileGradientMemoHits.Add(static_cast<double>(
|
|
Mc.marchingCubesGradientMemoHits));
|
|
const double ProfileSamples=static_cast<double>(Profiled->densitySampleCount);
|
|
if(ProfileSamples>0.0)
|
|
{
|
|
McProfileGridNsPerSample.Add(Grid*1000000.0/ProfileSamples);
|
|
McProfileGridExclusiveNsPerSample.Add(GridExclusive*1000000.0/ProfileSamples);
|
|
McProfileCellNsPerSample.Add(Cell*1000000.0/ProfileSamples);
|
|
McProfileNormalNsPerSample.Add(Normal*1000000.0/ProfileSamples);
|
|
McProfileInterpolationNsPerSample.Add(Interpolation*1000000.0/ProfileSamples);
|
|
McProfileStreamNsPerSample.Add(Stream*1000000.0/ProfileSamples);
|
|
McProfileOtherNsPerSample.Add(Other*1000000.0/ProfileSamples);
|
|
McProfileResidualNsPerSample.Add(Residual*1000000.0/ProfileSamples);
|
|
}
|
|
}
|
|
}
|
|
const auto P95=[](TArray<double> Values)
|
|
{ Values.Sort(); return Values.IsEmpty() ? 0.0 : Values[FMath::Clamp(
|
|
FMath::CeilToInt(Values.Num()*0.95f)-1,0,Values.Num()-1)]; };
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeSharedFeatureCost] label=%s lod=%d column_cache_direct_mapped=%d gradient_memoization=%d tiles=%d hashes_identical=1 full_p95_ms=%.5f candidate_p95_ms=%.5f list_p95_ms=%.5f classification_wall_p95_ms=%.5f classification_exclusive_p95_ms=%.5f column_compute_p95_ms=%.5f column_eval_exclusive_p95_ms=%.5f mc_column_compute_p95_ms=%.5f mc_exclusive_p95_ms=%.5f ground_column_compute_p95_ms=%.5f ground_refine_exclusive_p95_ms=%.5f cap_column_compute_p95_ms=%.5f cap_refine_exclusive_p95_ms=%.5f mesh_assembly_p95_ms=%.5f meshing_p95_ms=%.5f horizon_p95_ms=%.5f samples_p95=%.0f classification_ns_per_sample_p95=%.2f column_ns_per_sample_p95=%.2f mc_column_ns_per_sample_p95=%.2f mc_ns_per_sample_p95=%.2f ground_column_ns_per_sample_p95=%.2f ground_ns_per_sample_p95=%.2f cap_column_ns_per_sample_p95=%.2f cap_ns_per_sample_p95=%.2f assembly_ns_per_sample_p95=%.2f feature_list_ns_per_sample_p95=%.2f full_shell_p95_ms=%.5f candidate_shell_p95_ms=%.5f repair_skipped_p95=%.0f unknown_boxes_p95=%.0f field_calls_p95=%.0f unclamped_field_calls_p95=%.0f column_misses_p95=%.0f column_conflict_misses_p95=%.0f stratum_shell_p95_ms=%.5f stratum_field_calls_p95=%.0f connector_shell_p95_ms=%.5f connector_field_calls_p95=%.0f shell_triangles_p95=%.0f horizon_ground_triangles_p95=%.0f horizon_ground_removed_p95=%.0f horizon_cap_triangles_p95=%.0f"),
|
|
Label,LOD,AuditDirectMappedColumnCache!=0?1:0,AuditMemoizeMcGradients!=0?1:0,
|
|
FullTimes.Num(),P95(FullTimes),P95(CandidateTimes),P95(ListTimes),
|
|
P95(ClassificationTimes),P95(ClassificationExclusiveTimes),P95(ColumnComputeTimes),
|
|
P95(ColumnEvalExclusiveTimes),P95(MarchingColumnTimes),P95(MarchingExclusiveTimes),
|
|
P95(GroundColumnTimes),P95(GroundRefineExclusiveTimes),P95(CapColumnTimes),
|
|
P95(CapRefineExclusiveTimes),
|
|
P95(MeshAssemblyTimes),P95(MeshingTimes),P95(HorizonTimes),P95(DensitySamples),
|
|
P95(ClassificationNsPerSample),P95(ColumnNsPerSample),P95(MarchingColumnNsPerSample),
|
|
P95(MarchingNsPerSample),P95(GroundColumnNsPerSample),P95(GroundNsPerSample),
|
|
P95(CapColumnNsPerSample),P95(CapNsPerSample),P95(AssemblyNsPerSample),P95(ListNsPerSample),
|
|
P95(FullShellTimes),P95(CandidateShellTimes),P95(RepairSkipped),P95(UnknownBoxes),P95(FieldCalls),
|
|
P95(UnclampedFieldCalls),
|
|
P95(ColumnMisses),P95(ColumnConflictMisses),P95(StratumShellTimes),P95(StratumFieldCalls),
|
|
P95(ConnectorShellTimes),P95(ConnectorFieldCalls),P95(ShellTriangles),
|
|
P95(GroundTriangles),P95(GroundRemoved),P95(CapTriangles));
|
|
if(bMcStageAudit)
|
|
{
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeMCStageCost] label=%s lod=%d tiles=%d sampling=row_total_and_1_of_8_cells total_p95_ms=%.5f density_grid_inclusive_p95_ms=%.5f density_grid_ex_column_p95_ms=%.5f cell_classification_p95_ms=%.5f gradient_normals_p95_ms=%.5f edge_interpolation_p95_ms=%.5f vertex_dedup_stream_p95_ms=%.5f setup_finalization_p95_ms=%.5f unassigned_residual_p95_ms=%.5f grid_ns_per_sample_p95=%.2f grid_ex_column_ns_per_sample_p95=%.2f cell_ns_per_sample_p95=%.2f normals_ns_per_sample_p95=%.2f interpolation_ns_per_sample_p95=%.2f dedup_stream_ns_per_sample_p95=%.2f setup_finalization_ns_per_sample_p95=%.2f residual_ns_per_sample_p95=%.2f gradient_fallback_density_calls_p95=%.0f edge_midpoint_density_probes_p95=%.0f gradient_memo_hits_p95=%.0f"),
|
|
Label,LOD,McProfileTotalTimes.Num(),P95(McProfileTotalTimes),P95(McProfileGridTimes),
|
|
P95(McProfileGridExclusiveTimes),P95(McProfileCellTimes),P95(McProfileNormalTimes),
|
|
P95(McProfileInterpolationTimes),P95(McProfileStreamTimes),P95(McProfileOtherTimes),
|
|
P95(McProfileResidualTimes),P95(McProfileGridNsPerSample),
|
|
P95(McProfileGridExclusiveNsPerSample),P95(McProfileCellNsPerSample),
|
|
P95(McProfileNormalNsPerSample),P95(McProfileInterpolationNsPerSample),
|
|
P95(McProfileStreamNsPerSample),P95(McProfileOtherNsPerSample),
|
|
P95(McProfileResidualNsPerSample),P95(McProfileGradientFallbackCalls),
|
|
P95(McProfileEdgeMidpointCalls),P95(McProfileGradientMemoHits));
|
|
}
|
|
}
|
|
if(Assembly!=nullptr&&!Assembly->plan.strates.empty())
|
|
{
|
|
if(FParse::Param(FCommandLine::Get(),TEXT("corefeatureauditonly"))
|
|
&&!RunCoreFarCapTileCensus(World,*Assembly,OutError))return false;
|
|
if(!RunCoreFarHorizonAgreementAudit(World,Keys,*Assembly,OutError))return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreContentPlacementCostAudit(AVoxelWorld* Actor, const FVector& CoreLocalProbeCm,
|
|
const TCHAR* Label, FString& OutError);
|
|
|
|
bool RunCoreFloatingIslandActorIntegrationTest(
|
|
const FExploreArguments& Arguments, FString& OutError)
|
|
{
|
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
|
nullptr, *Arguments.SettingsReference);
|
|
if (AuthoredSettings == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not load Core island test settings '%s'."),
|
|
*Arguments.SettingsReference);
|
|
return false;
|
|
}
|
|
float StagedWalkSpeedCmPerSecond = 0.0f;
|
|
float StagedSprintSpeedCmPerSecond = 0.0f;
|
|
if (Arguments.bCoreWorldStreamTraceOnly)
|
|
{
|
|
StagedWalkSpeedCmPerSecond = 600.0f;
|
|
StagedSprintSpeedCmPerSecond = 6000.0f;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgePawnSpeedFixture] source=trace_fixture_defaults walk_cm_s=%.3f sprint_cm_s=%.3f"),
|
|
StagedWalkSpeedCmPerSecond, StagedSprintSpeedCmPerSecond);
|
|
}
|
|
else if (!ReadStagedPlayerSpeeds(
|
|
StagedWalkSpeedCmPerSecond, StagedSprintSpeedCmPerSecond, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int32 EffectiveSeed = ResolveCoreTestWorldSeed(AuthoredSettings);
|
|
if (const UVoxelSeasonAsset* AuthoredSeason = AuthoredSettings->Season.LoadSynchronous())
|
|
EffectiveSeed = AuthoredSeason->GetSeasonSeed();
|
|
TStrongObjectPtr<UVoxelSettings> OwnerSurfaceSettings;
|
|
TStrongObjectPtr<UVoxelStrateDefinition> OwnerSurfaceDefinition;
|
|
if (!CreateLoggedOwnerSurfaceSettingsCopy(AuthoredSettings, EffectiveSeed,
|
|
OwnerSurfaceSettings, OwnerSurfaceDefinition, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
TStrongObjectPtr<UVoxelStrateManager> SourceManager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!SourceManager.IsValid()
|
|
|| !SourceManager->Initialize(OwnerSurfaceSettings.Get(), EffectiveSeed))
|
|
{
|
|
OutError = TEXT("Core island test could not resolve the staged owner settings layout.");
|
|
return false;
|
|
}
|
|
int32 IslandStrateIndex = INDEX_NONE;
|
|
TStrongObjectPtr<UVoxelSettings> TestSettings;
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> TestDefinitions;
|
|
if (!CreateLockedLayoutSettingsCopy(OwnerSurfaceSettings.Get(), SourceManager.Get(), EffectiveSeed,
|
|
true, TestSettings, TestDefinitions, IslandStrateIndex, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
TestSettings->bEnableDensityVolume = false;
|
|
TestSettings->DecorationRadiusChunks = 2;
|
|
TestSettings->DecorationNearRadiusChunks = 1;
|
|
TestSettings->DecorationRegionSizeCells = 1;
|
|
TestSettings->DecorationSpacingVoxels = 16;
|
|
TestSettings->DecorationFarSpacingVoxels = 16;
|
|
TestSettings->DecorationMarchStepVoxels = 2;
|
|
TestSettings->DecorationMaxCrossingsPerColumn = 2;
|
|
TestSettings->DecorationColumnDepthVoxels = 320;
|
|
TestSettings->MaxConcurrentDecorationTasks = 2;
|
|
TestSettings->MaxDecorationCellsPerFrame = 4;
|
|
|
|
TSoftObjectPtr<UVoxelStrateDefinition>* IslandDefinitionSoft =
|
|
TestSettings->FixedStrates.Find(IslandStrateIndex);
|
|
UVoxelStrateDefinition* IslandDefinition = IslandDefinitionSoft != nullptr
|
|
? IslandDefinitionSoft->Get() : nullptr;
|
|
if (IslandDefinition == nullptr
|
|
|| IslandDefinition->GeneratorType != ECaveGeneratorType::FloatingIslands)
|
|
{
|
|
OutError = TEXT("Could not resolve the transient owner SurfaceWorld strate as FloatingIslands.");
|
|
return false;
|
|
}
|
|
|
|
TStrongObjectPtr<UVoxelStrateManager> Manager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
if (!Manager.IsValid() || !Manager->Initialize(TestSettings.Get(), EffectiveSeed))
|
|
{
|
|
OutError = TEXT("Core island test could not rebuild the locked copy of the owner's layout.");
|
|
return false;
|
|
}
|
|
const FStrateSlot* IslandSlot = nullptr;
|
|
for (const FStrateSlot& Slot : Manager->GetLayout())
|
|
{
|
|
if (Slot.StrateIndex == IslandStrateIndex) IslandSlot = &Slot;
|
|
}
|
|
const FStrateSlot* SourceIslandSlot = SourceManager->GetLayout().FindByPredicate(
|
|
[IslandStrateIndex](const FStrateSlot& Slot)
|
|
{ return Slot.StrateIndex == IslandStrateIndex; });
|
|
if (IslandSlot == nullptr || SourceIslandSlot == nullptr
|
|
|| IslandSlot->TopChunkZ != SourceIslandSlot->TopChunkZ
|
|
|| IslandSlot->BottomChunkZ != SourceIslandSlot->BottomChunkZ
|
|
|| IslandSlot->Definition == nullptr
|
|
|| IslandSlot->Definition->GeneratorType != ECaveGeneratorType::FloatingIslands)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Transient island strate did not preserve owner bounds [%d,%d]: actual=[%d,%d]."),
|
|
SourceIslandSlot ? SourceIslandSlot->TopChunkZ : MAX_int32,
|
|
SourceIslandSlot ? SourceIslandSlot->BottomChunkZ : MIN_int32,
|
|
IslandSlot ? IslandSlot->TopChunkZ : MAX_int32,
|
|
IslandSlot ? IslandSlot->BottomChunkZ : MIN_int32);
|
|
return false;
|
|
}
|
|
|
|
FString RuntimeJson;
|
|
if (!VoxelForgeCoreAdapter::SerializeResolvedWorld(
|
|
TestSettings.Get(), Manager.Get(), 3, 8, Arguments.CoreVoxelSizeCm, RuntimeJson, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
FTCHARToUTF8 Utf8Runtime(*RuntimeJson);
|
|
std::string CoreError;
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> Snapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(Utf8Runtime.Get(), static_cast<std::size_t>(Utf8Runtime.Length())),
|
|
CoreError);
|
|
if (!Snapshot || Snapshot->worldAssembly() == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core adapter did not generate an owner world assembly: %s"),
|
|
Snapshot ? TEXT("world assembly is missing") : UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const VoxelForgeCore::WorldAssemblyManifest& NativeAssembly = *Snapshot->worldAssembly();
|
|
const VoxelForgeCore::WorldAssemblyFloatingIslandPlanV1* NativeIslandPlanPtr = nullptr;
|
|
for (const VoxelForgeCore::WorldAssemblyFloatingIslandPlanV1& Plan : NativeAssembly.floatingIslands)
|
|
if (Plan.strateIndex == IslandStrateIndex) NativeIslandPlanPtr = &Plan;
|
|
if (NativeIslandPlanPtr == nullptr
|
|
|| NativeIslandPlanPtr->topChunkZ != IslandSlot->TopChunkZ
|
|
|| NativeIslandPlanPtr->bottomChunkZ != IslandSlot->BottomChunkZ)
|
|
{
|
|
OutError = TEXT("Core did not generate a floating-island plan for the converted owner slot at its original heights.");
|
|
return false;
|
|
}
|
|
const VoxelForgeCore::WorldAssemblyFloatingIslandPlanV1 NativeIslandPlan = *NativeIslandPlanPtr;
|
|
if (NativeIslandPlan.strateIndex != IslandStrateIndex
|
|
|| NativeIslandPlan.manifest.plan.islands.empty()
|
|
|| NativeIslandPlan.manifest.plan.clusterEdges.empty()
|
|
|| NativeIslandPlan.manifest.plan.route.size() < 2u)
|
|
{
|
|
OutError = TEXT("Core generated island plan has the wrong owner strate or route.");
|
|
return false;
|
|
}
|
|
VoxelForgeCore::FloatingIslandFieldV1 RouteField(
|
|
NativeIslandPlan.manifest.recipe, NativeIslandPlan.manifest.plan);
|
|
const VoxelForgeCore::FloatingIslandRouteAuditResult RouteAudit =
|
|
VoxelForgeCore::AuditFloatingIslandRoute(RouteField);
|
|
const VoxelForgeCore::FloatingIslandThicknessAuditResult ThicknessAudit =
|
|
VoxelForgeCore::AuditFloatingIslandThickness(NativeIslandPlan.manifest.plan);
|
|
if (!RouteField.isValid() || !RouteAudit.passed || !ThicknessAudit.passed)
|
|
{
|
|
OutError = TEXT("Generated Core island field failed route clearance or mass-thickness validation.");
|
|
return false;
|
|
}
|
|
const VoxelForgeCore::WorldAssemblyRoutePointV1& StartRoute =
|
|
NativeIslandPlan.manifest.plan.route.front();
|
|
constexpr double SubvoxelsPerCoreVoxel = 256.0;
|
|
const double CoreVoxelSizeCm = Arguments.CoreVoxelSizeCm;
|
|
const FVector CameraVoxels(
|
|
static_cast<double>(StartRoute.positionSubvoxels[0]) / SubvoxelsPerCoreVoxel,
|
|
static_cast<double>(StartRoute.positionSubvoxels[1]) / SubvoxelsPerCoreVoxel,
|
|
static_cast<double>(StartRoute.positionSubvoxels[2]) / SubvoxelsPerCoreVoxel);
|
|
const FVector CameraLocalCm = CameraVoxels * CoreVoxelSizeCm;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] plan_generated=PASS hash=%016llx recipe_hash=%016llx plan_hash=%016llx seed=%d strate=%d owner_bounds=[%d,%d] scale_owner_cm=%.3f scale_core_cm=%.3f islands=%d connectors=%d route_segments=%llu route_clearance_subvoxels=%lld water_fixed=%d"),
|
|
static_cast<unsigned long long>(NativeAssembly.contentHash),
|
|
static_cast<unsigned long long>(NativeIslandPlan.manifest.recipe.contentHash),
|
|
static_cast<unsigned long long>(NativeIslandPlan.manifest.plan.contentHash),
|
|
EffectiveSeed, IslandStrateIndex, IslandSlot->TopChunkZ, IslandSlot->BottomChunkZ,
|
|
static_cast<double>(VOXEL_SIZE), CoreVoxelSizeCm,
|
|
static_cast<int32>(NativeIslandPlan.manifest.plan.islands.size()),
|
|
static_cast<int32>(NativeIslandPlan.manifest.plan.clusterEdges.size()),
|
|
static_cast<unsigned long long>(RouteAudit.segmentsTested),
|
|
static_cast<long long>(RouteAudit.minimumClearanceSubvoxels),
|
|
IslandSlot->Definition->bHasWater ? 1 : 0);
|
|
|
|
// Generate the standalone reference before actor startup so two full Core worlds do not overlap.
|
|
// Generer la reference Standalone avant l'acteur pour ne pas garder deux mondes Core complets.
|
|
TMap<FVoxelTileKey, FVoxelForgeCoreTileHashRecord> ExpectedHashes;
|
|
TSet<FVoxelTileKey> ExpectedDesiredSet;
|
|
std::vector<VoxelForgeCore::TileKey> Desired;
|
|
std::array<std::uint64_t, 4> ExpectedAggregates{};
|
|
std::map<std::string, VoxelForgeCore::TileKey> TimingProbeKeys;
|
|
std::int32_t CoverageLocalTiles = 0;
|
|
std::int32_t CoverageFaces = 0;
|
|
std::int32_t TransitionFaces = 0;
|
|
std::int32_t Lod0Tiles = 0, Lod1Tiles = 0;
|
|
VoxelForgeCore::Vec3d Camera{CameraVoxels.X, CameraVoxels.Y, CameraVoxels.Z};
|
|
VoxelForgeCore::ShapeBuildRequest Request;
|
|
{
|
|
VoxelForgeCore::World ReferenceWorld(std::move(*Snapshot));
|
|
Desired = ReferenceWorld.desiredTiles(Camera);
|
|
if (Desired.empty())
|
|
{
|
|
OutError = TEXT("Standalone Core returned no tiles around the island route start.");
|
|
return false;
|
|
}
|
|
std::int32_t FineMin[3] = {MAX_int32, MAX_int32, MAX_int32};
|
|
std::int32_t FineMax[3] = {MIN_int32, MIN_int32, MIN_int32};
|
|
std::int32_t ClipMin[3] = {MAX_int32, MAX_int32, MAX_int32};
|
|
std::int32_t ClipMax[3] = {MIN_int32, MIN_int32, MIN_int32};
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::int32_t Extent = 32 << Key.lod;
|
|
const std::int32_t Origins[3] = {
|
|
Key.coordinate.x * Extent, Key.coordinate.y * Extent, Key.coordinate.z * Extent};
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
ClipMin[Axis] = FMath::Min(ClipMin[Axis], Origins[Axis]);
|
|
ClipMax[Axis] = FMath::Max(ClipMax[Axis], Origins[Axis] + Extent);
|
|
if (Key.lod < ReferenceWorld.configuration().fullResolutionLodCount)
|
|
{
|
|
FineMin[Axis] = FMath::Min(FineMin[Axis], Origins[Axis]);
|
|
FineMax[Axis] = FMath::Max(FineMax[Axis], Origins[Axis] + Extent);
|
|
}
|
|
}
|
|
if (Key.lod == 0) ++Lod0Tiles;
|
|
if (Key.lod == 1) ++Lod1Tiles;
|
|
}
|
|
if (FineMin[0] == MAX_int32 || ClipMin[0] == MAX_int32)
|
|
{
|
|
OutError = TEXT("Standalone Core island clipmap bounds are invalid.");
|
|
return false;
|
|
}
|
|
Request.minVoxel = {FineMin[0], FineMin[1], FineMin[2]};
|
|
Request.maxVoxel = {FineMax[0], FineMax[1], FineMax[2]};
|
|
Request.clipMinVoxel = {ClipMin[0], ClipMin[1], ClipMin[2]};
|
|
Request.clipMaxVoxel = {ClipMax[0], ClipMax[1], ClipMax[2]};
|
|
Request.workerLimit = 8;
|
|
Request.detail = VoxelForgeCore::DetailVariant::coherent;
|
|
Request.includeFarWorld = true;
|
|
FStandaloneCoreAdapterScheduler Scheduler;
|
|
if (!ReferenceWorld.compile(Request, Scheduler, CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core island reference compile failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
ReferenceWorld.retainTileRegions(Desired);
|
|
if (!RunCoreSharedFeatureCostAudit(ReferenceWorld,Desired,TEXT("islands"),
|
|
&NativeAssembly,OutError)) return false;
|
|
VoxelForgeCore::TileGenerationOptions Options;
|
|
Options.hashDensity = true;
|
|
std::vector<FStandaloneCoreAdapterTileHashes> AggregateRows;
|
|
AggregateRows.reserve(Desired.size());
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, Options, CoreError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core island tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
FVoxelForgeCoreTileHashRecord Record;
|
|
Record.Density = Mesh->densityHash;
|
|
Record.Mesh = Mesh->meshHash;
|
|
Record.Payload = Mesh->payloadHash;
|
|
Record.Topology = Mesh->topologyHash;
|
|
Record.Triangles = static_cast<int32>(Mesh->indices.size() / 3u);
|
|
Record.bEmpty = Mesh->indices.empty();
|
|
Record.bHasHashes = true;
|
|
ExpectedHashes.Add(RuntimeKey, Record);
|
|
ExpectedDesiredSet.Add(RuntimeKey);
|
|
AggregateRows.push_back({Key,
|
|
{Record.Density, Record.Mesh, Record.Payload, Record.Topology}});
|
|
|
|
if (Key.lod > 1u) continue;
|
|
const bool bAir = Mesh->diagnostics.tileClass == VoxelForgeCore::TileClass::allAir;
|
|
const bool bSolid = Mesh->diagnostics.tileClass == VoxelForgeCore::TileClass::allSolid;
|
|
bool bIslandBounds = false;
|
|
bool bConnectorBounds = false;
|
|
const std::int64_t TileMinimum[3] = {
|
|
static_cast<std::int64_t>(Key.coordinate.x) * (32ll << Key.lod) * 256ll,
|
|
static_cast<std::int64_t>(Key.coordinate.y) * (32ll << Key.lod) * 256ll,
|
|
static_cast<std::int64_t>(Key.coordinate.z) * (32ll << Key.lod) * 256ll};
|
|
const std::int64_t TileMaximum[3] = {
|
|
TileMinimum[0] + (32ll << Key.lod) * 256ll,
|
|
TileMinimum[1] + (32ll << Key.lod) * 256ll,
|
|
TileMinimum[2] + (32ll << Key.lod) * 256ll};
|
|
const auto IntersectsFeature = [&](const auto& Feature)
|
|
{
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
if (TileMaximum[Axis] <= Feature.influenceMinimumSubvoxels[Axis]
|
|
|| TileMinimum[Axis] >= Feature.influenceMaximumSubvoxels[Axis]) return false;
|
|
}
|
|
return true;
|
|
};
|
|
for (const auto& Island : NativeIslandPlan.manifest.plan.islands)
|
|
bIslandBounds |= IntersectsFeature(Island);
|
|
for (const auto& Connector : NativeIslandPlan.manifest.plan.clusterEdges)
|
|
bConnectorBounds |= IntersectsFeature(Connector);
|
|
const std::string LodName = "lod" + std::to_string(Key.lod) + ":";
|
|
const std::string Kind = bAir ? "air" : bSolid ? "solid"
|
|
: (bConnectorBounds && !bIslandBounds) ? "span"
|
|
: bIslandBounds ? "island" : "mixed";
|
|
const std::string ProbeName = LodName + Kind;
|
|
if (TimingProbeKeys.find(ProbeName) == TimingProbeKeys.end())
|
|
TimingProbeKeys.emplace(ProbeName, Key);
|
|
if (!bAir && !bSolid
|
|
&& TimingProbeKeys.find(LodName + "mixed") == TimingProbeKeys.end())
|
|
TimingProbeKeys.emplace(LodName + "mixed", Key);
|
|
}
|
|
ExpectedAggregates = StandaloneCoreAggregateHashes(AggregateRows);
|
|
if (!AuditCoreClipmapCoverage(Desired, Camera, 128,
|
|
CoverageLocalTiles, CoverageFaces, TransitionFaces, OutError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Island standalone clipmap coverage failed: %s"), *OutError);
|
|
return false;
|
|
}
|
|
|
|
UVoxelGenerator* LegacyGenerator = NewObject<UVoxelGenerator>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
UVoxelMarchingCubesMesher* LegacyMesher = NewObject<UVoxelMarchingCubesMesher>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
if (LegacyGenerator == nullptr || LegacyMesher == nullptr)
|
|
{
|
|
OutError = TEXT("Could not allocate the matched legacy island timing fixture.");
|
|
return false;
|
|
}
|
|
LegacyGenerator->InitializeSettings(TestSettings.Get());
|
|
LegacyGenerator->SetStrateManager(Manager.Get());
|
|
LegacyMesher->SetGenerator(LegacyGenerator);
|
|
LegacyMesher->SetGeometryVoxelSizeCm(static_cast<float>(CoreVoxelSizeCm));
|
|
const auto P95Milliseconds = [](TArray<double>& Samples)
|
|
{
|
|
Samples.Sort();
|
|
const int32 Index = FMath::Clamp(
|
|
FMath::CeilToInt(static_cast<float>(Samples.Num()) * 0.95f) - 1,
|
|
0, Samples.Num() - 1);
|
|
return Samples.IsEmpty() ? 0.0 : Samples[Index];
|
|
};
|
|
for (const auto& ProbePair : TimingProbeKeys)
|
|
{
|
|
const VoxelForgeCore::TileKey& Key = ProbePair.second;
|
|
TArray<double> CoreMilliseconds;
|
|
TArray<double> CoreClassificationMilliseconds;
|
|
TArray<double> CoreColumnComputeMilliseconds;
|
|
TArray<double> CoreMeshingMilliseconds;
|
|
TArray<double> LegacyMilliseconds;
|
|
CoreMilliseconds.Reserve(5);
|
|
CoreClassificationMilliseconds.Reserve(5);
|
|
CoreColumnComputeMilliseconds.Reserve(5);
|
|
CoreMeshingMilliseconds.Reserve(5);
|
|
LegacyMilliseconds.Reserve(5);
|
|
for (int32 Repeat = 0; Repeat < 5; ++Repeat)
|
|
{
|
|
const double CoreStart = FPlatformTime::Seconds();
|
|
const std::optional<VoxelForgeCore::TileMesh> TimedCoreMesh =
|
|
ReferenceWorld.generateTile(Key, {}, CoreError);
|
|
const double CoreElapsedMs = (FPlatformTime::Seconds() - CoreStart) * 1000.0;
|
|
if (!TimedCoreMesh)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core island timing sample failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
CoreMilliseconds.Add(CoreElapsedMs);
|
|
CoreClassificationMilliseconds.Add(TimedCoreMesh->diagnostics.classificationMilliseconds);
|
|
CoreColumnComputeMilliseconds.Add(TimedCoreMesh->diagnostics.columnComputeMilliseconds);
|
|
CoreMeshingMilliseconds.Add(TimedCoreMesh->diagnostics.meshingMilliseconds);
|
|
const int32 Extent = CHUNK_SIZE << Key.lod;
|
|
const FIntVector CoreOrigin(
|
|
Key.coordinate.x * Extent, Key.coordinate.y * Extent,
|
|
Key.coordinate.z * Extent);
|
|
const FIntVector LegacyOrigin = CoreOrigin * 2;
|
|
const int32 LegacyStep = 2 << Key.lod;
|
|
const double LegacyStart = FPlatformTime::Seconds();
|
|
const FVoxelMeshData LegacyMesh = LegacyMesher->GenerateMesh(
|
|
LegacyOrigin, LegacyStep, CHUNK_SIZE);
|
|
const double LegacyElapsedMs = (FPlatformTime::Seconds() - LegacyStart) * 1000.0;
|
|
LegacyMilliseconds.Add(LegacyElapsedMs);
|
|
if (Repeat == 0)
|
|
{
|
|
const int32 CoreTriangles = static_cast<int32>(TimedCoreMesh->indices.size() / 3u);
|
|
const int32 LegacyTriangles = LegacyMesh.Triangles.Num() / 3;
|
|
const double TileTravelCm = CoreVoxelSizeCm * Extent;
|
|
const double WalkDeadlineMs = TileTravelCm / FMath::Max(StagedWalkSpeedCmPerSecond, 1.0f) * 1000.0;
|
|
const double SprintDeadlineMs = TileTravelCm / FMath::Max(StagedSprintSpeedCmPerSecond, 1.0f) * 1000.0;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeIslandStreaming] lod=%d kind=%s key=(%d,%d,%d) core_triangles=%d legacy_triangles=%d tile_travel_cm=%.0f walk_deadline_ms=%.3f sprint_deadline_ms=%.3f"),
|
|
static_cast<int32>(Key.lod), UTF8_TO_TCHAR(ProbePair.first.c_str()),
|
|
Key.coordinate.x, Key.coordinate.y, Key.coordinate.z,
|
|
CoreTriangles, LegacyTriangles, TileTravelCm, WalkDeadlineMs, SprintDeadlineMs);
|
|
}
|
|
}
|
|
const double CoreP95 = P95Milliseconds(CoreMilliseconds);
|
|
const double CoreClassificationP95 = P95Milliseconds(CoreClassificationMilliseconds);
|
|
const double CoreColumnComputeP95 = P95Milliseconds(CoreColumnComputeMilliseconds);
|
|
const double CoreMeshingP95 = P95Milliseconds(CoreMeshingMilliseconds);
|
|
const double LegacyP95 = P95Milliseconds(LegacyMilliseconds);
|
|
const int32 Extent = CHUNK_SIZE << Key.lod;
|
|
const double TileTravelCm = CoreVoxelSizeCm * Extent;
|
|
const double WalkDeadlineMs = TileTravelCm / FMath::Max(StagedWalkSpeedCmPerSecond, 1.0f) * 1000.0;
|
|
const double SprintDeadlineMs = TileTravelCm / FMath::Max(StagedSprintSpeedCmPerSecond, 1.0f) * 1000.0;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeIslandStreaming] timing lod=%d kind=%s repeats=5 core_p95_ms=%.3f core_classify_p95_ms=%.3f core_columns_p95_ms=%.3f core_mesh_p95_ms=%.3f legacy_p95_ms=%.3f walk_deadline_ms=%.3f sprint_deadline_ms=%.3f core_walk_lag_ms=%.3f legacy_walk_lag_ms=%.3f core_sprint_lag_ms=%.3f legacy_sprint_lag_ms=%.3f"),
|
|
static_cast<int32>(Key.lod), UTF8_TO_TCHAR(ProbePair.first.c_str()),
|
|
CoreP95, CoreClassificationP95, CoreColumnComputeP95, CoreMeshingP95,
|
|
LegacyP95, WalkDeadlineMs, SprintDeadlineMs,
|
|
FMath::Max(0.0, CoreP95 - WalkDeadlineMs), FMath::Max(0.0, LegacyP95 - WalkDeadlineMs),
|
|
FMath::Max(0.0, CoreP95 - SprintDeadlineMs), FMath::Max(0.0, LegacyP95 - SprintDeadlineMs));
|
|
}
|
|
for (const int32 Lod : {0, 1})
|
|
{
|
|
if (TimingProbeKeys.find("lod" + std::to_string(Lod) + ":air") == TimingProbeKeys.end()
|
|
|| (TimingProbeKeys.find("lod" + std::to_string(Lod) + ":island") == TimingProbeKeys.end()
|
|
&& TimingProbeKeys.find("lod" + std::to_string(Lod) + ":mixed") == TimingProbeKeys.end()))
|
|
{
|
|
OutError = FString::Printf(TEXT("Island clipmap timing did not find air and surface probes at LOD%d."), Lod);
|
|
return false;
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] standalone_reference=PASS tiles=%d lod0=%d lod1=%d lod_transitions=%d coverage_local=%d checked_faces=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
ExpectedDesiredSet.Num(), Lod0Tiles, Lod1Tiles, TransitionFaces, CoverageLocalTiles,
|
|
CoverageFaces, static_cast<unsigned long long>(ExpectedAggregates[0]),
|
|
static_cast<unsigned long long>(ExpectedAggregates[1]),
|
|
static_cast<unsigned long long>(ExpectedAggregates[2]),
|
|
static_cast<unsigned long long>(ExpectedAggregates[3]));
|
|
}
|
|
|
|
const auto BuildWorkerHashAggregate = [&](uint32 WorkerCount,
|
|
std::array<std::uint64_t, 4>& OutHashes) -> bool
|
|
{
|
|
FTCHARToUTF8 WorkerJson(*RuntimeJson);
|
|
std::string WorkerError;
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> WorkerSnapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(WorkerJson.Get(), static_cast<std::size_t>(WorkerJson.Length())),
|
|
WorkerError);
|
|
if (!WorkerSnapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core W%u island snapshot failed: %s"),
|
|
WorkerCount, UTF8_TO_TCHAR(WorkerError.c_str()));
|
|
return false;
|
|
}
|
|
VoxelForgeCore::World WorkerWorld(std::move(*WorkerSnapshot));
|
|
VoxelForgeCore::ShapeBuildRequest WorkerRequest = Request;
|
|
WorkerRequest.workerLimit = WorkerCount;
|
|
FStandaloneCoreAdapterScheduler Scheduler;
|
|
if (!WorkerWorld.compile(WorkerRequest, Scheduler, WorkerError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Core W%u island compile failed: %s"),
|
|
WorkerCount, UTF8_TO_TCHAR(WorkerError.c_str()));
|
|
return false;
|
|
}
|
|
WorkerWorld.retainTileRegions(Desired);
|
|
VoxelForgeCore::TileGenerationOptions Options;
|
|
Options.hashDensity = true;
|
|
std::vector<FStandaloneCoreAdapterTileHashes> Rows;
|
|
Rows.reserve(Desired.size());
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
WorkerWorld.generateTile(Key, Options, WorkerError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core W%u island tile hash failed: %s"),
|
|
WorkerCount, UTF8_TO_TCHAR(WorkerError.c_str()));
|
|
return false;
|
|
}
|
|
Rows.push_back({Key, {Mesh->densityHash, Mesh->meshHash,
|
|
Mesh->payloadHash, Mesh->topologyHash}});
|
|
}
|
|
OutHashes = StandaloneCoreAggregateHashes(Rows);
|
|
return true;
|
|
};
|
|
std::array<std::uint64_t, 4> W1Hashes{}, W16Hashes{};
|
|
if (!BuildWorkerHashAggregate(1, W1Hashes)
|
|
|| !BuildWorkerHashAggregate(16, W16Hashes))
|
|
{
|
|
return false;
|
|
}
|
|
if (W1Hashes != ExpectedAggregates || W16Hashes != ExpectedAggregates)
|
|
{
|
|
OutError = TEXT("Island clipmap hashes differ across worker limits 1, 8 and 16.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] worker_hash_matrix=1/8/16 stable=1 density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
static_cast<unsigned long long>(ExpectedAggregates[0]),
|
|
static_cast<unsigned long long>(ExpectedAggregates[1]),
|
|
static_cast<unsigned long long>(ExpectedAggregates[2]),
|
|
static_cast<unsigned long long>(ExpectedAggregates[3]));
|
|
|
|
const UWorld::InitializationValues WorldInitialization = UWorld::InitializationValues()
|
|
.AllowAudioPlayback(false).RequiresHitProxies(false).CreatePhysicsScene(true)
|
|
.CreateNavigation(false).CreateAISystem(false).ShouldSimulatePhysics(true)
|
|
.SetDefaultGameMode(AGameModeBase::StaticClass()).SetTransactional(false);
|
|
UWorld* TestWorld = UWorld::CreateWorld(EWorldType::Game, false,
|
|
FName(TEXT("VoxelForgeCoreIslandValidationWorld")), GetTransientPackage(),
|
|
false, ERHIFeatureLevel::Num, &WorldInitialization);
|
|
if (TestWorld == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the transient BuildHost island actor world.");
|
|
return false;
|
|
}
|
|
UGameInstance* TestGameInstance = NewObject<UGameInstance>(GEngine);
|
|
FWorldContext& TestWorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
|
|
TestWorldContext.OwningGameInstance = TestGameInstance;
|
|
TestWorldContext.SetCurrentWorld(TestWorld);
|
|
TestWorld->SetGameInstance(TestGameInstance);
|
|
TestWorld->SetShouldTick(false);
|
|
TestGameInstance->Init();
|
|
TestWorld->AddToRoot();
|
|
struct FIslandWorldCleanup
|
|
{
|
|
UWorld* World = nullptr;
|
|
uint64 PreviousFrameCounter = GFrameCounter;
|
|
~FIslandWorldCleanup()
|
|
{
|
|
if (World != nullptr)
|
|
{
|
|
if (World->HasBegunPlay())
|
|
{
|
|
World->BeginTearingDown();
|
|
World->EndPlay(EEndPlayReason::Quit);
|
|
}
|
|
if (UGameInstance* GameInstance = World->GetGameInstance()) GameInstance->Shutdown();
|
|
if (GEngine != nullptr) GEngine->DestroyWorldContext(World);
|
|
World->DestroyWorld(false);
|
|
if (World->IsRooted()) World->RemoveFromRoot();
|
|
}
|
|
GFrameCounter = PreviousFrameCounter;
|
|
}
|
|
} Cleanup{TestWorld, GFrameCounter};
|
|
|
|
if (!TestWorld->SetGameMode(FURL()))
|
|
{
|
|
OutError = TEXT("Could not create the transient GameMode for island actor validation.");
|
|
return false;
|
|
}
|
|
AGameModeBase* ValidationGameMode = TestWorld->GetAuthGameMode();
|
|
if (ValidationGameMode == nullptr)
|
|
{
|
|
OutError = TEXT("Transient island validation GameMode was not available before startup.");
|
|
return false;
|
|
}
|
|
// Preserve the explicitly placed route pawn; GameMode's default spawn would move Core's first compile to the origin.
|
|
// Conserver le pawn de route ; le spawn par defaut du GameMode deplacerait le premier compile Core a l'origine.
|
|
ValidationGameMode->DefaultPawnClass = nullptr;
|
|
UPlayer* TestPlayer = NewObject<UPlayer>(GEngine, NAME_None, RF_Transient);
|
|
APlayerController* PlayerController = TestWorld->SpawnActor<APlayerController>(
|
|
APlayerController::StaticClass(), FTransform::Identity,
|
|
FActorSpawnParameters());
|
|
if (TestPlayer == nullptr || PlayerController == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn a BuildHost route player controller.");
|
|
return false;
|
|
}
|
|
// The world stream anchor reads only the first controller's pawn; use a transient base player
|
|
// to avoid online identity initialization in this headless commandlet.
|
|
// L'ancre de streaming lit le pawn du premier controleur ; eviter l'initialisation en ligne.
|
|
PlayerController->SetPlayer(TestPlayer);
|
|
// This transient world has not initialized actors yet, so register the controller explicitly.
|
|
// Ce monde transitoire n'a pas encore initialise ses acteurs ; enregistrer le controleur.
|
|
TestWorld->AddController(PlayerController);
|
|
APawn* PlayerPawn = TestWorld->SpawnActor<APawn>(APawn::StaticClass(),
|
|
CameraLocalCm, FRotator::ZeroRotator);
|
|
if (PlayerPawn == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the BuildHost island route player.");
|
|
return false;
|
|
}
|
|
USceneComponent* PawnRoot = NewObject<USceneComponent>(PlayerPawn, NAME_None, RF_Transient);
|
|
if (PawnRoot == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the BuildHost island route pawn root.");
|
|
return false;
|
|
}
|
|
PlayerPawn->SetRootComponent(PawnRoot);
|
|
PawnRoot->RegisterComponent();
|
|
PlayerPawn->SetActorLocation(CameraLocalCm, false, nullptr, ETeleportType::TeleportPhysics);
|
|
PlayerController->Possess(PlayerPawn);
|
|
APlayerController* FirstPlayerController = TestWorld->GetFirstPlayerController();
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] player_preflight first=%s manual=%s first_pawn=%s route_pawn=%s route_controller=%s first_matches=%d possess_matches=%d position_matches=%d route_position=(%.1f,%.1f,%.1f) world_netmode=%d"),
|
|
*GetNameSafe(FirstPlayerController), *GetNameSafe(PlayerController),
|
|
*GetNameSafe(FirstPlayerController ? FirstPlayerController->GetPawn() : nullptr),
|
|
*GetNameSafe(PlayerPawn), *GetNameSafe(PlayerPawn->GetController()),
|
|
FirstPlayerController == PlayerController ? 1 : 0,
|
|
PlayerController->GetPawn() == PlayerPawn ? 1 : 0,
|
|
PlayerPawn->GetActorLocation().Equals(CameraLocalCm, 0.1) ? 1 : 0,
|
|
PlayerPawn->GetActorLocation().X, PlayerPawn->GetActorLocation().Y,
|
|
PlayerPawn->GetActorLocation().Z, static_cast<int32>(TestWorld->GetNetMode()));
|
|
if (FirstPlayerController != PlayerController
|
|
|| PlayerController->GetPawn() != PlayerPawn
|
|
|| !PlayerPawn->GetActorLocation().Equals(CameraLocalCm, 0.1))
|
|
{
|
|
OutError = TEXT("BuildHost route controller did not retain the route pawn before Core initialization.");
|
|
return false;
|
|
}
|
|
// Spawn and possess the route pawn before AVoxelWorld begins play. Its initial Core snapshot
|
|
// must compile the same route-centered clipmap that the Standalone reference audits.
|
|
// Creer le joueur avant le monde voxel afin que le premier clipmap Core soit centre sur la route.
|
|
AVoxelWorld* Actor = TestWorld->SpawnActorDeferred<AVoxelWorld>(
|
|
AVoxelWorld::StaticClass(), FTransform::Identity, nullptr, nullptr,
|
|
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
|
|
if (Actor == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the transient AVoxelWorld for island validation.");
|
|
return false;
|
|
}
|
|
Actor->Settings = TestSettings.Get();
|
|
Actor->bManageAtmosphere = true;
|
|
Actor->bUseVoxelForgeCore = true;
|
|
Actor->CoreVoxelSizeCm = Arguments.CoreVoxelSizeCm;
|
|
Actor->CoreMesherRecipe = EVoxelForgeCoreMesherRecipe::MarchingCubesV1;
|
|
Actor->CoreClipRadius = 3;
|
|
Actor->CoreMaxClipLevel = 8;
|
|
Actor->bCaptureVoxelForgeCoreHashesForValidation = true;
|
|
Actor->FinishSpawning(FTransform::Identity);
|
|
TestWorld->InitializeActorsForPlay(FURL());
|
|
TestWorld->BeginPlay();
|
|
// GameMode may restart manually spawned controllers at BeginPlay and replace their pawn.
|
|
// Reassert the route pawn after startup so both the first Core request and later clipmap
|
|
// comparisons stay tied to the authored island route.
|
|
// Le GameMode peut remplacer le pawn au demarrage ; reprendre explicitement le pawn de route.
|
|
PlayerPawn->SetActorLocation(CameraLocalCm, false, nullptr, ETeleportType::TeleportPhysics);
|
|
PlayerController->Possess(PlayerPawn);
|
|
if (PlayerController->GetPawn() != PlayerPawn
|
|
|| !PlayerPawn->GetActorLocation().Equals(CameraLocalCm, 0.1))
|
|
{
|
|
OutError = TEXT("BuildHost GameMode did not preserve the route pawn after BeginPlay.");
|
|
return false;
|
|
}
|
|
Actor->SetActorTickEnabled(true);
|
|
FString ActorRuntimeJson;
|
|
FString ActorRuntimeError;
|
|
const bool bActorRuntimeSerialized = VoxelForgeCoreAdapter::SerializeResolvedWorld(
|
|
Actor->Settings, Actor->StrateManager, 3, 8, Actor->CoreVoxelSizeCm,
|
|
ActorRuntimeJson, ActorRuntimeError);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] world_initialized=1 core=1 actor_initialized=%d player=(%.1f,%.1f,%.1f) possessed=(%.1f,%.1f,%.1f) snapshot_parity=%d expected_bytes=%d actor_bytes=%d"),
|
|
Actor->IsActorInitialized() ? 1 : 0,
|
|
CameraLocalCm.X, CameraLocalCm.Y, CameraLocalCm.Z,
|
|
PlayerController->GetPawn() ? PlayerController->GetPawn()->GetActorLocation().X : 0.0,
|
|
PlayerController->GetPawn() ? PlayerController->GetPawn()->GetActorLocation().Y : 0.0,
|
|
PlayerController->GetPawn() ? PlayerController->GetPawn()->GetActorLocation().Z : 0.0,
|
|
bActorRuntimeSerialized && ActorRuntimeJson == RuntimeJson ? 1 : 0,
|
|
RuntimeJson.Len(), ActorRuntimeJson.Len());
|
|
|
|
const auto DesiredTilesLoaded = [](const AVoxelWorld* World)
|
|
{
|
|
if (World == nullptr || World->DesiredSorted.IsEmpty()) return false;
|
|
for (const FVoxelTileKey& Key : World->DesiredSorted)
|
|
if (!World->LoadedTiles.Contains(Key)) return false;
|
|
return true;
|
|
};
|
|
const auto CollisionSetSettled = [](const AVoxelWorld* World)
|
|
{
|
|
if (World == nullptr) return false;
|
|
for (const FVoxelTileKey& Key : World->CollisionDesiredTiles)
|
|
if (!World->CollisionReadyTiles.Contains(Key)
|
|
&& !World->CollisionNotRequiredTiles.Contains(Key)) return false;
|
|
return true;
|
|
};
|
|
const auto TickWorldUntil = [&](double TimeoutSeconds, const TFunction<bool()>& Predicate)
|
|
{
|
|
const double Deadline = FPlatformTime::Seconds() + TimeoutSeconds;
|
|
double LastProgress = FPlatformTime::Seconds();
|
|
int32 Ticks = 0;
|
|
while (FPlatformTime::Seconds() < Deadline)
|
|
{
|
|
TestWorld->Tick(LEVELTICK_All, 0.016f);
|
|
++GFrameCounter;
|
|
++Ticks;
|
|
FPlatformProcess::Sleep(0.001f);
|
|
if (Predicate()) return true;
|
|
const double Now = FPlatformTime::Seconds();
|
|
if (Now - LastProgress >= 10.0)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] wait ticks=%d ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d"),
|
|
Ticks, Actor->IsVoxelForgeCoreReady() ? 1 : 0, Actor->DesiredSorted.Num(),
|
|
Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(),
|
|
Actor->PendingCollisionCooks.Num(), Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
LastProgress = Now;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
if (!TickWorldUntil(600.0, [&]()
|
|
{
|
|
return Actor->IsVoxelForgeCoreReady() && Actor->bAllChunksLoaded
|
|
&& Actor->PendingTiles.IsEmpty() && Actor->PendingCollisionCooks.IsEmpty()
|
|
&& Actor->PendingUnload.IsEmpty()
|
|
&& Actor->ActiveTaskCount.load(std::memory_order_acquire) == 0
|
|
&& DesiredTilesLoaded(Actor) && CollisionSetSettled(Actor);
|
|
}))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("BuildHost island clipmap did not settle: ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d."),
|
|
Actor->IsVoxelForgeCoreReady() ? 1 : 0, Actor->DesiredSorted.Num(),
|
|
Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(),
|
|
Actor->PendingCollisionCooks.Num(), Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
return false;
|
|
}
|
|
TSet<FVoxelTileKey> ActualDesiredSet;
|
|
TSet<FVoxelTileKey> ActualVisualSet;
|
|
TArray<FVoxelTileKey> RuntimeCollisionSupportKeys;
|
|
int32 RuntimeCollisionSupportTiles = 0;
|
|
for (const FVoxelTileKey& Key : Actor->DesiredSorted) ActualDesiredSet.Add(Key);
|
|
for (const FVoxelTileKey& Key : ActualDesiredSet)
|
|
{
|
|
if (!Actor->CollisionOnlyTiles.Contains(Key))
|
|
{
|
|
ActualVisualSet.Add(Key);
|
|
continue;
|
|
}
|
|
const FVoxelForgeCoreTileHashRecord* SupportHash = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
if (!Actor->CollisionDesiredTiles.Contains(Key) || SupportHash == nullptr
|
|
|| !SupportHash->bHasHashes
|
|
|| (!Actor->CollisionReadyTiles.Contains(Key)
|
|
&& !Actor->CollisionNotRequiredTiles.Contains(Key)))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("BuildHost island collision-only support tile (%d,%d,%d) was not hashed and cooked."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
++RuntimeCollisionSupportTiles;
|
|
RuntimeCollisionSupportKeys.Add(Key);
|
|
}
|
|
if (ActualVisualSet.Num() != ExpectedDesiredSet.Num())
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("BuildHost island visual clipmap has %d tiles; Standalone expected %d (runtime collision-only=%d)."),
|
|
ActualVisualSet.Num(), ExpectedDesiredSet.Num(), RuntimeCollisionSupportTiles);
|
|
return false;
|
|
}
|
|
if (!RuntimeCollisionSupportKeys.IsEmpty())
|
|
{
|
|
FTCHARToUTF8 SupportJson(*RuntimeJson);
|
|
std::string SupportError;
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> SupportSnapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(SupportJson.Get(), static_cast<std::size_t>(SupportJson.Length())),
|
|
SupportError);
|
|
if (!SupportSnapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not parse the Standalone collision-support snapshot: %s"),
|
|
UTF8_TO_TCHAR(SupportError.c_str()));
|
|
return false;
|
|
}
|
|
VoxelForgeCore::World SupportReferenceWorld(std::move(*SupportSnapshot));
|
|
std::vector<VoxelForgeCore::TileKey> SupportReferenceKeys;
|
|
VoxelForgeCore::ShapeBuildRequest SupportRequest;
|
|
SupportRequest.workerLimit = 8;
|
|
SupportRequest.detail = VoxelForgeCore::DetailVariant::coherent;
|
|
SupportRequest.includeFarWorld = true;
|
|
std::int32_t SupportFineMin[3] = {MAX_int32, MAX_int32, MAX_int32};
|
|
std::int32_t SupportFineMax[3] = {MIN_int32, MIN_int32, MIN_int32};
|
|
std::int32_t SupportClipMin[3] = {MAX_int32, MAX_int32, MAX_int32};
|
|
std::int32_t SupportClipMax[3] = {MIN_int32, MIN_int32, MIN_int32};
|
|
for (const FVoxelTileKey& Key : RuntimeCollisionSupportKeys)
|
|
{
|
|
const std::int32_t Extent = 32 << Key.Level;
|
|
const std::int32_t Origin[3] = {
|
|
Key.Coord.X * Extent, Key.Coord.Y * Extent, Key.Coord.Z * Extent};
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
SupportClipMin[Axis] = FMath::Min(SupportClipMin[Axis], Origin[Axis]);
|
|
SupportClipMax[Axis] = FMath::Max(SupportClipMax[Axis], Origin[Axis] + Extent);
|
|
if (Key.Level < SupportReferenceWorld.configuration().fullResolutionLodCount)
|
|
{
|
|
SupportFineMin[Axis] = FMath::Min(SupportFineMin[Axis], Origin[Axis]);
|
|
SupportFineMax[Axis] = FMath::Max(SupportFineMax[Axis], Origin[Axis] + Extent);
|
|
}
|
|
}
|
|
SupportReferenceKeys.push_back({
|
|
{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, static_cast<std::int32_t>(Key.Level)});
|
|
}
|
|
if (SupportClipMin[0] == MAX_int32)
|
|
{
|
|
OutError = TEXT("Standalone collision-support tile bounds are invalid.");
|
|
return false;
|
|
}
|
|
if (SupportFineMin[0] == MAX_int32)
|
|
{
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
SupportFineMin[Axis] = SupportClipMin[Axis];
|
|
SupportFineMax[Axis] = SupportClipMax[Axis];
|
|
}
|
|
}
|
|
SupportRequest.minVoxel = {SupportFineMin[0], SupportFineMin[1], SupportFineMin[2]};
|
|
SupportRequest.maxVoxel = {SupportFineMax[0], SupportFineMax[1], SupportFineMax[2]};
|
|
SupportRequest.clipMinVoxel = {SupportClipMin[0], SupportClipMin[1], SupportClipMin[2]};
|
|
SupportRequest.clipMaxVoxel = {SupportClipMax[0], SupportClipMax[1], SupportClipMax[2]};
|
|
FStandaloneCoreAdapterScheduler SupportScheduler;
|
|
if (!SupportReferenceWorld.compile(SupportRequest, SupportScheduler, SupportError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone collision-support compile failed: %s"),
|
|
UTF8_TO_TCHAR(SupportError.c_str()));
|
|
return false;
|
|
}
|
|
SupportReferenceWorld.retainTileRegions(SupportReferenceKeys);
|
|
VoxelForgeCore::TileGenerationOptions SupportOptions;
|
|
SupportOptions.hashDensity = true;
|
|
for (const FVoxelTileKey& Key : RuntimeCollisionSupportKeys)
|
|
{
|
|
const VoxelForgeCore::TileKey SupportKey{
|
|
{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, static_cast<std::int32_t>(Key.Level)};
|
|
const std::optional<VoxelForgeCore::TileMesh> SupportMesh =
|
|
SupportReferenceWorld.generateTile(SupportKey, SupportOptions, SupportError);
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
if (!SupportMesh || !SupportMesh->hasDensityHash || !SupportMesh->hasMeshHash
|
|
|| !SupportMesh->hasPayloadHash || !SupportMesh->hasTopologyHash
|
|
|| Actual == nullptr || !Actual->bHasHashes
|
|
|| Actual->Density != SupportMesh->densityHash
|
|
|| Actual->Mesh != SupportMesh->meshHash
|
|
|| Actual->Payload != SupportMesh->payloadHash
|
|
|| Actual->Topology != SupportMesh->topologyHash
|
|
|| Actual->Triangles != static_cast<int32>(SupportMesh->indices.size() / 3u)
|
|
|| Actual->bEmpty != SupportMesh->indices.empty())
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("BuildHost collision-support tile (%d,%d,%d) does not match Standalone."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] collision_support_hashes=PASS standalone_tiles=%d"),
|
|
RuntimeCollisionSupportKeys.Num());
|
|
}
|
|
for (const FVoxelTileKey& Key : ExpectedDesiredSet)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedHashes.Find(Key);
|
|
if (!ActualVisualSet.Contains(Key) || Actual == nullptr || Expected == nullptr
|
|
|| !Actual->bHasHashes || Actual->Density != Expected->Density
|
|
|| Actual->Mesh != Expected->Mesh || Actual->Payload != Expected->Payload
|
|
|| Actual->Topology != Expected->Topology || Actual->Triangles != Expected->Triangles
|
|
|| Actual->bEmpty != Expected->bEmpty)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("BuildHost island tile hash mismatch at (%d,%d,%d) LOD%d: "
|
|
"density=%016llx/%016llx mesh=%016llx/%016llx payload=%016llx/%016llx "
|
|
"topology=%016llx/%016llx triangles=%d/%d empty=%d/%d hashes=%d/%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level,
|
|
static_cast<unsigned long long>(Actual ? Actual->Density : 0ull),
|
|
static_cast<unsigned long long>(Expected ? Expected->Density : 0ull),
|
|
static_cast<unsigned long long>(Actual ? Actual->Mesh : 0ull),
|
|
static_cast<unsigned long long>(Expected ? Expected->Mesh : 0ull),
|
|
static_cast<unsigned long long>(Actual ? Actual->Payload : 0ull),
|
|
static_cast<unsigned long long>(Expected ? Expected->Payload : 0ull),
|
|
static_cast<unsigned long long>(Actual ? Actual->Topology : 0ull),
|
|
static_cast<unsigned long long>(Expected ? Expected->Topology : 0ull),
|
|
Actual ? Actual->Triangles : -1, Expected ? Expected->Triangles : -1,
|
|
Actual && Actual->bEmpty ? 1 : 0, Expected && Expected->bEmpty ? 1 : 0,
|
|
Actual && Actual->bHasHashes ? 1 : 0, Expected && Expected->bHasHashes ? 1 : 0);
|
|
return false;
|
|
}
|
|
}
|
|
for (const FVoxelTileKey& Key : Actor->CollisionDesiredTiles)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedHashes.Find(Key);
|
|
if (Expected == nullptr && Actor->CollisionOnlyTiles.Contains(Key))
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord* SupportHash = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
if (SupportHash == nullptr || !SupportHash->bHasHashes
|
|
|| (!Actor->CollisionReadyTiles.Contains(Key)
|
|
&& !Actor->CollisionNotRequiredTiles.Contains(Key)))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("BuildHost island collision-only support tile (%d,%d,%d) has no cooked collision."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
if (Expected == nullptr
|
|
|| (Expected->bEmpty && !Actor->CollisionNotRequiredTiles.Contains(Key))
|
|
|| (!Expected->bEmpty && !Actor->CollisionReadyTiles.Contains(Key)))
|
|
{
|
|
OutError = FString::Printf(TEXT("BuildHost island collision state differs from Standalone for (%d,%d,%d)."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] pass phase=initial standalone_visual_tiles=%d collision_support_extra=%d collision_desired=%d collision_ready=%d collision_empty=%d standalone_hashes=1 lod0=%d lod1=%d transitions=%d"),
|
|
ExpectedDesiredSet.Num(), RuntimeCollisionSupportTiles, Actor->CollisionDesiredTiles.Num(),
|
|
Actor->CollisionReadyTiles.Num(), Actor->CollisionNotRequiredTiles.Num(),
|
|
Lod0Tiles, Lod1Tiles, TransitionFaces);
|
|
|
|
const auto LocalPointFromSubvoxels = [CoreVoxelSizeCm](const std::int64_t Position[3])
|
|
{
|
|
return FVector(
|
|
static_cast<double>(Position[0]) * CoreVoxelSizeCm / 256.0,
|
|
static_cast<double>(Position[1]) * CoreVoxelSizeCm / 256.0,
|
|
static_cast<double>(Position[2]) * CoreVoxelSizeCm / 256.0);
|
|
};
|
|
const FVector IslandTopXY = LocalPointFromSubvoxels(
|
|
NativeIslandPlan.manifest.plan.islands.front().centerSubvoxels.data());
|
|
const auto& Connector = NativeIslandPlan.manifest.plan.clusterEdges.front();
|
|
const FVector SpanXY(
|
|
(static_cast<double>(Connector.startSubvoxels[0] + Connector.endSubvoxels[0]) * 0.5)
|
|
* CoreVoxelSizeCm / 256.0,
|
|
(static_cast<double>(Connector.startSubvoxels[1] + Connector.endSubvoxels[1]) * 0.5)
|
|
* CoreVoxelSizeCm / 256.0,
|
|
(static_cast<double>(Connector.startSubvoxels[2] + Connector.endSubvoxels[2]) * 0.5)
|
|
* CoreVoxelSizeCm / 256.0);
|
|
float IslandContentGroundCm = 0.0f;
|
|
float SpanContentGroundCm = 0.0f;
|
|
if (Actor->ContentManager == nullptr
|
|
|| !Actor->ContentManager->QueryNativeFloatingIslandAnchor(IslandStrateIndex,
|
|
FVector2D(IslandTopXY.X, IslandTopXY.Y), IslandContentGroundCm)
|
|
|| !Actor->ContentManager->QueryNativeFloatingIslandAnchor(IslandStrateIndex,
|
|
FVector2D(SpanXY.X, SpanXY.Y), SpanContentGroundCm))
|
|
{
|
|
OutError = TEXT("Core island content could not resolve the island-top and connector anchors.");
|
|
return false;
|
|
}
|
|
const auto CheckTerrainAnchor = [&](const TCHAR* Label, const FVector& XY,
|
|
float ContentGroundCm, float& OutTerrainGroundWorldZ, FVector& OutGroundWorld) -> bool
|
|
{
|
|
const FVector StartLocal(XY.X, XY.Y, ContentGroundCm + 400.0f);
|
|
const FVector StartWorld = Actor->GetActorTransform().TransformPosition(StartLocal);
|
|
float TerrainGroundWorldZ = 0.0f, TerrainCeilingWorldZ = 0.0f;
|
|
if (!Actor->GetVoxelSurfaceHeightAt(StartWorld, TerrainGroundWorldZ, TerrainCeilingWorldZ))
|
|
{
|
|
OutError = FString::Printf(TEXT("Core island %s height query found no ground from air."), Label);
|
|
return false;
|
|
}
|
|
const FVector GroundWorld = Actor->GetActorTransform().TransformPosition(
|
|
FVector(XY.X, XY.Y, ContentGroundCm));
|
|
OutTerrainGroundWorldZ = TerrainGroundWorldZ;
|
|
OutGroundWorld = FVector(GroundWorld.X, GroundWorld.Y, TerrainGroundWorldZ);
|
|
const double DeltaCm = FMath::Abs(TerrainGroundWorldZ - GroundWorld.Z);
|
|
if (DeltaCm > 25.0)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core island %s terrain/content anchors differ by %.2f cm."),
|
|
Label, DeltaCm);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
float IslandTerrainGroundWorldZ = 0.0f, SpanTerrainGroundWorldZ = 0.0f;
|
|
FVector IslandGroundWorld = FVector::ZeroVector, SpanGroundWorld = FVector::ZeroVector;
|
|
if (!CheckTerrainAnchor(TEXT("top"), IslandTopXY, IslandContentGroundCm,
|
|
IslandTerrainGroundWorldZ, IslandGroundWorld)
|
|
|| !CheckTerrainAnchor(TEXT("span"), SpanXY, SpanContentGroundCm,
|
|
SpanTerrainGroundWorldZ, SpanGroundWorld))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const int32 IslandStrate = Actor->GetStrateAtPosition(IslandGroundWorld);
|
|
const int32 SpanStrate = Actor->GetStrateAtPosition(SpanGroundWorld);
|
|
if (IslandStrate != IslandStrateIndex || SpanStrate != IslandStrateIndex
|
|
|| Actor->AtmosphereManager == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Island top/span resolved to wrong strate: expected=%d island=%d span=%d atmosphere=%d."),
|
|
IslandStrateIndex, IslandStrate, SpanStrate, Actor->AtmosphereManager ? 1 : 0);
|
|
return false;
|
|
}
|
|
Actor->AtmosphereManager->UpdateForPlayer(
|
|
Actor->GetActorTransform().TransformPosition(CameraLocalCm));
|
|
const int32 AtmosphereStrate = Actor->AtmosphereManager->GetCurrentStrateIndex();
|
|
if (AtmosphereStrate != IslandStrateIndex)
|
|
{
|
|
OutError = FString::Printf(TEXT("Island route selected atmosphere strate %d instead of %d."),
|
|
AtmosphereStrate, IslandStrateIndex);
|
|
return false;
|
|
}
|
|
bool bContentHasWater = false;
|
|
float ContentWaterLocalZCm = -FLT_MAX;
|
|
if (!Actor->ContentManager->QueryStrateWaterAnchor(
|
|
IslandStrateIndex, bContentHasWater, ContentWaterLocalZCm))
|
|
{
|
|
OutError = TEXT("Core island content did not return the fixed strate water datum.");
|
|
return false;
|
|
}
|
|
float ManagerTopZ = 0.0f, ManagerBottomZ = 0.0f;
|
|
const float ManagerRouteZ = Actor->ToStrateManagerLocalZ(CameraLocalCm.Z);
|
|
if (!Actor->StrateManager->GetStrateUnrealZRange(
|
|
ManagerRouteZ, ManagerTopZ, ManagerBottomZ))
|
|
{
|
|
OutError = TEXT("Core island atmosphere check could not resolve corrected strate bounds.");
|
|
return false;
|
|
}
|
|
const float AtmosphereTopCm = Actor->FromStrateManagerLocalZ(ManagerTopZ);
|
|
const float AtmosphereBottomCm = Actor->FromStrateManagerLocalZ(ManagerBottomZ);
|
|
if (IslandContentGroundCm < AtmosphereBottomCm || IslandContentGroundCm > AtmosphereTopCm
|
|
|| (bContentHasWater && (ContentWaterLocalZCm < AtmosphereBottomCm
|
|
|| ContentWaterLocalZCm > AtmosphereTopCm)))
|
|
{
|
|
OutError = TEXT("Island ground or fixed water datum falls outside the resolved atmosphere strate.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] height_anchor_agreement=PASS island_ground_cm=%.3f island_terrain_world_z=%.3f span_ground_cm=%.3f span_terrain_world_z=%.3f corrected_strate=%d atmosphere_strate=%d water_enabled=%d water_local_cm=%.3f atmosphere_bounds_cm=(%.3f,%.3f)"),
|
|
IslandContentGroundCm, IslandTerrainGroundWorldZ, SpanContentGroundCm,
|
|
SpanTerrainGroundWorldZ, IslandStrate, AtmosphereStrate,
|
|
bContentHasWater ? 1 : 0, ContentWaterLocalZCm, AtmosphereBottomCm, AtmosphereTopCm);
|
|
|
|
const auto CheckCapsuleContact = [&](const TCHAR* Label, const FVector& GroundWorld) -> bool
|
|
{
|
|
const FVector Start = GroundWorld + FVector(0.0, 0.0, 200.0);
|
|
const FVector End = GroundWorld + FVector(0.0, 0.0, 60.0);
|
|
const FCollisionShape Capsule = FCollisionShape::MakeCapsule(34.8f, 90.1f);
|
|
FCollisionQueryParams QueryParams(
|
|
SCENE_QUERY_STAT(VoxelForgeCoreIslandCapsuleContact), false);
|
|
FHitResult Hit;
|
|
if (!TestWorld->SweepSingleByChannel(
|
|
Hit, Start, End, FQuat::Identity, ECC_Pawn, Capsule, QueryParams)
|
|
|| Hit.GetActor() != Actor)
|
|
{
|
|
OutError = FString::Printf(TEXT("BuildHost capsule did not contact the Core island %s."), Label);
|
|
return false;
|
|
}
|
|
const FVector LocalHit = Actor->GetActorTransform().InverseTransformPosition(Hit.ImpactPoint);
|
|
const double CoreTileExtentCm = static_cast<double>(CHUNK_SIZE) * Arguments.CoreVoxelSizeCm;
|
|
const FVoxelTileKey Tile(FIntVector(
|
|
FMath::FloorToInt(LocalHit.X / CoreTileExtentCm),
|
|
FMath::FloorToInt(LocalHit.Y / CoreTileExtentCm),
|
|
FMath::FloorToInt(LocalHit.Z / CoreTileExtentCm)), 0);
|
|
if (!Actor->CollisionReadyTiles.Contains(Tile)
|
|
|| FVector::Dist(Hit.ImpactPoint, GroundWorld) > 100.0)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core island %s capsule hit is outside its cooked support tile."), Label);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] capsule_contact=PASS surface=%s tile=(%d,%d,%d) hit_cm=(%.1f,%.1f,%.1f) collision_ready=1"),
|
|
Label, Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z,
|
|
Hit.ImpactPoint.X, Hit.ImpactPoint.Y, Hit.ImpactPoint.Z);
|
|
return true;
|
|
};
|
|
if (!CheckCapsuleContact(TEXT("island_top"), IslandGroundWorld)
|
|
|| !CheckCapsuleContact(TEXT("span"), SpanGroundWorld))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] content_anchor=PASS source=native_island_field anchors=island_top,connector_span terrain_agreement=PASS"));
|
|
|
|
FVector CarveLocal(IslandTopXY.X, IslandTopXY.Y, IslandContentGroundCm - 40.0f);
|
|
const FVector CarveWorld = Actor->GetActorTransform().TransformPosition(CarveLocal);
|
|
float DensityBefore = 0.0f, OutsideBefore = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorld, DensityBefore)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(CarveWorld + FVector(90, 0, 0), OutsideBefore)
|
|
|| DensityBefore >= 0.0f)
|
|
{
|
|
OutError = TEXT("Island Blueprint edit probe did not begin inside solid rock.");
|
|
return false;
|
|
}
|
|
Actor->CarveAtPosition(CarveWorld, 6.0f, 10.0f);
|
|
float CarvedDensity = 0.0f, OutsideAfterCarve = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorld, CarvedDensity)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(CarveWorld + FVector(90, 0, 0), OutsideAfterCarve)
|
|
|| CarvedDensity <= DensityBefore || FMath::Abs(OutsideAfterCarve - OutsideBefore) > 1.0e-4f)
|
|
{
|
|
OutError = TEXT("Island Blueprint carve did not add air inside 75 cm or changed a point 90 cm away.");
|
|
return false;
|
|
}
|
|
Actor->FillAtPosition(CarveWorld, 6.0f, 10.0f);
|
|
float FilledDensity = 0.0f, OutsideAfterFill = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorld, FilledDensity)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(CarveWorld + FVector(90, 0, 0), OutsideAfterFill)
|
|
|| FilledDensity >= CarvedDensity || FMath::Abs(OutsideAfterFill - OutsideBefore) > 1.0e-4f)
|
|
{
|
|
OutError = TEXT("Island Blueprint fill did not restore solid direction or changed a point 90 cm away.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] blueprint_edit=PASS carve_sign=air fill_sign=solid radius_legacy_voxels=6 radius_cm=%.3f strength_legacy=10 density_before=%.4f carved=%.4f filled=%.4f outside_90cm_delta=%.6f"),
|
|
6.0 * static_cast<double>(VOXEL_SIZE),
|
|
DensityBefore, CarvedDensity, FilledDensity,
|
|
FMath::Abs(OutsideAfterFill - OutsideBefore));
|
|
|
|
const FString CoreSeasonHash = Actor->GetCurrentSeasonContentHash();
|
|
const float LegacyDensityBeforeSwitch = Actor->Generator->GetDensityAt(
|
|
IslandTopXY.X / VOXEL_SIZE, IslandTopXY.Y / VOXEL_SIZE,
|
|
IslandContentGroundCm / VOXEL_SIZE);
|
|
Actor->bUseVoxelForgeCore = false;
|
|
float DisabledAnchorZ = 0.0f;
|
|
const bool bDisabledAnchorAvailable = Actor->ContentManager->QueryNativeFloatingIslandAnchor(
|
|
1, FVector2D(IslandTopXY.X, IslandTopXY.Y), DisabledAnchorZ);
|
|
const FString LegacySeasonHash = Actor->GetCurrentSeasonContentHash();
|
|
const float LegacyDensityAfterSwitch = Actor->Generator->GetDensityAt(
|
|
IslandTopXY.X / VOXEL_SIZE, IslandTopXY.Y / VOXEL_SIZE,
|
|
IslandContentGroundCm / VOXEL_SIZE);
|
|
if (bDisabledAnchorAvailable || Actor->bUseVoxelForgeCore
|
|
|| FMath::Abs(LegacyDensityAfterSwitch - LegacyDensityBeforeSwitch) > 1.0e-6f
|
|
|| CoreSeasonHash.IsEmpty() || CoreSeasonHash == LegacySeasonHash)
|
|
{
|
|
OutError = TEXT("Core-off island check changed legacy generation or retained Core manifest anchors/hash.");
|
|
return false;
|
|
}
|
|
Actor->bUseVoxelForgeCore = true;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreIslandTest] switch_off_legacy=PASS core_hash=%s off_hash=%s native_anchor_when_disabled=0 legacy_voxel_cm=%.1f legacy_density_before=%.6f after=%.6f"),
|
|
*CoreSeasonHash, *LegacySeasonHash, VOXEL_SIZE,
|
|
LegacyDensityBeforeSwitch, LegacyDensityAfterSwitch);
|
|
|
|
if (!RunCoreContentPlacementCostAudit(Actor,
|
|
FVector(IslandTopXY.X, IslandTopXY.Y, IslandContentGroundCm + 100.0f),
|
|
TEXT("FloatingIslands"), OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCoreIslandTest] pass complete=1"));
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreContentPlacementCostAudit(AVoxelWorld* Actor, const FVector& CoreLocalProbeCm,
|
|
const TCHAR* Label, FString& OutError)
|
|
{
|
|
if (Actor == nullptr || Actor->ContentManager == nullptr)
|
|
{
|
|
OutError = TEXT("Content placement cost audit has no owner actor or content manager.");
|
|
return false;
|
|
}
|
|
constexpr int32 SampleCells = 64;
|
|
constexpr int32 SampleFrames = 32;
|
|
const float CoreVoxelSizeCm = Actor->CoreVoxelSizeCm;
|
|
const FTransform OwnerXf = Actor->GetActorTransform();
|
|
|
|
FString AssemblyJson, AssemblyHash;
|
|
if (Actor->GetVoxelForgeCoreWorldAssembly(AssemblyJson,AssemblyHash))
|
|
{
|
|
const FString ExportDir=FPaths::Combine(FPaths::ProjectSavedDir(),TEXT("CoreWorldValidation/Output"));
|
|
IFileManager::Get().MakeDirectory(*ExportDir,true);
|
|
FFileHelper::SaveStringToFile(AssemblyJson,
|
|
*FPaths::Combine(ExportDir,FString::Printf(TEXT("s2_assembly_%s.json"),Label)));
|
|
}
|
|
|
|
UVoxelContentManager::FDecorationPlacementCostAudit CoreAudit;
|
|
Actor->bUseVoxelForgeCore = true;
|
|
const FVector CorePlayerWorld = OwnerXf.TransformPosition(CoreLocalProbeCm);
|
|
if (!Actor->ContentManager->MeasurePlacementCostForValidation(
|
|
CorePlayerWorld, SampleCells, SampleFrames, CoreAudit, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
UVoxelContentManager::FDecorationPlacementCostAudit LegacyAudit;
|
|
Actor->bUseVoxelForgeCore = false;
|
|
const float CoreToOwnerScale = VOXEL_SIZE / CoreVoxelSizeCm;
|
|
const FVector LegacyPlayerWorld = OwnerXf.TransformPosition(CoreLocalProbeCm * CoreToOwnerScale);
|
|
if (!Actor->ContentManager->MeasurePlacementCostForValidation(
|
|
LegacyPlayerWorld, SampleCells, SampleFrames, LegacyAudit, OutError))
|
|
{
|
|
Actor->bUseVoxelForgeCore = true;
|
|
return false;
|
|
}
|
|
Actor->bUseVoxelForgeCore = true;
|
|
|
|
if (CoreAudit.bSyntheticWorkload && CoreAudit.AnchorChecks > 0
|
|
&& (CoreAudit.MaximumAnchorErrorCm > 0.5 || CoreAudit.MaximumDensityResidual > 0.25))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core synthetic decoration anchors drifted: height_error_cm=%.6f density_residual=%.6f."),
|
|
CoreAudit.MaximumAnchorErrorCm, CoreAudit.MaximumDensityResidual);
|
|
return false;
|
|
}
|
|
if (LegacyAudit.bSyntheticWorkload && LegacyAudit.AnchorChecks > 0
|
|
&& LegacyAudit.MaximumDensityResidual > 0.25)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Legacy synthetic decoration anchors drifted: density_residual=%.6f."),
|
|
LegacyAudit.MaximumDensityResidual);
|
|
return false;
|
|
}
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeContentCost] label=%s route=core voxel_cm=%.3f tile_cell_cm=%.3f authored_entries=%d synthetic=%d cells=%d columns_per_cell=%d density_queries_per_cell=%.2f surface_queries_per_cell=%.2f cell_median_ms=%.5f cell_p95_ms=%.5f update_median_ms=%.5f update_p95_ms=%.5f placements=%d anchor_checks=%d max_anchor_error_cm=%.6f max_density_residual=%.6f"),
|
|
Label, CoreVoxelSizeCm, CHUNK_SIZE * CoreVoxelSizeCm,
|
|
CoreAudit.NearEntries + CoreAudit.FarEntries, CoreAudit.bSyntheticWorkload ? 1 : 0,
|
|
CoreAudit.SampledCells, CoreAudit.ColumnsPerCell, CoreAudit.DensityQueriesPerCell,
|
|
CoreAudit.SurfaceQueriesPerCell, CoreAudit.CellMedianMilliseconds,
|
|
CoreAudit.CellP95Milliseconds, CoreAudit.UpdateMedianMilliseconds,
|
|
CoreAudit.UpdateP95Milliseconds, CoreAudit.Placements, CoreAudit.AnchorChecks,
|
|
CoreAudit.MaximumAnchorErrorCm, CoreAudit.MaximumDensityResidual);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeContentCostS2] label=%s authored_full_p95_ms=%.5f authored_candidate_p95_ms=%.5f authored_active_batch_full_ms=%.5f authored_active_batch_candidate_ms=%.5f populated_cells=%d populated_placements=%d populated_full_p95_ms=%.5f populated_candidate_p95_ms=%.5f exact_transform_comparisons=%d anchor_checks=%d max_density_residual=%.6f"),
|
|
Label,CoreAudit.FullScanCellP95Milliseconds,CoreAudit.CellP95Milliseconds,
|
|
CoreAudit.FullScanActiveBatchMilliseconds,CoreAudit.ActiveBatchMilliseconds,
|
|
CoreAudit.PopulatedCells,CoreAudit.PopulatedPlacements,
|
|
CoreAudit.FullScanPopulatedCellP95Milliseconds,CoreAudit.PopulatedCellP95Milliseconds,
|
|
CoreAudit.ExactPlacementComparisons,CoreAudit.AnchorChecks,CoreAudit.MaximumDensityResidual);
|
|
UE_LOG(LogTemp,Display,
|
|
TEXT("[VoxelForgeContentActiveS2] label=%s cells=%d placements=%d full_batch_ms=%.5f candidate_batch_ms=%.5f merge_ms=%.5f apply_ms=%.5f applied_instances=%d full_total_cpu_ms=%.5f candidate_total_cpu_ms=%.5f rhi=null"),
|
|
Label,CoreAudit.SampledCells,CoreAudit.PopulatedPlacements,
|
|
CoreAudit.FullScanPopulatedBatchMilliseconds,CoreAudit.PopulatedBatchMilliseconds,
|
|
CoreAudit.PopulatedMergeMilliseconds,CoreAudit.PopulatedApplyMilliseconds,CoreAudit.AppliedInstances,
|
|
CoreAudit.FullScanPopulatedBatchMilliseconds+CoreAudit.PopulatedMergeMilliseconds+CoreAudit.PopulatedApplyMilliseconds,
|
|
CoreAudit.PopulatedBatchMilliseconds+CoreAudit.PopulatedMergeMilliseconds+CoreAudit.PopulatedApplyMilliseconds);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeContentCost] label=%s route=legacy voxel_cm=%.3f tile_cell_cm=%.3f authored_entries=%d synthetic=%d cells=%d columns_per_cell=%d density_queries_per_cell=%.2f surface_queries_per_cell=%.2f cell_median_ms=%.5f cell_p95_ms=%.5f update_median_ms=%.5f update_p95_ms=%.5f placements=%d anchor_checks=%d max_anchor_error_cm=%.6f max_density_residual=%.6f"),
|
|
Label, VOXEL_SIZE, CHUNK_SIZE * VOXEL_SIZE,
|
|
LegacyAudit.NearEntries + LegacyAudit.FarEntries, LegacyAudit.bSyntheticWorkload ? 1 : 0,
|
|
LegacyAudit.SampledCells, LegacyAudit.ColumnsPerCell, LegacyAudit.DensityQueriesPerCell,
|
|
LegacyAudit.SurfaceQueriesPerCell, LegacyAudit.CellMedianMilliseconds,
|
|
LegacyAudit.CellP95Milliseconds, LegacyAudit.UpdateMedianMilliseconds,
|
|
LegacyAudit.UpdateP95Milliseconds, LegacyAudit.Placements, LegacyAudit.AnchorChecks,
|
|
LegacyAudit.MaximumAnchorErrorCm, LegacyAudit.MaximumDensityResidual);
|
|
return true;
|
|
}
|
|
|
|
bool RunCoreWorldActorIntegrationTest(const FExploreArguments& Arguments, FString& OutError)
|
|
{
|
|
if (Arguments.bCoreFloatingIslandTest)
|
|
{
|
|
return RunCoreFloatingIslandActorIntegrationTest(Arguments, OutError);
|
|
}
|
|
if (Arguments.bCorePlanOnlyTest)
|
|
{
|
|
return RunCorePlanOnlyTest(Arguments, OutError);
|
|
}
|
|
if (Arguments.bCoreFallbackTest)
|
|
{
|
|
return RunCoreFallbackActorTest(Arguments, OutError);
|
|
}
|
|
if (Arguments.bCoreOffTest)
|
|
{
|
|
return RunCoreOffActorTest(Arguments, OutError);
|
|
}
|
|
UVoxelSettings* AuthoredSettings = LoadObject<UVoxelSettings>(
|
|
nullptr, *Arguments.SettingsReference);
|
|
if (!AuthoredSettings)
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not load Core actor test settings '%s'."),
|
|
*Arguments.SettingsReference);
|
|
return false;
|
|
}
|
|
|
|
float StagedWalkSpeedCmPerSecond = 0.0f;
|
|
float StagedSprintSpeedCmPerSecond = 0.0f;
|
|
if (Arguments.bCoreWorldStreamTraceOnly)
|
|
{
|
|
StagedWalkSpeedCmPerSecond = 600.0f;
|
|
StagedSprintSpeedCmPerSecond = 6000.0f;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgePawnSpeedFixture] source=trace_fixture_defaults walk_cm_s=%.3f sprint_cm_s=%.3f"),
|
|
StagedWalkSpeedCmPerSecond, StagedSprintSpeedCmPerSecond);
|
|
}
|
|
else if (!ReadStagedPlayerSpeeds(
|
|
StagedWalkSpeedCmPerSecond, StagedSprintSpeedCmPerSecond, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const int32 OwnerSeed = ResolveCoreTestWorldSeed(AuthoredSettings);
|
|
TStrongObjectPtr<UVoxelSettings> OwnerSurfaceSettings;
|
|
TStrongObjectPtr<UVoxelStrateDefinition> OwnerSurfaceDefinition;
|
|
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> OwnerPlanDefinitions;
|
|
if (Arguments.bOwnerFourStratePlan)
|
|
{
|
|
UVoxelSettings* PlanSettings = DuplicateObject<UVoxelSettings>(
|
|
AuthoredSettings, GetTransientPackage(), TEXT("VoxelForgeOwnerFourStrateTraceSettings"));
|
|
if (PlanSettings == nullptr)
|
|
{
|
|
OutError = TEXT("Could not duplicate owner settings for the transient four-strate Core trace.");
|
|
return false;
|
|
}
|
|
PlanSettings->Seed = OwnerSeed;
|
|
if (!ApplyOwnerFourStratePlan(PlanSettings, OwnerPlanDefinitions, true, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
OwnerSurfaceSettings = TStrongObjectPtr<UVoxelSettings>(PlanSettings);
|
|
}
|
|
else if (!CreateLoggedOwnerSurfaceSettingsCopy(AuthoredSettings, OwnerSeed,
|
|
OwnerSurfaceSettings, OwnerSurfaceDefinition, OutError,
|
|
Arguments.bCoreWorldStreamTraceOnly))
|
|
{
|
|
return false;
|
|
}
|
|
AuthoredSettings = OwnerSurfaceSettings.Get();
|
|
|
|
if (Arguments.bCoreHybridFixturesOnly)
|
|
{
|
|
UVoxelGenerator* FixtureGenerator = NewObject<UVoxelGenerator>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
if (FixtureGenerator == nullptr)
|
|
{
|
|
OutError = TEXT("Could not allocate the focused hybrid fixture generator.");
|
|
return false;
|
|
}
|
|
FixtureGenerator->InitializeSettings(AuthoredSettings);
|
|
if (!RunHybridPluginMesherFixtures(
|
|
FixtureGenerator, Arguments.bCoreHybridMesherV2, OutError)) return false;
|
|
UE_LOG(LogTemp, Display, TEXT("[WorldAssemblerHybridPluginFixture] fixture_only=PASS"));
|
|
return true;
|
|
}
|
|
|
|
if (!Arguments.CoreWorldAssemblyManifestPath.IsEmpty())
|
|
{
|
|
const FString ManifestPath = FPaths::ConvertRelativePathToFull(
|
|
Arguments.CoreWorldAssemblyManifestPath);
|
|
if (!IsUnderVoxelForgeSaved(ManifestPath))
|
|
{
|
|
OutError = TEXT("Core world assembly test input must be staged under the plugin Saved directory.");
|
|
return false;
|
|
}
|
|
FString ManifestJson;
|
|
if (!FFileHelper::LoadFileToString(ManifestJson, *ManifestPath))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not read staged Core world assembly '%s'."), *ManifestPath);
|
|
return false;
|
|
}
|
|
FString ImportReport;
|
|
if (!AuthoredSettings->SetWorldAssemblyManifestJson(ManifestJson, ImportReport))
|
|
{
|
|
OutError = FString::Printf(TEXT("Could not inject native SurfaceWorld plan: %s"), *ImportReport);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCoreWorldTest] %s"), *ImportReport);
|
|
}
|
|
|
|
std::vector<FHybridFixtureContactProbe> HybridFixtureContacts;
|
|
FString HybridFixtureManifestHash;
|
|
if (Arguments.bCoreHybridFixturePlacement
|
|
&& !AddHybridFixturePlacement(AuthoredSettings, Arguments.OutDirectory,
|
|
HybridFixtureContacts, HybridFixtureManifestHash, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
if (Arguments.bCoreHybridFixturePlacement)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] hybrid_fixture_placement=1 boxes=9 contacts=%d manifest_hash=%s file=world_assembly_hybrid_fixtures.json"),
|
|
static_cast<int32>(HybridFixtureContacts.size()), *HybridFixtureManifestHash);
|
|
}
|
|
|
|
TStrongObjectPtr<UVoxelStrateManager> Manager(NewObject<UVoxelStrateManager>(
|
|
GetTransientPackage(), NAME_None, RF_Transient));
|
|
int32 ResolvedSeed = AuthoredSettings->Seed;
|
|
if (const UVoxelSeasonAsset* Season = AuthoredSettings->Season.LoadSynchronous())
|
|
{
|
|
ResolvedSeed = Season->GetSeasonSeed();
|
|
}
|
|
if (!Manager.IsValid() || !Manager->Initialize(AuthoredSettings, ResolvedSeed))
|
|
{
|
|
OutError = TEXT("Core actor test could not resolve the owner's strate manager.");
|
|
return false;
|
|
}
|
|
FString Json;
|
|
if (!VoxelForgeCoreAdapter::SerializeResolvedWorld(
|
|
AuthoredSettings, Manager.Get(), 3, 8, Arguments.CoreVoxelSizeCm, Json, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (Arguments.bCoreWorldStreamTraceOnly)
|
|
{
|
|
const UWorld::InitializationValues WorldInitialization = UWorld::InitializationValues()
|
|
.AllowAudioPlayback(false)
|
|
.RequiresHitProxies(false)
|
|
.CreatePhysicsScene(true)
|
|
.CreateNavigation(false)
|
|
.CreateAISystem(false)
|
|
.ShouldSimulatePhysics(true)
|
|
.SetDefaultGameMode(AGameModeBase::StaticClass())
|
|
.SetTransactional(false);
|
|
UWorld* TestWorld = UWorld::CreateWorld(
|
|
EWorldType::Game, false, FName(TEXT("VoxelForgeCoreStreamTraceWorld")),
|
|
GetTransientPackage(), false, ERHIFeatureLevel::Num, &WorldInitialization);
|
|
if (TestWorld == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the transient BuildHost stream-trace world.");
|
|
return false;
|
|
}
|
|
UGameInstance* TestGameInstance = NewObject<UGameInstance>(GEngine);
|
|
FWorldContext& TestWorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
|
|
TestWorldContext.OwningGameInstance = TestGameInstance;
|
|
TestWorldContext.SetCurrentWorld(TestWorld);
|
|
TestWorld->SetGameInstance(TestGameInstance);
|
|
TestWorld->SetShouldTick(false);
|
|
TestGameInstance->Init();
|
|
TestWorld->AddToRoot();
|
|
struct FStreamTraceWorldCleanup
|
|
{
|
|
UWorld* World = nullptr;
|
|
uint64 PreviousFrameCounter = GFrameCounter;
|
|
~FStreamTraceWorldCleanup()
|
|
{
|
|
if (World != nullptr)
|
|
{
|
|
if (World->HasBegunPlay())
|
|
{
|
|
World->BeginTearingDown();
|
|
World->EndPlay(EEndPlayReason::Quit);
|
|
}
|
|
if (UGameInstance* GameInstance = World->GetGameInstance())
|
|
{
|
|
GameInstance->Shutdown();
|
|
}
|
|
if (GEngine != nullptr) GEngine->DestroyWorldContext(World);
|
|
World->DestroyWorld(false);
|
|
if (World->IsRooted()) World->RemoveFromRoot();
|
|
}
|
|
GFrameCounter = PreviousFrameCounter;
|
|
}
|
|
} Cleanup{TestWorld, GFrameCounter};
|
|
|
|
if (!TestWorld->SetGameMode(FURL()))
|
|
{
|
|
OutError = TEXT("Could not create the transient BuildHost GameMode for stream tracing.");
|
|
return false;
|
|
}
|
|
if (AGameModeBase* GameMode = TestWorld->GetAuthGameMode())
|
|
{
|
|
GameMode->DefaultPawnClass = nullptr;
|
|
}
|
|
UPlayer* TestPlayer = NewObject<UPlayer>(GEngine, NAME_None, RF_Transient);
|
|
APlayerController* PlayerController = TestWorld->SpawnActor<APlayerController>(
|
|
APlayerController::StaticClass(), FTransform::Identity, FActorSpawnParameters());
|
|
if (TestPlayer == nullptr || PlayerController == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the BuildHost stream-trace player controller.");
|
|
return false;
|
|
}
|
|
PlayerController->SetPlayer(TestPlayer);
|
|
TestWorld->AddController(PlayerController);
|
|
APawn* PlayerPawn = TestWorld->SpawnActor<APawn>(
|
|
APawn::StaticClass(), FVector::ZeroVector, FRotator::ZeroRotator);
|
|
if (PlayerPawn == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the BuildHost stream-trace pawn.");
|
|
return false;
|
|
}
|
|
FVector StartupTraceSpawnCm = FVector::ZeroVector;
|
|
FString StartupTraceSpawnText;
|
|
if (FParse::Value(FCommandLine::Get(), TEXT("voxel.StartupTraceSpawnCm="),
|
|
StartupTraceSpawnText, false))
|
|
{
|
|
TArray<FString> Components;
|
|
StartupTraceSpawnText.ParseIntoArray(Components, TEXT(","), true);
|
|
if (Components.Num() != 3
|
|
|| !LexTryParseString(StartupTraceSpawnCm.X, *Components[0])
|
|
|| !LexTryParseString(StartupTraceSpawnCm.Y, *Components[1])
|
|
|| !LexTryParseString(StartupTraceSpawnCm.Z, *Components[2])
|
|
|| !FMath::IsFinite(StartupTraceSpawnCm.X)
|
|
|| !FMath::IsFinite(StartupTraceSpawnCm.Y)
|
|
|| !FMath::IsFinite(StartupTraceSpawnCm.Z))
|
|
{
|
|
OutError = TEXT("voxel.StartupTraceSpawnCm must be finite X,Y,Z centimetres.");
|
|
return false;
|
|
}
|
|
}
|
|
USceneComponent* PawnRoot = NewObject<USceneComponent>(PlayerPawn, NAME_None, RF_Transient);
|
|
if (PawnRoot == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the BuildHost stream-trace pawn root.");
|
|
return false;
|
|
}
|
|
PlayerPawn->SetRootComponent(PawnRoot);
|
|
PawnRoot->RegisterComponent();
|
|
PlayerPawn->SetActorLocation(StartupTraceSpawnCm, false, nullptr, ETeleportType::TeleportPhysics);
|
|
PlayerController->Possess(PlayerPawn);
|
|
if (TestWorld->GetFirstPlayerController() != PlayerController
|
|
|| PlayerController->GetPawn() != PlayerPawn)
|
|
{
|
|
OutError = TEXT("BuildHost stream-trace controller did not retain its pawn.");
|
|
return false;
|
|
}
|
|
|
|
const FTransform ActorTransform = FTransform::Identity;
|
|
AVoxelWorld* Actor = TestWorld->SpawnActorDeferred<AVoxelWorld>(
|
|
AVoxelWorld::StaticClass(), ActorTransform, nullptr, nullptr,
|
|
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
|
|
if (Actor == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the transient AVoxelWorld for stream tracing.");
|
|
return false;
|
|
}
|
|
Actor->Settings = AuthoredSettings;
|
|
Actor->bManageAtmosphere = false;
|
|
Actor->bUseVoxelForgeCore = true;
|
|
Actor->CoreVoxelSizeCm = Arguments.CoreVoxelSizeCm;
|
|
Actor->CoreMesherRecipe = Arguments.CoreHybridMesherVersion == 2
|
|
? EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV2
|
|
: (Arguments.CoreHybridMesherVersion == 1
|
|
? EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV1
|
|
: EVoxelForgeCoreMesherRecipe::MarchingCubesV1);
|
|
Actor->CoreClipRadius = 3;
|
|
Actor->CoreMaxClipLevel = 8;
|
|
Actor->bCaptureVoxelForgeCoreHashesForValidation = true;
|
|
Actor->FinishSpawning(ActorTransform);
|
|
TestWorld->InitializeActorsForPlay(FURL());
|
|
TestWorld->BeginPlay();
|
|
if (!VoxelForgeStartupTrace::IsActive())
|
|
{
|
|
OutError = TEXT("Core stream trace mode requires -voxel.StartupTraceFile.");
|
|
return false;
|
|
}
|
|
Actor->SetActorTickEnabled(true);
|
|
VoxelForgeStartupTrace::RecordEvent(TEXT("trace_spawn_location"), FString::Printf(
|
|
TEXT("\"location_cm\":[%s,%s,%s]"),
|
|
*FString::SanitizeFloat(StartupTraceSpawnCm.X),
|
|
*FString::SanitizeFloat(StartupTraceSpawnCm.Y),
|
|
*FString::SanitizeFloat(StartupTraceSpawnCm.Z)));
|
|
|
|
auto DesiredTilesLoaded = [](const AVoxelWorld* World)
|
|
{
|
|
if (World == nullptr || World->DesiredSorted.IsEmpty()) return false;
|
|
for (const FVoxelTileKey& Key : World->DesiredSorted)
|
|
{
|
|
if (!World->LoadedTiles.Contains(Key)) return false;
|
|
}
|
|
return true;
|
|
};
|
|
auto CollisionSetSettled = [](const AVoxelWorld* World)
|
|
{
|
|
if (World == nullptr) return false;
|
|
for (const FVoxelTileKey& Key : World->CollisionDesiredTiles)
|
|
{
|
|
if (!World->CollisionReadyTiles.Contains(Key)
|
|
&& !World->CollisionNotRequiredTiles.Contains(Key)) return false;
|
|
}
|
|
return true;
|
|
};
|
|
const double ActorBeginSeconds = FPlatformTime::Seconds();
|
|
constexpr double WalkStartSeconds = 30.0;
|
|
constexpr double RunDurationSeconds = 45.0;
|
|
float WalkSpeedCmPerSecond = 600.0f;
|
|
FParse::Value(FCommandLine::Get(), TEXT("voxel.TestMoveSpeedCmPerSecond="),
|
|
WalkSpeedCmPerSecond);
|
|
FString WalkDirectionText(TEXT("0,1,0"));
|
|
const FString DirectionKey(TEXT("voxel.TestMoveDirection="));
|
|
const FString CommandLineText(FCommandLine::Get());
|
|
const int32 DirectionKeyIndex = CommandLineText.Find(
|
|
DirectionKey, ESearchCase::IgnoreCase, ESearchDir::FromStart);
|
|
if (DirectionKeyIndex != INDEX_NONE)
|
|
{
|
|
WalkDirectionText = CommandLineText.Mid(DirectionKeyIndex + DirectionKey.Len());
|
|
const int32 DirectionEnd = WalkDirectionText.Find(
|
|
TEXT(" "), ESearchCase::CaseSensitive, ESearchDir::FromStart);
|
|
if (DirectionEnd != INDEX_NONE)
|
|
{
|
|
WalkDirectionText.LeftInline(DirectionEnd, EAllowShrinking::No);
|
|
}
|
|
WalkDirectionText.TrimStartAndEndInline();
|
|
WalkDirectionText.RemoveFromStart(TEXT("\""));
|
|
WalkDirectionText.RemoveFromEnd(TEXT("\""));
|
|
}
|
|
TArray<double> SpawnTickMilliseconds;
|
|
TArray<double> WalkTickMilliseconds;
|
|
TArray<TPair<double, double>> TickSamples;
|
|
bool bInitialSetReady = false;
|
|
double InitialSetReadySeconds = -1.0;
|
|
while (FPlatformTime::Seconds() - ActorBeginSeconds < RunDurationSeconds)
|
|
{
|
|
const double TickStartSeconds = FPlatformTime::Seconds();
|
|
TestWorld->Tick(LEVELTICK_All, 0.016f);
|
|
const double TickMilliseconds =
|
|
(FPlatformTime::Seconds() - TickStartSeconds) * 1000.0;
|
|
++GFrameCounter;
|
|
const double ElapsedSeconds = FPlatformTime::Seconds() - ActorBeginSeconds;
|
|
TickSamples.Emplace(ElapsedSeconds, TickMilliseconds);
|
|
|
|
if (!bInitialSetReady && Actor->IsVoxelForgeCoreReady()
|
|
&& Actor->bAllChunksLoaded && Actor->PendingTiles.IsEmpty()
|
|
&& Actor->PendingCollisionCooks.IsEmpty() && Actor->PendingUnload.IsEmpty()
|
|
&& Actor->ActiveTaskCount.load(std::memory_order_acquire) == 0
|
|
&& DesiredTilesLoaded(Actor) && CollisionSetSettled(Actor))
|
|
{
|
|
bInitialSetReady = true;
|
|
InitialSetReadySeconds = ElapsedSeconds;
|
|
VoxelForgeStartupTrace::RecordEvent(TEXT("stream_trace_milestone"), FString::Printf(
|
|
TEXT("\"name\":\"initial_set_ready\",\"elapsed_s\":%s,\"desired\":%d,\"loaded\":%d,\"collision_desired\":%d,\"collision_ready\":%d"),
|
|
*FString::SanitizeFloat(ElapsedSeconds), Actor->DesiredSorted.Num(),
|
|
Actor->LoadedTiles.Num(), Actor->CollisionDesiredTiles.Num(),
|
|
Actor->CollisionReadyTiles.Num()));
|
|
}
|
|
FPlatformProcess::Sleep(0.016f);
|
|
}
|
|
|
|
for (const TPair<double, double>& Sample : TickSamples)
|
|
{
|
|
if (bInitialSetReady && Sample.Key <= InitialSetReadySeconds)
|
|
{
|
|
SpawnTickMilliseconds.Add(Sample.Value);
|
|
}
|
|
else if (Sample.Key >= WalkStartSeconds)
|
|
{
|
|
WalkTickMilliseconds.Add(Sample.Value);
|
|
}
|
|
}
|
|
|
|
auto PercentileMilliseconds = [](TArray<double> Values, double Fraction)
|
|
{
|
|
if (Values.IsEmpty()) return 0.0;
|
|
Values.Sort();
|
|
const int32 Index = FMath::Clamp(
|
|
FMath::CeilToInt(Fraction * static_cast<double>(Values.Num() - 1)),
|
|
0, Values.Num() - 1);
|
|
return Values[Index];
|
|
};
|
|
const auto MaxMilliseconds = [](const TArray<double>& Values)
|
|
{
|
|
double Maximum = 0.0;
|
|
for (const double Value : Values) Maximum = FMath::Max(Maximum, Value);
|
|
return Maximum;
|
|
};
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldStreamTrace] initial_ready=%d initial_ready_s=%.6f walk_start_s=%.3f run_s=%.3f desired=%d loaded=%d "
|
|
"spawn_tick_ms_p95=%.3f spawn_tick_ms_max=%.3f walk_tick_ms_p95=%.3f walk_tick_ms_max=%.3f"),
|
|
bInitialSetReady ? 1 : 0, InitialSetReadySeconds, WalkStartSeconds,
|
|
RunDurationSeconds, Actor->DesiredSorted.Num(), Actor->LoadedTiles.Num(),
|
|
PercentileMilliseconds(SpawnTickMilliseconds, 0.95),
|
|
MaxMilliseconds(SpawnTickMilliseconds),
|
|
PercentileMilliseconds(WalkTickMilliseconds, 0.95),
|
|
MaxMilliseconds(WalkTickMilliseconds));
|
|
if (!bInitialSetReady)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Actor-only Core stream trace did not reach initial readiness: ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d."),
|
|
Actor->IsVoxelForgeCoreReady() ? 1 : 0, Actor->DesiredSorted.Num(),
|
|
Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(),
|
|
Actor->PendingCollisionCooks.Num(),
|
|
Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldStreamTrace] pass complete=1 recipe=%s voxel_cm=%.3f walk_speed_cm_s=%.0f walk_direction=%s"),
|
|
Arguments.CoreHybridMesherVersion == 2 ? TEXT("WorldAssemblerHybridV2")
|
|
: (Arguments.CoreHybridMesherVersion == 1 ? TEXT("WorldAssemblerHybridV1")
|
|
: TEXT("MarchingCubesV1")),
|
|
Arguments.CoreVoxelSizeCm, WalkSpeedCmPerSecond, *WalkDirectionText);
|
|
return true;
|
|
}
|
|
|
|
// Build one Standalone reference before creating the actor world so the two full Core worlds
|
|
// never overlap in memory. The reference also precomputes the exact edit result and hashes.
|
|
// Construire la référence Standalone avant le monde acteur pour ne jamais garder deux mondes
|
|
// Core complets en mémoire simultanément ; elle calcule aussi les hachages après édition.
|
|
TMap<FVoxelTileKey, FVoxelForgeCoreTileHashRecord> ExpectedHashes;
|
|
TMap<FVoxelTileKey, FVoxelForgeCoreTileHashRecord> ExpectedPostEditHashes;
|
|
TMap<FVoxelTileKey, FVoxelForgeCoreTileHashRecord> ExpectedTravelHashes;
|
|
TMap<FVoxelTileKey, FVoxelForgeCoreTileHashRecord> ExpectedSurfaceTravelHashes;
|
|
std::optional<VoxelForgeCore::TileMesh> ExpectedHybridFixtureVisualMesh;
|
|
TSet<FVoxelTileKey> ExpectedInitialDesiredSet;
|
|
TSet<FVoxelTileKey> ExpectedTravelDesiredSet;
|
|
TSet<FVoxelTileKey> ExpectedSurfaceTravelDesiredSet;
|
|
TSet<FVoxelTileKey> ExpectedSurfaceCollisionSet;
|
|
int32 ExpectedSurfaceCollisionBodyCount = 0;
|
|
int32 ExpectedSurfaceCollisionNoBodyCount = 0;
|
|
TArray<FVoxelTileKey> ExpectedAffectedTiles;
|
|
FVector CarveLocalPositionCm = FVector::ZeroVector;
|
|
FVoxelTileKey CarveTargetTile;
|
|
constexpr float CarveBrushRadiusLegacyVoxels = 6.0f;
|
|
const double CoreVoxelSizeCm = Arguments.CoreVoxelSizeCm;
|
|
const double CarveBrushRadiusCoreVoxels =
|
|
CarveBrushRadiusLegacyVoxels * static_cast<double>(VOXEL_SIZE) / CoreVoxelSizeCm;
|
|
const int32 CollisionRadius = FMath::Clamp(AuthoredSettings->PawnCollisionRadiusChunks, 1, 4);
|
|
FIntVector ColdStartCollisionCenter = FIntVector::ZeroValue;
|
|
uint64 ExpectedEditDigest = 0;
|
|
std::array<std::uint64_t, 4> InitialAggregates{};
|
|
std::array<std::uint64_t, 4> TravelAggregates{};
|
|
std::array<std::uint64_t, 4> SurfaceTravelAggregates{};
|
|
std::vector<VoxelForgeCore::TileKey> MatchedTimingInitialDesired;
|
|
std::vector<VoxelForgeCore::TileKey> MatchedTimingSurfaceDesired;
|
|
std::vector<VoxelForgeCore::TileKey> MatchedTimingSurfaceMissingFine;
|
|
VoxelForgeCore::ShapeBuildRequest MatchedTimingInitialRequest;
|
|
double NativeSurfaceRegionCompileSeconds = 0.0;
|
|
double NativeSurfaceTileGenerationSeconds = 0.0;
|
|
std::vector<FAssemblerTileTimingSample> NativeSurfaceTileSamples;
|
|
std::vector<FAssemblerTileTimingSample> BridgeSurfaceTileSamples;
|
|
std::uint64_t NativeSurfaceTriangleCount = 0;
|
|
std::size_t ExpectedInitialTileCount = 0;
|
|
std::size_t ExpectedTravelTileCount = 0;
|
|
std::size_t TravelFineTilesCompiled = 0;
|
|
std::size_t TravelRegionsBuilt = 0;
|
|
std::int32_t TravelCoverageLocalTiles = 0;
|
|
std::int32_t TravelCoverageFaces = 0;
|
|
std::int32_t TravelLodTransitionFaces = 0;
|
|
std::int32_t TravelTilesWithSkirts = 0;
|
|
std::size_t SurfaceTravelFineTilesCompiled = 0;
|
|
std::size_t SurfaceTravelRegionsBuilt = 0;
|
|
std::int32_t SurfaceTravelCoverageLocalTiles = 0;
|
|
std::int32_t SurfaceTravelCoverageFaces = 0;
|
|
std::int32_t SurfaceTravelLodTransitionFaces = 0;
|
|
std::int32_t SurfaceTravelTilesWithSkirts = 0;
|
|
VoxelForgeCore::TileRegionCompileStats TravelCompileStats;
|
|
VoxelForgeCore::TileRegionCompileStats SurfaceTravelCompileStats;
|
|
const VoxelForgeCore::Vec3d TravelCameraVoxels{-156.75164794921875, 25.093801498413086,
|
|
-823.67364501953125};
|
|
VoxelForgeCore::Vec3d SurfaceCameraVoxels{};
|
|
int32 SurfaceStrateIndex = INDEX_NONE;
|
|
uint32 NativeSurfaceBiomeId = 0;
|
|
int32 NativeBiomeIdsSampled = 0;
|
|
int32 NativeBiomeSampleCount = 0;
|
|
bool bNativeBiomeFieldValidated = false;
|
|
{
|
|
FTCHARToUTF8 Utf8Json(*Json);
|
|
std::string CoreError;
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> Snapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(Utf8Json.Get(), static_cast<std::size_t>(Utf8Json.Length())),
|
|
CoreError);
|
|
if (!Snapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core rejected runtime adapter JSON: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
FString CheckedAssemblyJson;
|
|
FString ExpectedAssemblyHash;
|
|
FString AssemblyValidationError;
|
|
if (!AuthoredSettings->GetWorldAssemblyManifestJson(
|
|
CheckedAssemblyJson, ExpectedAssemblyHash, AssemblyValidationError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Core actor test settings manifest failed validation: %s"),
|
|
*AssemblyValidationError);
|
|
return false;
|
|
}
|
|
int32 ExpectedSurfacePlanCount = 0;
|
|
int32 ExpectedIslandPlanCount = 0;
|
|
for (const FStrateSlot& Slot : Manager->GetLayout())
|
|
{
|
|
if (Slot.Definition == nullptr) continue;
|
|
const ECaveGeneratorType Type = Manager->GetGeneratorTypeForChunk(
|
|
FIntVector(0, 0, Slot.TopChunkZ));
|
|
ExpectedSurfacePlanCount += Type == ECaveGeneratorType::SurfaceWorld ? 1 : 0;
|
|
ExpectedIslandPlanCount += Type == ECaveGeneratorType::FloatingIslands ? 1 : 0;
|
|
if (SurfaceStrateIndex == INDEX_NONE && Type == ECaveGeneratorType::SurfaceWorld)
|
|
SurfaceStrateIndex = Slot.StrateIndex;
|
|
}
|
|
const VoxelForgeCore::WorldAssemblyManifest* Assembly = Snapshot->worldAssembly();
|
|
if (Assembly == nullptr
|
|
|| Assembly->plan.strates.size() != static_cast<std::size_t>(ExpectedSurfacePlanCount)
|
|
|| Assembly->floatingIslands.size() != static_cast<std::size_t>(ExpectedIslandPlanCount))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core runtime adapter planned SurfaceWorld/island counts %d/%d, expected %d/%d from the resolved owner layout."),
|
|
Assembly ? static_cast<int32>(Assembly->plan.strates.size()) : 0,
|
|
Assembly ? static_cast<int32>(Assembly->floatingIslands.size()) : 0,
|
|
ExpectedSurfacePlanCount, ExpectedIslandPlanCount);
|
|
return false;
|
|
}
|
|
if (!ExpectedAssemblyHash.IsEmpty())
|
|
{
|
|
const FString SnapshotHash = FString::Printf(
|
|
TEXT("%016llx"), static_cast<unsigned long long>(Assembly->contentHash));
|
|
if (!SnapshotHash.Equals(ExpectedAssemblyHash, ESearchCase::IgnoreCase))
|
|
{
|
|
OutError = TEXT("Core runtime adapter did not preserve the settings world assembly manifest.");
|
|
return false;
|
|
}
|
|
}
|
|
else if (!ExpectedSurfacePlanCount && !ExpectedIslandPlanCount)
|
|
{
|
|
OutError = TEXT("The owner layout has no SurfaceWorld or FloatingIslands plan for this cross-system test.");
|
|
return false;
|
|
}
|
|
const FString SnapshotHash = FString::Printf(TEXT("%016llx"),
|
|
static_cast<unsigned long long>(Assembly->contentHash));
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] world_assembly_source=%s world_assembly_hash=%s surface_strates=%d island_strates=%d native=1"),
|
|
ExpectedAssemblyHash.IsEmpty() ? TEXT("generated_at_startup") : TEXT("settings_override"),
|
|
*SnapshotHash, static_cast<int32>(Assembly->plan.strates.size()),
|
|
static_cast<int32>(Assembly->floatingIslands.size()));
|
|
|
|
// Validate the native Core field's biome assignment, not the legacy decoration query.
|
|
// Valider les biomes du champ Core natif, pas la requete historique des decorations.
|
|
const VoxelForgeCore::WorldAssemblyManifest* NativeAssembly = Assembly;
|
|
const VoxelForgeCore::WorldAssemblyStratumPlanV1* NativeSurfacePlan = nullptr;
|
|
if (NativeAssembly != nullptr)
|
|
{
|
|
for (const VoxelForgeCore::WorldAssemblyStratumPlanV1& Plan : NativeAssembly->plan.strates)
|
|
{
|
|
if (Plan.strateIndex == SurfaceStrateIndex)
|
|
{
|
|
NativeSurfacePlan = &Plan;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (NativeAssembly == nullptr || NativeSurfacePlan == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core runtime snapshot has no native biome plan for owner SurfaceWorld strate %d."),
|
|
SurfaceStrateIndex);
|
|
return false;
|
|
}
|
|
const VoxelForgeCore::Heightfield2D NativeSurfaceField(
|
|
NativeAssembly->recipe, *NativeSurfacePlan);
|
|
if (!NativeSurfaceField.isValid())
|
|
{
|
|
OutError = TEXT("Core runtime snapshot's native SurfaceWorld heightfield is invalid.");
|
|
return false;
|
|
}
|
|
const int64 SubvoxelsPerVoxel = VoxelForgeCore::WorldAssemblySubvoxelsPerVoxel;
|
|
if (NativeSurfacePlan->route.empty())
|
|
{
|
|
OutError = TEXT("Generated owner SurfaceWorld plan has no walkable route point.");
|
|
return false;
|
|
}
|
|
SurfaceCameraVoxels = VoxelForgeCore::Vec3d{
|
|
static_cast<double>(NativeSurfacePlan->route.front().positionSubvoxels[0]) / SubvoxelsPerVoxel,
|
|
static_cast<double>(NativeSurfacePlan->route.front().positionSubvoxels[1]) / SubvoxelsPerVoxel,
|
|
static_cast<double>(NativeSurfacePlan->route.front().positionSubvoxels[2]) / SubvoxelsPerVoxel};
|
|
const VoxelForgeCore::HeightfieldPointSample SurfaceBiomeSample = NativeSurfaceField.point(
|
|
static_cast<int64>(std::llround(SurfaceCameraVoxels.x * SubvoxelsPerVoxel)),
|
|
static_cast<int64>(std::llround(SurfaceCameraVoxels.y * SubvoxelsPerVoxel)), 0);
|
|
if (SurfaceBiomeSample.biomeId == 0)
|
|
{
|
|
OutError = TEXT("Native SurfaceWorld heightfield returned no biome at the route camera.");
|
|
return false;
|
|
}
|
|
TSet<uint32> NativeBiomeIds;
|
|
constexpr int32 BiomeProbeSide = 64;
|
|
constexpr double BiomeProbeSpanVoxels = 3200.0;
|
|
for (int32 Y = 0; Y < BiomeProbeSide; ++Y)
|
|
for (int32 X = 0; X < BiomeProbeSide; ++X)
|
|
{
|
|
const double XVoxel = (static_cast<double>(X) / (BiomeProbeSide - 1) - 0.5)
|
|
* BiomeProbeSpanVoxels;
|
|
const double YVoxel = (static_cast<double>(Y) / (BiomeProbeSide - 1) - 0.5)
|
|
* BiomeProbeSpanVoxels;
|
|
const VoxelForgeCore::HeightfieldPointSample Sample = NativeSurfaceField.point(
|
|
static_cast<int64>(std::llround(XVoxel * SubvoxelsPerVoxel)),
|
|
static_cast<int64>(std::llround(YVoxel * SubvoxelsPerVoxel)), 0);
|
|
if (Sample.biomeId != 0) NativeBiomeIds.Add(Sample.biomeId);
|
|
if (Sample.neighborBiomeId != 0 && Sample.biomeWeightQ16 > 0)
|
|
NativeBiomeIds.Add(Sample.neighborBiomeId);
|
|
++NativeBiomeSampleCount;
|
|
}
|
|
if (NativeBiomeIds.Num() < 2)
|
|
{
|
|
OutError = TEXT("Native SurfaceWorld heightfield did not assign at least two biomes across its review domain.");
|
|
return false;
|
|
}
|
|
NativeSurfaceBiomeId = SurfaceBiomeSample.biomeId;
|
|
NativeBiomeIdsSampled = NativeBiomeIds.Num();
|
|
bNativeBiomeFieldValidated = true;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] native_biome_field=1 surface_biome_id=%u sampled_biomes=%d samples=%d span_voxels=%.0f"),
|
|
NativeSurfaceBiomeId, NativeBiomeIdsSampled, NativeBiomeSampleCount,
|
|
BiomeProbeSpanVoxels);
|
|
|
|
VoxelForgeCore::World ReferenceWorld(std::move(*Snapshot));
|
|
// Match the actor's cold-start fallback camera when the transient world has no pawn.
|
|
// Aligner la reference sur la camera de repli de l'acteur sans pawn au demarrage.
|
|
const VoxelForgeCore::WorldConfiguration& ReferenceConfiguration =
|
|
ReferenceWorld.configuration();
|
|
const VoxelForgeCore::Vec3d InitialCameraVoxels = ReferenceConfiguration.viewerStartFound
|
|
? ReferenceConfiguration.viewerStartVoxels : VoxelForgeCore::Vec3d{};
|
|
const std::vector<VoxelForgeCore::TileKey> Desired =
|
|
ReferenceWorld.desiredTiles(InitialCameraVoxels);
|
|
if (Desired.empty())
|
|
{
|
|
OutError = TEXT("Standalone Core returned an empty reference clipmap at the cold-start camera.");
|
|
return false;
|
|
}
|
|
ExpectedInitialTileCount = Desired.size();
|
|
std::int32_t FineMin[3] = {MAX_int32, MAX_int32, MAX_int32};
|
|
std::int32_t FineMax[3] = {MIN_int32, MIN_int32, MIN_int32};
|
|
std::int32_t ClipMin[3] = {MAX_int32, MAX_int32, MAX_int32};
|
|
std::int32_t ClipMax[3] = {MIN_int32, MIN_int32, MIN_int32};
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::int32_t Extent = 32 << Key.lod;
|
|
const std::int32_t Origin[3] = {
|
|
Key.coordinate.x * Extent, Key.coordinate.y * Extent, Key.coordinate.z * Extent};
|
|
for (int32 Axis = 0; Axis < 3; ++Axis)
|
|
{
|
|
ClipMin[Axis] = FMath::Min(ClipMin[Axis], Origin[Axis]);
|
|
ClipMax[Axis] = FMath::Max(ClipMax[Axis], Origin[Axis] + Extent);
|
|
if (Key.lod < ReferenceWorld.configuration().fullResolutionLodCount)
|
|
{
|
|
FineMin[Axis] = FMath::Min(FineMin[Axis], Origin[Axis]);
|
|
FineMax[Axis] = FMath::Max(FineMax[Axis], Origin[Axis] + Extent);
|
|
}
|
|
}
|
|
}
|
|
if (FineMin[0] == MAX_int32 || ClipMin[0] == MAX_int32)
|
|
{
|
|
OutError = TEXT("Standalone Core reference clipmap bounds are invalid.");
|
|
return false;
|
|
}
|
|
|
|
VoxelForgeCore::ShapeBuildRequest Request;
|
|
Request.minVoxel = {FineMin[0], FineMin[1], FineMin[2]};
|
|
Request.maxVoxel = {FineMax[0], FineMax[1], FineMax[2]};
|
|
Request.clipMinVoxel = {ClipMin[0], ClipMin[1], ClipMin[2]};
|
|
Request.clipMaxVoxel = {ClipMax[0], ClipMax[1], ClipMax[2]};
|
|
Request.workerLimit = 8;
|
|
Request.detail = VoxelForgeCore::DetailVariant::coherent;
|
|
Request.includeFarWorld = true;
|
|
MatchedTimingInitialDesired = Desired;
|
|
MatchedTimingInitialRequest = Request;
|
|
FStandaloneCoreAdapterScheduler Scheduler;
|
|
if (!ReferenceWorld.compile(Request, Scheduler, CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core reference compile failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
// Match the runtime collision probe around the same cold-start viewer tile.
|
|
// Reproduire la sonde de collision autour de la meme tuile de depart a froid.
|
|
ColdStartCollisionCenter = FIntVector(
|
|
FMath::FloorToInt(InitialCameraVoxels.x / 32.0),
|
|
FMath::FloorToInt(InitialCameraVoxels.y / 32.0),
|
|
FMath::FloorToInt(InitialCameraVoxels.z / 32.0));
|
|
std::vector<VoxelForgeCore::TileKey> ColdStartCollisionTiles;
|
|
ColdStartCollisionTiles.reserve(static_cast<std::size_t>(2 * CollisionRadius + 1)
|
|
* static_cast<std::size_t>(2 * CollisionRadius + 1)
|
|
* static_cast<std::size_t>(2 * CollisionRadius + 1));
|
|
for (int32 Z = -CollisionRadius; Z <= CollisionRadius; ++Z)
|
|
for (int32 Y = -CollisionRadius; Y <= CollisionRadius; ++Y)
|
|
for (int32 X = -CollisionRadius; X <= CollisionRadius; ++X)
|
|
ColdStartCollisionTiles.push_back({
|
|
{ColdStartCollisionCenter.X + X,
|
|
ColdStartCollisionCenter.Y + Y,
|
|
ColdStartCollisionCenter.Z + Z}, 0});
|
|
|
|
if (Arguments.bCoreHybridCliffOnly)
|
|
{
|
|
// This is the sampled M1 type-4 cliff tile used by the round-2 standalone identity check.
|
|
// C'est la tuile de falaise native type 4 utilisee par le controle d'identite hors ligne.
|
|
const VoxelForgeCore::TileKey CliffKey{{-22, 13, -58}, 0};
|
|
const std::vector<VoxelForgeCore::TileKey> CliffTiles{CliffKey};
|
|
ReferenceWorld.retainTileRegions(CliffTiles);
|
|
if (!ReferenceWorld.compileTileRegion(CliffTiles, 16, Scheduler, CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Native SurfaceWorld cliff region compile failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
VoxelForgeCore::TileGenerationOptions McOptions;
|
|
McOptions.hashDensity = true;
|
|
McOptions.mesherRecipe = VoxelForgeCore::MesherRecipe::marchingCubesV1;
|
|
VoxelForgeCore::TileGenerationOptions HybridOptions = McOptions;
|
|
HybridOptions.mesherRecipe = Arguments.bCoreHybridMesherV2
|
|
? VoxelForgeCore::MesherRecipe::worldAssemblerHybridV2
|
|
: VoxelForgeCore::MesherRecipe::worldAssemblerHybridV1;
|
|
const auto McMesh = ReferenceWorld.generateTile(CliffKey, McOptions, CoreError);
|
|
const auto HybridMesh = ReferenceWorld.generateTile(CliffKey, HybridOptions, CoreError);
|
|
if (!McMesh || !HybridMesh || !McMesh->hasMeshHash || !HybridMesh->hasMeshHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Native SurfaceWorld cliff tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
bool bBitIdentical = McMesh->meshHash == HybridMesh->meshHash
|
|
&& McMesh->payloadHash == HybridMesh->payloadHash
|
|
&& McMesh->topologyHash == HybridMesh->topologyHash
|
|
&& McMesh->vertices.size() == HybridMesh->vertices.size()
|
|
&& McMesh->indices == HybridMesh->indices;
|
|
for (std::size_t I = 0; bBitIdentical && I < McMesh->vertices.size(); ++I)
|
|
{
|
|
const VoxelForgeCore::MeshVertex& A = McMesh->vertices[I];
|
|
const VoxelForgeCore::MeshVertex& B = HybridMesh->vertices[I];
|
|
bBitIdentical = A.position.x == B.position.x && A.position.y == B.position.y
|
|
&& A.position.z == B.position.z && A.normal.x == B.normal.x
|
|
&& A.normal.y == B.normal.y && A.normal.z == B.normal.z
|
|
&& A.uv.x == B.uv.x && A.uv.y == B.uv.y
|
|
&& A.color.r == B.color.r && A.color.g == B.color.g
|
|
&& A.color.b == B.color.b && A.color.a == B.color.a;
|
|
}
|
|
if (!bBitIdentical)
|
|
{
|
|
OutError = TEXT("The production hybrid changed the sampled native SurfaceWorld cliff tile.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerHybridNativeCliff] key=(-22,13,-58) lod=0 vertices=%llu triangles=%llu mesh_hash=0x%016llx bit_identical=1 type4_only=1"),
|
|
static_cast<unsigned long long>(McMesh->vertices.size()),
|
|
static_cast<unsigned long long>(McMesh->indices.size() / 3u),
|
|
static_cast<unsigned long long>(McMesh->meshHash));
|
|
return true;
|
|
}
|
|
|
|
if (!RunCoreSharedFeatureCostAudit(ReferenceWorld,Desired,TEXT("surface"),
|
|
Assembly,OutError)) return false;
|
|
if (FParse::Param(FCommandLine::Get(),TEXT("corefeatureauditonly")))
|
|
{
|
|
UE_LOG(LogTemp,Display,TEXT("[VoxelForgeSharedFeatureCost] pass complete=1 warmed_routes=1"));
|
|
return true;
|
|
}
|
|
VoxelForgeCore::TileGenerationOptions Options;
|
|
Options.hashDensity = true;
|
|
if (Arguments.CoreHybridMesherVersion == 2)
|
|
Options.mesherRecipe = VoxelForgeCore::MesherRecipe::worldAssemblerHybridV2;
|
|
else if (Arguments.CoreHybridMesherVersion == 1)
|
|
Options.mesherRecipe = VoxelForgeCore::MesherRecipe::worldAssemblerHybridV1;
|
|
else
|
|
Options.mesherRecipe = VoxelForgeCore::MesherRecipe::marchingCubesV1;
|
|
|
|
// Keep collision hashes outside ExpectedInitialDesiredSet; the collision cube is an
|
|
// additional runtime request even where it overlaps the visual clipmap.
|
|
// Garder les hachages de collision hors de l'ensemble visuel initial, meme s'ils se
|
|
// recoupent avec le clipmap.
|
|
std::vector<VoxelForgeCore::TileKey> RetainedReferenceTiles = Desired;
|
|
RetainedReferenceTiles.insert(RetainedReferenceTiles.end(),
|
|
ColdStartCollisionTiles.begin(), ColdStartCollisionTiles.end());
|
|
ReferenceWorld.retainTileRegions(RetainedReferenceTiles);
|
|
if (!ReferenceWorld.compileTileRegion(ColdStartCollisionTiles, 16, Scheduler, CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone collision reference compile failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
std::size_t CollisionBodyTiles = 0;
|
|
for (const VoxelForgeCore::TileKey& Key : ColdStartCollisionTiles)
|
|
{
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, Options, CoreError);
|
|
if (!Mesh)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone collision tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
FVoxelForgeCoreTileHashRecord Record;
|
|
Record.bEmpty = Mesh->indices.empty();
|
|
Record.bHasHashes = Mesh->hasDensityHash && Mesh->hasMeshHash
|
|
&& Mesh->hasPayloadHash && Mesh->hasTopologyHash;
|
|
if (!Record.bEmpty) ++CollisionBodyTiles;
|
|
ExpectedHashes.Add(FVoxelTileKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod), Record);
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] standalone_collision_reference=1 center=(%d,%d,%d) tiles=%d body=%llu no_body=%llu"),
|
|
ColdStartCollisionCenter.X, ColdStartCollisionCenter.Y, ColdStartCollisionCenter.Z,
|
|
static_cast<int32>(ColdStartCollisionTiles.size()),
|
|
static_cast<unsigned long long>(CollisionBodyTiles),
|
|
static_cast<unsigned long long>(ColdStartCollisionTiles.size() - CollisionBodyTiles));
|
|
|
|
std::vector<FStandaloneCoreAdapterTileHashes> AggregateRows;
|
|
AggregateRows.reserve(Desired.size());
|
|
bool bFoundCarveVertex = false;
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, Options, CoreError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core hash generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
FVoxelForgeCoreTileHashRecord Record;
|
|
Record.Density = Mesh->densityHash;
|
|
Record.Mesh = Mesh->meshHash;
|
|
Record.Payload = Mesh->payloadHash;
|
|
Record.Topology = Mesh->topologyHash;
|
|
Record.Triangles = static_cast<int32>(Mesh->indices.size() / 3);
|
|
Record.bEmpty = Mesh->indices.empty();
|
|
Record.bHasHashes = true;
|
|
ExpectedHashes.Add(RuntimeKey, Record);
|
|
ExpectedInitialDesiredSet.Add(RuntimeKey);
|
|
AggregateRows.push_back({Key, {Record.Density, Record.Mesh, Record.Payload, Record.Topology}});
|
|
if (!bFoundCarveVertex && Key.lod == 0 && !Mesh->vertices.empty()
|
|
&& !Mesh->indices.empty())
|
|
{
|
|
const VoxelForgeCore::Vec3d Vertex = Mesh->vertices.front().position;
|
|
CarveLocalPositionCm = FVector(Vertex.x, Vertex.y, Vertex.z);
|
|
CarveTargetTile = RuntimeKey;
|
|
bFoundCarveVertex = true;
|
|
}
|
|
}
|
|
InitialAggregates = StandaloneCoreAggregateHashes(AggregateRows);
|
|
|
|
// Rebuild and hash the same full clipmap at W1 and W16; the main reference is W8.
|
|
// Recompiler et hacher le même clipmap à W1 et W16 ; la référence principale est W8.
|
|
auto BuildWorkerHash = [&](uint32 WorkerLimit,
|
|
std::array<std::uint64_t, 4>& OutHashes) -> bool
|
|
{
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> WorkerSnapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(Utf8Json.Get(), static_cast<std::size_t>(Utf8Json.Length())),
|
|
CoreError);
|
|
if (!WorkerSnapshot)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core worker-hash snapshot failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
VoxelForgeCore::World WorkerWorld(std::move(*WorkerSnapshot));
|
|
VoxelForgeCore::ShapeBuildRequest WorkerRequest = Request;
|
|
WorkerRequest.workerLimit = WorkerLimit;
|
|
FStandaloneCoreAdapterScheduler WorkerScheduler;
|
|
if (!WorkerWorld.compile(WorkerRequest, WorkerScheduler, CoreError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Core W%u compile failed: %s"), WorkerLimit,
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
WorkerWorld.retainTileRegions(Desired);
|
|
std::vector<FStandaloneCoreAdapterTileHashes> WorkerRows;
|
|
WorkerRows.reserve(Desired.size());
|
|
for (const VoxelForgeCore::TileKey& Key : Desired)
|
|
{
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
WorkerWorld.generateTile(Key, Options, CoreError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Core W%u tile hash failed: %s"), WorkerLimit,
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
WorkerRows.push_back({Key,
|
|
{Mesh->densityHash, Mesh->meshHash, Mesh->payloadHash, Mesh->topologyHash}});
|
|
}
|
|
OutHashes = StandaloneCoreAggregateHashes(WorkerRows);
|
|
return true;
|
|
};
|
|
std::array<std::uint64_t, 4> Worker1Aggregates{};
|
|
std::array<std::uint64_t, 4> Worker16Aggregates{};
|
|
if (!BuildWorkerHash(1, Worker1Aggregates)
|
|
|| !BuildWorkerHash(16, Worker16Aggregates))
|
|
{
|
|
return false;
|
|
}
|
|
if (Worker1Aggregates != InitialAggregates || Worker16Aggregates != InitialAggregates)
|
|
{
|
|
OutError = TEXT("Core full-clipmap hashes differ across worker limits 1, 8, and 16.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] worker_hash_matrix=1/8/16 stable=1 density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
static_cast<unsigned long long>(InitialAggregates[0]),
|
|
static_cast<unsigned long long>(InitialAggregates[1]),
|
|
static_cast<unsigned long long>(InitialAggregates[2]),
|
|
static_cast<unsigned long long>(InitialAggregates[3]));
|
|
|
|
if (!Manager->GetLayout().IsEmpty() && Manager->GetLayout()[0].Definition != nullptr
|
|
&& Manager->GetGeneratorTypeForChunk(FIntVector(
|
|
0, 0, Manager->GetLayout()[0].TopChunkZ)) != ECaveGeneratorType::SurfaceWorld)
|
|
{
|
|
const FStrateSlot& TopSlot = Manager->GetLayout()[0];
|
|
const FIntVector MidChunk(0, 0, (TopSlot.TopChunkZ + TopSlot.BottomChunkZ) / 2);
|
|
const FStrateGenerationParams TopParams = Manager->GetGenerationParams(MidChunk);
|
|
const float TopZ = (static_cast<float>(TopSlot.TopChunkZ) + 1.0f) * CHUNK_SIZE;
|
|
const float BottomZ = static_cast<float>(TopSlot.BottomChunkZ) * CHUNK_SIZE;
|
|
const UVoxelSeasonAsset* EffectiveSeason = AuthoredSettings->Season.LoadSynchronous();
|
|
const float OriginRadius = EffectiveSeason != nullptr
|
|
? EffectiveSeason->GetOriginSpineRadius() : AuthoredSettings->OriginSpineRadius;
|
|
const VoxelPassageGeometry::FOriginLandingGeometry Geometry =
|
|
VoxelPassageGeometry::BuildOriginLandingGeometry(
|
|
TopZ, BottomZ, TopParams.BoundarySealThickness,
|
|
OriginRadius);
|
|
if (Geometry.bValid)
|
|
{
|
|
const double RoomZ = 0.5 * (Geometry.FloorZ + Geometry.CeilingZ);
|
|
const float RoomDensity = ReferenceWorld.densityAt({0.0, 0.0, RoomZ});
|
|
const float FloorCenterBelow = ReferenceWorld.densityAt(
|
|
{0.0, 0.0, Geometry.FloorZ - 8.0});
|
|
const float FloorSideBelow = ReferenceWorld.densityAt(
|
|
{Geometry.HalfWidth * 0.5, 0.0, Geometry.FloorZ - 8.0});
|
|
const float FloorCenterAbove = ReferenceWorld.densityAt(
|
|
{0.0, 0.0, Geometry.FloorZ + 8.0});
|
|
const float FloorSideAbove = ReferenceWorld.densityAt(
|
|
{Geometry.HalfWidth * 0.5, 0.0, Geometry.FloorZ + 8.0});
|
|
const float ShaftContinuationDensity = ReferenceWorld.densityAt(
|
|
{0.0, 0.0, Geometry.FloorZ - 32.0});
|
|
if (!(RoomDensity > 0.0f) || !(FloorCenterBelow < 0.0f)
|
|
|| !(FloorSideBelow < 0.0f) || !(FloorCenterAbove > 0.0f)
|
|
|| !(FloorSideAbove > 0.0f) || !(ShaftContinuationDensity < 0.0f))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core origin landing geometry failed: room=%.3f floor_below=(%.3f,%.3f) floor_above=(%.3f,%.3f) shaft_below=%.3f."),
|
|
RoomDensity, FloorCenterBelow, FloorSideBelow,
|
|
FloorCenterAbove, FloorSideAbove, ShaftContinuationDensity);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] origin_landing=PASS floor=%.3f room=%.3f floor_below=(%.3f,%.3f) floor_above=(%.3f,%.3f) shaft_below=%.3f"),
|
|
Geometry.FloorZ, RoomDensity, FloorCenterBelow, FloorSideBelow,
|
|
FloorCenterAbove, FloorSideAbove, ShaftContinuationDensity);
|
|
}
|
|
else
|
|
{
|
|
OutError = TEXT("Core origin landing test geometry is invalid for the top strate.");
|
|
return false;
|
|
}
|
|
}
|
|
else if (!Manager->GetLayout().IsEmpty()
|
|
&& Manager->GetLayout()[0].Definition != nullptr
|
|
&& Manager->GetGeneratorTypeForChunk(FIntVector(
|
|
0, 0, Manager->GetLayout()[0].TopChunkZ)) == ECaveGeneratorType::SurfaceWorld)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] origin_landing=SKIP reason=surface_world_uses_native_walkable_route"));
|
|
}
|
|
if (!bFoundCarveVertex)
|
|
{
|
|
OutError = TEXT("Standalone Core reference has no level-0 surface vertex for the carve fixture.");
|
|
return false;
|
|
}
|
|
|
|
VoxelForgeCore::EditTransaction Transaction;
|
|
Transaction.sequence = 1;
|
|
VoxelForgeCore::EditBrush Brush;
|
|
Brush.shape = VoxelForgeCore::EditBrush::Shape::sphere;
|
|
Brush.operation = VoxelForgeCore::EditBrush::Operation::densityOffset;
|
|
Brush.a = {CarveLocalPositionCm.X / CoreVoxelSizeCm,
|
|
CarveLocalPositionCm.Y / CoreVoxelSizeCm,
|
|
CarveLocalPositionCm.Z / CoreVoxelSizeCm};
|
|
Brush.halfExtent = {5.0, 5.0, 5.0};
|
|
Brush.radius = CarveBrushRadiusCoreVoxels;
|
|
Brush.falloff = 2.0f;
|
|
const double ExpectedCoreBrushStrength = 10.0 * VOXEL_SIZE / CoreVoxelSizeCm;
|
|
Brush.strength = static_cast<float>(ExpectedCoreBrushStrength);
|
|
if (!FMath::IsNearlyEqual(CoreVoxelSizeCm, 25.0, 0.000001)
|
|
&& FMath::IsNearlyEqual(ExpectedCoreBrushStrength, 5.0, 0.000001))
|
|
{
|
|
OutError = TEXT("Non-default Core voxel size reused the fixed 25 cm carve strength.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] carve_scale_control=PASS legacy_fixed_strength=5.000 dynamic_strength=%.3f stale_default_control_detected=%d"),
|
|
ExpectedCoreBrushStrength,
|
|
FMath::IsNearlyEqual(ExpectedCoreBrushStrength, 5.0, 0.000001) ? 0 : 1);
|
|
Transaction.brushes.push_back(Brush);
|
|
const std::optional<VoxelForgeCore::EditResult> Edit =
|
|
ReferenceWorld.applyEditTransaction(Transaction, Desired, CoreError);
|
|
if (!Edit)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core reference edit failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
ExpectedEditDigest = Edit->digest;
|
|
for (const std::vector<VoxelForgeCore::TileKey>& ByLod : Edit->affectedByLod)
|
|
{
|
|
for (const VoxelForgeCore::TileKey& Key : ByLod)
|
|
{
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
ExpectedAffectedTiles.Add(RuntimeKey);
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, Options, CoreError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core edited tile hash failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
FVoxelForgeCoreTileHashRecord Record;
|
|
Record.Density = Mesh->densityHash;
|
|
Record.Mesh = Mesh->meshHash;
|
|
Record.Payload = Mesh->payloadHash;
|
|
Record.Topology = Mesh->topologyHash;
|
|
Record.Triangles = static_cast<int32>(Mesh->indices.size() / 3);
|
|
Record.bEmpty = Mesh->indices.empty();
|
|
Record.bHasHashes = true;
|
|
ExpectedPostEditHashes.Add(RuntimeKey, Record);
|
|
}
|
|
}
|
|
ExpectedAffectedTiles.Sort([](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
|
{
|
|
if (A.Level != B.Level) return A.Level < B.Level;
|
|
if (A.Coord.X != B.Coord.X) return A.Coord.X < B.Coord.X;
|
|
if (A.Coord.Y != B.Coord.Y) return A.Coord.Y < B.Coord.Y;
|
|
return A.Coord.Z < B.Coord.Z;
|
|
});
|
|
if (!ExpectedPostEditHashes.Contains(CarveTargetTile)
|
|
|| ExpectedAffectedTiles.IsEmpty())
|
|
{
|
|
OutError = TEXT("Standalone Core edit fixture did not affect its carved level-0 tile.");
|
|
return false;
|
|
}
|
|
|
|
// The authored lower landing is in the second strate (the biome/surface strate). Compile
|
|
// its fine clipmap pages from the same Core reference before moving AVoxelWorld there.
|
|
// Le palier inférieur appartient à la strate surface/biomes; préparer la référence avant le déplacement.
|
|
const std::vector<VoxelForgeCore::TileKey> TravelDesired =
|
|
ReferenceWorld.desiredTiles(TravelCameraVoxels);
|
|
if (TravelDesired.empty())
|
|
{
|
|
OutError = TEXT("Standalone Core returned no desired tiles at the authored lower landing.");
|
|
return false;
|
|
}
|
|
FString CoverageError;
|
|
if (!AuditCoreClipmapCoverage(TravelDesired, TravelCameraVoxels, 128,
|
|
TravelCoverageLocalTiles, TravelCoverageFaces, TravelLodTransitionFaces,
|
|
CoverageError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core lower-landing coverage failed: %s"),
|
|
*CoverageError);
|
|
return false;
|
|
}
|
|
|
|
std::vector<VoxelForgeCore::TileKey> MissingFineTiles;
|
|
MissingFineTiles.reserve(TravelDesired.size());
|
|
for (const VoxelForgeCore::TileKey& Key : TravelDesired)
|
|
{
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
ExpectedTravelDesiredSet.Add(RuntimeKey);
|
|
if (Key.lod < ReferenceWorld.configuration().fullResolutionLodCount)
|
|
{
|
|
MissingFineTiles.push_back(Key);
|
|
}
|
|
}
|
|
if (!ReferenceWorld.compileTileRegion(MissingFineTiles, 16, Scheduler, CoreError,
|
|
&TravelFineTilesCompiled, &TravelCompileStats))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core lower-landing region compile failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
ReferenceWorld.retainTileRegions(TravelDesired);
|
|
TravelRegionsBuilt = TravelCompileStats.regionsBuilt;
|
|
|
|
VoxelForgeCore::TileGenerationOptions TravelOptions;
|
|
TravelOptions.hashDensity = true;
|
|
TravelOptions.mesherRecipe = Options.mesherRecipe;
|
|
std::vector<FStandaloneCoreAdapterTileHashes> TravelRows;
|
|
TravelRows.reserve(TravelDesired.size());
|
|
for (const VoxelForgeCore::TileKey& Key : TravelDesired)
|
|
{
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, TravelOptions, CoreError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core lower-landing tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
FVoxelForgeCoreTileHashRecord Record;
|
|
Record.Density = Mesh->densityHash;
|
|
Record.Mesh = Mesh->meshHash;
|
|
Record.Payload = Mesh->payloadHash;
|
|
Record.Topology = Mesh->topologyHash;
|
|
Record.Triangles = static_cast<int32>(Mesh->indices.size() / 3);
|
|
Record.bEmpty = Mesh->indices.empty();
|
|
Record.bHasHashes = true;
|
|
ExpectedTravelHashes.Add(RuntimeKey, Record);
|
|
TravelRows.push_back({Key, {Record.Density, Record.Mesh, Record.Payload, Record.Topology}});
|
|
if (Mesh->groups.skirtTriangleCount > 0) ++TravelTilesWithSkirts;
|
|
}
|
|
TravelAggregates = StandaloneCoreAggregateHashes(TravelRows);
|
|
ExpectedTravelTileCount = TravelDesired.size();
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] standalone_travel_reference=1 location=(%.3f,%.3f,%.3f) tiles=%llu fine_compiled=%llu regions=%llu local_tiles=%d checked_faces=%d lod_transition_faces=%d skirt_tiles=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
TravelCameraVoxels.x, TravelCameraVoxels.y, TravelCameraVoxels.z,
|
|
static_cast<unsigned long long>(ExpectedTravelTileCount),
|
|
static_cast<unsigned long long>(TravelFineTilesCompiled),
|
|
static_cast<unsigned long long>(TravelRegionsBuilt),
|
|
TravelCoverageLocalTiles, TravelCoverageFaces, TravelLodTransitionFaces,
|
|
TravelTilesWithSkirts,
|
|
static_cast<unsigned long long>(TravelAggregates[0]),
|
|
static_cast<unsigned long long>(TravelAggregates[1]),
|
|
static_cast<unsigned long long>(TravelAggregates[2]),
|
|
static_cast<unsigned long long>(TravelAggregates[3]));
|
|
|
|
// Compile a second destination deeper in the biome strate, matching the owner's surface screenshot.
|
|
// Compiler une seconde destination dans la strate biome, à l'emplacement de la capture surface.
|
|
const std::vector<VoxelForgeCore::TileKey> SurfaceTravelDesired =
|
|
ReferenceWorld.desiredTiles(SurfaceCameraVoxels);
|
|
if (SurfaceTravelDesired.empty())
|
|
{
|
|
OutError = TEXT("Standalone Core returned no desired tiles at the surface-strate fixture.");
|
|
return false;
|
|
}
|
|
if (!AuditCoreClipmapCoverage(SurfaceTravelDesired, SurfaceCameraVoxels, 128,
|
|
SurfaceTravelCoverageLocalTiles, SurfaceTravelCoverageFaces,
|
|
SurfaceTravelLodTransitionFaces, CoverageError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core surface-strate coverage failed: %s"),
|
|
*CoverageError);
|
|
return false;
|
|
}
|
|
std::vector<VoxelForgeCore::TileKey> MissingSurfaceFineTiles;
|
|
MissingSurfaceFineTiles.reserve(SurfaceTravelDesired.size());
|
|
MatchedTimingSurfaceDesired = SurfaceTravelDesired;
|
|
for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired)
|
|
{
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
ExpectedSurfaceTravelDesiredSet.Add(RuntimeKey);
|
|
if (Key.lod < ReferenceWorld.configuration().fullResolutionLodCount)
|
|
MissingSurfaceFineTiles.push_back(Key);
|
|
}
|
|
MatchedTimingSurfaceMissingFine = MissingSurfaceFineTiles;
|
|
// Keep the retained reference clipmap bounded while compiling the second remote region.
|
|
// Garder la référence bornée avant de compiler la seconde région distante.
|
|
const double NativeSurfaceRegionCompileStart = FPlatformTime::Seconds();
|
|
ReferenceWorld.retainTileRegions(SurfaceTravelDesired);
|
|
if (!ReferenceWorld.compileTileRegion(MissingSurfaceFineTiles, 16, Scheduler, CoreError,
|
|
&SurfaceTravelFineTilesCompiled, &SurfaceTravelCompileStats))
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core surface-strate region compile failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
NativeSurfaceRegionCompileSeconds = FPlatformTime::Seconds()
|
|
- NativeSurfaceRegionCompileStart;
|
|
SurfaceTravelRegionsBuilt = SurfaceTravelCompileStats.regionsBuilt;
|
|
std::vector<FStandaloneCoreAdapterTileHashes> SurfaceTravelRows;
|
|
SurfaceTravelRows.reserve(SurfaceTravelDesired.size());
|
|
FAssemblerGeometryAudit GeometryAuditL0;
|
|
FAssemblerGeometryAudit GeometryAuditL1;
|
|
FAssemblerGeometryAudit GeometryAuditHorizon;
|
|
int32 GeometryAuditL0Distance = MAX_int32;
|
|
int32 GeometryAuditL1Distance = MAX_int32;
|
|
int32 GeometryAuditHorizonDistance = MAX_int32;
|
|
FAssemblerGeometryAudit BestSpanGeometryAuditL0;
|
|
double BestSpanRatioL0 = -std::numeric_limits<double>::infinity();
|
|
int32 GeometryAuditL0CandidateCount = 0;
|
|
int32 HorizonAuditLOD = 0;
|
|
for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired)
|
|
{
|
|
HorizonAuditLOD = FMath::Max(HorizonAuditLOD, static_cast<int32>(Key.lod));
|
|
}
|
|
for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired)
|
|
{
|
|
const double TileGenerationStart = FPlatformTime::Seconds();
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, TravelOptions, CoreError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone Core surface-strate tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
if (Arguments.bCoreHybridFixturePlacement && Key.lod == 0u
|
|
&& Key.coordinate.x == 1 && Key.coordinate.y == 5)
|
|
{
|
|
ExpectedHybridFixtureVisualMesh = *Mesh;
|
|
}
|
|
// Keep the terrain geometry audit on the sampled native field, not the opt-in box-fixture tile.
|
|
// Garder l'audit de geometrie sur le champ natif echantillonne, hors de la tuile des volumes de test.
|
|
const bool bInjectedHybridFixtureTile = Arguments.bCoreHybridFixturePlacement
|
|
&& Key.lod == 0u && Key.coordinate.x == 1 && Key.coordinate.y == 5;
|
|
if (!bInjectedHybridFixtureTile
|
|
&& (Key.lod <= 1u || static_cast<int32>(Key.lod) == HorizonAuditLOD)
|
|
&& Mesh->groups.groundTriangleCount > 0u)
|
|
{
|
|
const int32 LOD = static_cast<int32>(Key.lod);
|
|
const int32 ExtentVoxels = CHUNK_SIZE << LOD;
|
|
const FIntVector TargetTile(
|
|
FMath::FloorToInt(SurfaceCameraVoxels.x / ExtentVoxels),
|
|
FMath::FloorToInt(SurfaceCameraVoxels.y / ExtentVoxels),
|
|
FMath::FloorToInt(SurfaceCameraVoxels.z / ExtentVoxels));
|
|
const FIntVector TileCoord(
|
|
static_cast<int32>(Key.coordinate.x), static_cast<int32>(Key.coordinate.y),
|
|
static_cast<int32>(Key.coordinate.z));
|
|
const int32 Distance = FMath::Abs(TileCoord.X - TargetTile.X)
|
|
+ FMath::Abs(TileCoord.Y - TargetTile.Y) + FMath::Abs(TileCoord.Z - TargetTile.Z);
|
|
FAssemblerGeometryAudit Candidate;
|
|
FString GeometryError;
|
|
if (!AuditAssemblerCoreTile(ReferenceWorld, *Mesh, Candidate, GeometryError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Native surface mesh geometry audit failed: %s"),
|
|
*GeometryError);
|
|
return false;
|
|
}
|
|
const double SpanRatioX = Candidate.SpanXcm / Candidate.ExpectedTileSpanCm;
|
|
const double SpanRatioY = Candidate.SpanYcm / Candidate.ExpectedTileSpanCm;
|
|
if (LOD == 0)
|
|
{
|
|
++GeometryAuditL0CandidateCount;
|
|
const double MinimumSpanRatio = FMath::Min(SpanRatioX, SpanRatioY);
|
|
if (MinimumSpanRatio > BestSpanRatioL0)
|
|
{
|
|
BestSpanRatioL0 = MinimumSpanRatio;
|
|
BestSpanGeometryAuditL0 = Candidate;
|
|
}
|
|
}
|
|
if (SpanRatioX >= 0.95 && SpanRatioY >= 0.95
|
|
&& SpanRatioX <= 1.05 && SpanRatioY <= 1.05)
|
|
{
|
|
if (LOD == 0 && Distance < GeometryAuditL0Distance)
|
|
{
|
|
GeometryAuditL0 = Candidate;
|
|
GeometryAuditL0Distance = Distance;
|
|
}
|
|
else if (LOD == 1 && Distance < GeometryAuditL1Distance)
|
|
{
|
|
GeometryAuditL1 = Candidate;
|
|
GeometryAuditL1Distance = Distance;
|
|
}
|
|
if (LOD == HorizonAuditLOD && Distance < GeometryAuditHorizonDistance)
|
|
{
|
|
GeometryAuditHorizon = Candidate;
|
|
GeometryAuditHorizonDistance = Distance;
|
|
}
|
|
}
|
|
}
|
|
const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart;
|
|
NativeSurfaceTileGenerationSeconds += TileGenerationSeconds;
|
|
NativeSurfaceTriangleCount += Mesh->indices.size() / 3;
|
|
const FVoxelTileKey RuntimeKey(
|
|
FIntVector(Key.coordinate.x, Key.coordinate.y, Key.coordinate.z), Key.lod);
|
|
FVoxelForgeCoreTileHashRecord Record;
|
|
Record.Density = Mesh->densityHash;
|
|
Record.Mesh = Mesh->meshHash;
|
|
Record.Payload = Mesh->payloadHash;
|
|
Record.Topology = Mesh->topologyHash;
|
|
Record.Triangles = static_cast<int32>(Mesh->indices.size() / 3);
|
|
Record.bEmpty = Mesh->indices.empty();
|
|
Record.bHasHashes = true;
|
|
ExpectedSurfaceTravelHashes.Add(RuntimeKey, Record);
|
|
SurfaceTravelRows.push_back({Key,
|
|
{Record.Density, Record.Mesh, Record.Payload, Record.Topology}});
|
|
if (Mesh->groups.skirtTriangleCount > 0) ++SurfaceTravelTilesWithSkirts;
|
|
}
|
|
VoxelForgeCore::TileGenerationOptions NativeTimingOptions;
|
|
NativeTimingOptions.mesherRecipe = Options.mesherRecipe;
|
|
for (const VoxelForgeCore::TileKey& Key : SurfaceTravelDesired)
|
|
{
|
|
if (Key.lod > 1u)
|
|
{
|
|
continue;
|
|
}
|
|
const double TileGenerationStart = FPlatformTime::Seconds();
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
ReferenceWorld.generateTile(Key, NativeTimingOptions, CoreError);
|
|
if (!Mesh)
|
|
{
|
|
OutError = FString::Printf(TEXT("Native unhashed streaming sample failed: %s"),
|
|
UTF8_TO_TCHAR(CoreError.c_str()));
|
|
return false;
|
|
}
|
|
const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart;
|
|
NativeSurfaceTileSamples.push_back({
|
|
static_cast<int32>(Key.lod), ClassifyAssemblerTile(*Mesh), TileGenerationSeconds,
|
|
0.0, Mesh->diagnostics.classificationMilliseconds,
|
|
Mesh->diagnostics.columnComputeMilliseconds, Mesh->diagnostics.meshingMilliseconds,
|
|
Mesh->diagnostics.heightfieldBandLimited});
|
|
}
|
|
if (!GeometryAuditL0.bValid || !GeometryAuditL1.bValid || !GeometryAuditHorizon.bValid)
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeGeometryAudit] best_lod0_candidate count=%d min_span_ratio=%.6f tile=(%d,%d,%d) span_cm=(%.3f,%.3f) expected_cm=%.3f"),
|
|
GeometryAuditL0CandidateCount, BestSpanRatioL0,
|
|
BestSpanGeometryAuditL0.Tile.X, BestSpanGeometryAuditL0.Tile.Y,
|
|
BestSpanGeometryAuditL0.Tile.Z, BestSpanGeometryAuditL0.SpanXcm,
|
|
BestSpanGeometryAuditL0.SpanYcm, BestSpanGeometryAuditL0.ExpectedTileSpanCm);
|
|
OutError = FString::Printf(
|
|
TEXT("Native geometry audit missed full-span tiles (l0=%d l1=%d horizon_lod=%d horizon=%d)."),
|
|
GeometryAuditL0.bValid ? 1 : 0, GeometryAuditL1.bValid ? 1 : 0,
|
|
HorizonAuditLOD, GeometryAuditHorizon.bValid ? 1 : 0);
|
|
return false;
|
|
}
|
|
if (!FMath::IsNearlyEqual(Arguments.CoreVoxelSizeCm, 25.0f, 0.0001f))
|
|
{
|
|
const double StaleDefault25CmSpan = static_cast<double>(CHUNK_SIZE) * 25.0;
|
|
const bool bFixed25CmControlWouldFail =
|
|
!FMath::IsNearlyEqual(GeometryAuditL0.SpanXcm, StaleDefault25CmSpan, 0.1)
|
|
|| !FMath::IsNearlyEqual(GeometryAuditL0.SpanYcm, StaleDefault25CmSpan, 0.1);
|
|
if (!bFixed25CmControlWouldFail)
|
|
{
|
|
OutError = TEXT("The injected fixed-25-cm geometry negative control was not detected.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] geometry_scale_negative_control=DETECTED injected_fixed_voxel_cm=25 expected_tile_span_cm=%.1f actual_span_cm=(%.1f,%.1f)"),
|
|
StaleDefault25CmSpan, GeometryAuditL0.SpanXcm, GeometryAuditL0.SpanYcm);
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] geometry_scale_agreement=PASS legacy_default_cm=%.1f core_voxel_cm=%.1f "
|
|
"pre_fix_scale_ratio=%.4f lod0_expected_cm=%.1f span_cm=(%.1f,%.1f) ratio=(%.4f,%.4f) max_edge_cm=%.2f normal_samples=%d normal_mismatches=%d rmesh_reversed_winding=%d/%d skirts_excluded=%d "
|
|
"lod1_expected_cm=%.1f span_cm=(%.1f,%.1f) ratio=(%.4f,%.4f) max_edge_cm=%.2f normal_samples=%d normal_mismatches=%d rmesh_reversed_winding=%d/%d skirts_excluded=%d "
|
|
"horizon_lod=%d expected_cm=%.1f span_cm=(%.1f,%.1f) ratio=(%.4f,%.4f) max_edge_cm=%.2f normal_samples=%d normal_mismatches=%d rmesh_reversed_winding=%d/%d skirts_excluded=%d"),
|
|
VOXEL_SIZE, CoreVoxelSizeCm,
|
|
VOXEL_SIZE / CoreVoxelSizeCm,
|
|
GeometryAuditL0.ExpectedTileSpanCm, GeometryAuditL0.SpanXcm, GeometryAuditL0.SpanYcm,
|
|
GeometryAuditL0.SpanXcm / GeometryAuditL0.ExpectedTileSpanCm,
|
|
GeometryAuditL0.SpanYcm / GeometryAuditL0.ExpectedTileSpanCm,
|
|
GeometryAuditL0.MaxTriangleEdgeCm, GeometryAuditL0.NormalSamples,
|
|
GeometryAuditL0.NormalOrientationMismatches, GeometryAuditL0.TriangleWindingMismatches,
|
|
GeometryAuditL0.TriangleSamples, GeometryAuditL0.SkirtTrianglesExcluded,
|
|
GeometryAuditL1.ExpectedTileSpanCm, GeometryAuditL1.SpanXcm, GeometryAuditL1.SpanYcm,
|
|
GeometryAuditL1.SpanXcm / GeometryAuditL1.ExpectedTileSpanCm,
|
|
GeometryAuditL1.SpanYcm / GeometryAuditL1.ExpectedTileSpanCm,
|
|
GeometryAuditL1.MaxTriangleEdgeCm, GeometryAuditL1.NormalSamples,
|
|
GeometryAuditL1.NormalOrientationMismatches, GeometryAuditL1.TriangleWindingMismatches,
|
|
GeometryAuditL1.TriangleSamples, GeometryAuditL1.SkirtTrianglesExcluded,
|
|
HorizonAuditLOD, GeometryAuditHorizon.ExpectedTileSpanCm,
|
|
GeometryAuditHorizon.SpanXcm, GeometryAuditHorizon.SpanYcm,
|
|
GeometryAuditHorizon.SpanXcm / GeometryAuditHorizon.ExpectedTileSpanCm,
|
|
GeometryAuditHorizon.SpanYcm / GeometryAuditHorizon.ExpectedTileSpanCm,
|
|
GeometryAuditHorizon.MaxTriangleEdgeCm, GeometryAuditHorizon.NormalSamples,
|
|
GeometryAuditHorizon.NormalOrientationMismatches,
|
|
GeometryAuditHorizon.TriangleWindingMismatches, GeometryAuditHorizon.TriangleSamples,
|
|
GeometryAuditHorizon.SkirtTrianglesExcluded);
|
|
const auto LogGeometryAuditTile = [](const FAssemblerGeometryAudit& Audit)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeGeometryAudit] lod=%d tile=(%d,%d,%d) max_edge_cm=%.3f edge_cells=%.3f triangle=%llu endpoints_cm=((%.2f,%.2f,%.2f),(%.2f,%.2f,%.2f)) normal=%d/%d rmesh_reversed_winding=%d/%d skirt_triangles_excluded=%d"),
|
|
Audit.LOD, Audit.Tile.X, Audit.Tile.Y, Audit.Tile.Z,
|
|
Audit.MaxTriangleEdgeCm, Audit.MaxTriangleEdgeCm / FMath::Max(Audit.CellWorldCm, 1.0),
|
|
static_cast<unsigned long long>(Audit.MaxEdgeTriangleIndex),
|
|
Audit.MaxEdgeStart.x, Audit.MaxEdgeStart.y, Audit.MaxEdgeStart.z,
|
|
Audit.MaxEdgeEnd.x, Audit.MaxEdgeEnd.y, Audit.MaxEdgeEnd.z,
|
|
Audit.NormalOrientationMismatches, Audit.NormalSamples,
|
|
Audit.TriangleWindingMismatches, Audit.TriangleSamples,
|
|
Audit.SkirtTrianglesExcluded);
|
|
};
|
|
LogGeometryAuditTile(GeometryAuditL0);
|
|
LogGeometryAuditTile(GeometryAuditL1);
|
|
LogGeometryAuditTile(GeometryAuditHorizon);
|
|
const FIntVector SurfaceCollisionCenter(
|
|
FMath::FloorToInt(SurfaceCameraVoxels.x / CHUNK_SIZE),
|
|
FMath::FloorToInt(SurfaceCameraVoxels.y / CHUNK_SIZE),
|
|
FMath::FloorToInt(SurfaceCameraVoxels.z / CHUNK_SIZE));
|
|
for (int32 Z = -CollisionRadius; Z <= CollisionRadius; ++Z)
|
|
for (int32 Y = -CollisionRadius; Y <= CollisionRadius; ++Y)
|
|
for (int32 X = -CollisionRadius; X <= CollisionRadius; ++X)
|
|
{
|
|
const FVoxelTileKey Key(SurfaceCollisionCenter + FIntVector(X, Y, Z), 0);
|
|
const FVoxelForgeCoreTileHashRecord* Record = ExpectedSurfaceTravelHashes.Find(Key);
|
|
if (Record == nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Standalone native-surface reference omitted expected collision tile (%d,%d,%d)."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
ExpectedSurfaceCollisionSet.Add(Key);
|
|
if (Record->bEmpty) ++ExpectedSurfaceCollisionNoBodyCount;
|
|
else ++ExpectedSurfaceCollisionBodyCount;
|
|
}
|
|
SurfaceTravelAggregates = StandaloneCoreAggregateHashes(SurfaceTravelRows);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] standalone_surface_reference=1 location=(%.3f,%.3f,%.3f) tiles=%d fine_compiled=%llu regions=%llu local_tiles=%d checked_faces=%d lod_transition_faces=%d skirt_tiles=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
SurfaceCameraVoxels.x, SurfaceCameraVoxels.y, SurfaceCameraVoxels.z,
|
|
ExpectedSurfaceTravelDesiredSet.Num(),
|
|
static_cast<unsigned long long>(SurfaceTravelFineTilesCompiled),
|
|
static_cast<unsigned long long>(SurfaceTravelRegionsBuilt),
|
|
SurfaceTravelCoverageLocalTiles, SurfaceTravelCoverageFaces,
|
|
SurfaceTravelLodTransitionFaces, SurfaceTravelTilesWithSkirts,
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[0]),
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[1]),
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[2]),
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[3]));
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] standalone_reference=1 tiles=%llu affected=%d target=(%d,%d,%d) lod=%d"),
|
|
static_cast<unsigned long long>(ExpectedInitialTileCount),
|
|
ExpectedAffectedTiles.Num(), CarveTargetTile.Coord.X, CarveTargetTile.Coord.Y,
|
|
CarveTargetTile.Coord.Z, CarveTargetTile.Level);
|
|
}
|
|
|
|
// Compare native and compatibility bridge on the same remote surface keys in this process.
|
|
// Comparer le champ natif et le pont de compatibilite sur les memes cles de surface.
|
|
if (MatchedTimingInitialDesired.empty() || MatchedTimingSurfaceDesired.empty()
|
|
|| MatchedTimingSurfaceMissingFine.empty())
|
|
{
|
|
OutError = TEXT("Matched native/bridge timing did not retain its reference tile keys.");
|
|
return false;
|
|
}
|
|
FString BridgeJson = Json;
|
|
TSharedPtr<FJsonObject> BridgeRoot;
|
|
const TSharedRef<TJsonReader<>> BridgeReader = TJsonReaderFactory<>::Create(BridgeJson);
|
|
if (!FJsonSerializer::Deserialize(BridgeReader, BridgeRoot) || !BridgeRoot.IsValid())
|
|
{
|
|
OutError = TEXT("Matched legacy-bridge timing could not parse the Core snapshot JSON.");
|
|
return false;
|
|
}
|
|
BridgeRoot->RemoveField(TEXT("world_assembly"));
|
|
BridgeRoot->RemoveField(TEXT("world_assembly_content_hash"));
|
|
BridgeJson.Reset();
|
|
const TSharedRef<FExploreJsonWriter> BridgeWriter = TJsonWriterFactory<TCHAR,
|
|
TCondensedJsonPrintPolicy<TCHAR>>::Create(&BridgeJson);
|
|
if (!FJsonSerializer::Serialize(BridgeRoot.ToSharedRef(), BridgeWriter))
|
|
{
|
|
OutError = TEXT("Matched legacy-bridge timing could not serialize the bridge snapshot.");
|
|
return false;
|
|
}
|
|
BridgeWriter->Close();
|
|
|
|
FTCHARToUTF8 BridgeUtf8(*BridgeJson);
|
|
std::string BridgeError;
|
|
std::optional<VoxelForgeCore::ResolvedWorldSnapshot> BridgeSnapshot =
|
|
VoxelForgeCore::ResolvedWorldSnapshot::FromJson(
|
|
std::string_view(BridgeUtf8.Get(), static_cast<std::size_t>(BridgeUtf8.Length())),
|
|
BridgeError);
|
|
if (!BridgeSnapshot || BridgeSnapshot->worldAssembly() != nullptr)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Matched legacy-bridge snapshot failed validation or retained a native plan: %s"),
|
|
UTF8_TO_TCHAR(BridgeError.c_str()));
|
|
return false;
|
|
}
|
|
VoxelForgeCore::World BridgeWorld(std::move(*BridgeSnapshot));
|
|
FStandaloneCoreAdapterScheduler BridgeScheduler;
|
|
if (!BridgeWorld.compile(MatchedTimingInitialRequest, BridgeScheduler, BridgeError))
|
|
{
|
|
OutError = FString::Printf(TEXT("Matched legacy-bridge initial compile failed: %s"),
|
|
UTF8_TO_TCHAR(BridgeError.c_str()));
|
|
return false;
|
|
}
|
|
BridgeWorld.retainTileRegions(MatchedTimingInitialDesired);
|
|
const double BridgeRegionCompileStart = FPlatformTime::Seconds();
|
|
BridgeWorld.retainTileRegions(MatchedTimingSurfaceDesired);
|
|
VoxelForgeCore::TileRegionCompileStats BridgeRegionStats;
|
|
if (!BridgeWorld.compileTileRegion(MatchedTimingSurfaceMissingFine, 16, BridgeScheduler,
|
|
BridgeError, nullptr, &BridgeRegionStats))
|
|
{
|
|
OutError = FString::Printf(TEXT("Matched legacy-bridge surface compile failed: %s"),
|
|
UTF8_TO_TCHAR(BridgeError.c_str()));
|
|
return false;
|
|
}
|
|
const double BridgeSurfaceRegionCompileSeconds = FPlatformTime::Seconds()
|
|
- BridgeRegionCompileStart;
|
|
VoxelForgeCore::TileGenerationOptions BridgeTileOptions;
|
|
BridgeTileOptions.hashDensity = true;
|
|
double BridgeSurfaceTileGenerationSeconds = 0.0;
|
|
std::uint64_t BridgeSurfaceTriangleCount = 0;
|
|
std::size_t BridgeSurfaceTilesGenerated = 0;
|
|
for (const VoxelForgeCore::TileKey& Key : MatchedTimingSurfaceDesired)
|
|
{
|
|
const double TileGenerationStart = FPlatformTime::Seconds();
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
BridgeWorld.generateTile(Key, BridgeTileOptions, BridgeError);
|
|
if (!Mesh || !Mesh->hasDensityHash || !Mesh->hasMeshHash
|
|
|| !Mesh->hasPayloadHash || !Mesh->hasTopologyHash)
|
|
{
|
|
OutError = FString::Printf(TEXT("Matched legacy-bridge tile generation failed: %s"),
|
|
UTF8_TO_TCHAR(BridgeError.c_str()));
|
|
return false;
|
|
}
|
|
const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart;
|
|
BridgeSurfaceTileGenerationSeconds += TileGenerationSeconds;
|
|
BridgeSurfaceTriangleCount += Mesh->indices.size() / 3;
|
|
++BridgeSurfaceTilesGenerated;
|
|
}
|
|
if (BridgeSurfaceTilesGenerated != MatchedTimingSurfaceDesired.size())
|
|
{
|
|
OutError = TEXT("Matched legacy-bridge timing did not generate the complete surface key set.");
|
|
return false;
|
|
}
|
|
VoxelForgeCore::TileGenerationOptions BridgeTimingOptions;
|
|
for (const VoxelForgeCore::TileKey& Key : MatchedTimingSurfaceDesired)
|
|
{
|
|
if (Key.lod > 1u)
|
|
{
|
|
continue;
|
|
}
|
|
const double TileGenerationStart = FPlatformTime::Seconds();
|
|
const std::optional<VoxelForgeCore::TileMesh> Mesh =
|
|
BridgeWorld.generateTile(Key, BridgeTimingOptions, BridgeError);
|
|
if (!Mesh)
|
|
{
|
|
OutError = FString::Printf(TEXT("Legacy-bridge unhashed streaming sample failed: %s"),
|
|
UTF8_TO_TCHAR(BridgeError.c_str()));
|
|
return false;
|
|
}
|
|
const double TileGenerationSeconds = FPlatformTime::Seconds() - TileGenerationStart;
|
|
BridgeSurfaceTileSamples.push_back({
|
|
static_cast<int32>(Key.lod), ClassifyAssemblerTile(*Mesh), TileGenerationSeconds,
|
|
0.0, Mesh->diagnostics.classificationMilliseconds,
|
|
Mesh->diagnostics.columnComputeMilliseconds, Mesh->diagnostics.meshingMilliseconds,
|
|
Mesh->diagnostics.heightfieldBandLimited});
|
|
}
|
|
|
|
const auto AssignModelReadyTimes = [](std::vector<FAssemblerTileTimingSample>& Samples)
|
|
{
|
|
constexpr int32 WorkerSlots = 16;
|
|
std::array<double, WorkerSlots> WorkerReadySeconds{};
|
|
for (FAssemblerTileTimingSample& Sample : Samples)
|
|
{
|
|
int32 EarliestWorker = 0;
|
|
for (int32 Worker = 1; Worker < WorkerSlots; ++Worker)
|
|
{
|
|
if (WorkerReadySeconds[Worker] < WorkerReadySeconds[EarliestWorker])
|
|
{
|
|
EarliestWorker = Worker;
|
|
}
|
|
}
|
|
Sample.ModelReadySeconds = WorkerReadySeconds[EarliestWorker] + Sample.ServiceSeconds;
|
|
WorkerReadySeconds[EarliestWorker] = Sample.ModelReadySeconds;
|
|
}
|
|
};
|
|
const auto Percentile = [](std::vector<double> Values, double Fraction)
|
|
{
|
|
if (Values.empty()) return 0.0;
|
|
std::sort(Values.begin(), Values.end());
|
|
const std::size_t Index = static_cast<std::size_t>(FMath::Clamp(
|
|
FMath::CeilToInt(Fraction * static_cast<double>(Values.size() - 1u)),
|
|
0, static_cast<int32>(Values.size() - 1u)));
|
|
return Values[Index];
|
|
};
|
|
const auto EmitKindTiming = [&](const TCHAR* Backend,
|
|
std::vector<FAssemblerTileTimingSample>& Samples)
|
|
{
|
|
AssignModelReadyTimes(Samples);
|
|
for (int32 LOD = 0; LOD <= 1; ++LOD)
|
|
{
|
|
for (int32 KindIndex = 0; KindIndex < 4; ++KindIndex)
|
|
{
|
|
const EAssemblerTileKind Kind = static_cast<EAssemblerTileKind>(KindIndex);
|
|
std::vector<double> ServiceMs, ModelReadySeconds, ClassificationMs, ColumnMs, MeshingMs;
|
|
int32 HeightfieldBandedTiles = 0;
|
|
for (const FAssemblerTileTimingSample& Sample : Samples)
|
|
{
|
|
if (Sample.LOD != LOD || Sample.Kind != Kind) continue;
|
|
ServiceMs.push_back(Sample.ServiceSeconds * 1000.0);
|
|
ModelReadySeconds.push_back(Sample.ModelReadySeconds);
|
|
ClassificationMs.push_back(Sample.ClassificationMilliseconds);
|
|
ColumnMs.push_back(Sample.ColumnMilliseconds);
|
|
MeshingMs.push_back(Sample.MeshingMilliseconds);
|
|
if(Sample.bHeightfieldBandLimited)++HeightfieldBandedTiles;
|
|
}
|
|
const double ReadyP95 = Percentile(ModelReadySeconds, 0.95);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[WorldAssemblerPawnLag] sample=unhashed_runtime_fastpath backend=%s lod=%d kind=%s tiles=%d heightfield_banded=%d "
|
|
"service_ms[p50=%.3f p95=%.3f] worker_stage_ms_p95[classify=%.3f column=%.3f mesh=%.3f] "
|
|
"model_workers=16 model_ready_p95_s=%.6f walk_lag_p95_m=%.3f sprint_lag_p95_m=%.3f "
|
|
"worker_order=desired_distance"),
|
|
Backend, LOD, AssemblerTileKindName(Kind), static_cast<int32>(ServiceMs.size()),
|
|
HeightfieldBandedTiles,
|
|
Percentile(ServiceMs, 0.50), Percentile(ServiceMs, 0.95),
|
|
Percentile(ClassificationMs, 0.95), Percentile(ColumnMs, 0.95),
|
|
Percentile(MeshingMs, 0.95), ReadyP95,
|
|
ReadyP95 * static_cast<double>(StagedWalkSpeedCmPerSecond) / 100.0,
|
|
ReadyP95 * static_cast<double>(StagedSprintSpeedCmPerSecond) / 100.0);
|
|
}
|
|
}
|
|
};
|
|
EmitKindTiming(TEXT("core_native"), NativeSurfaceTileSamples);
|
|
EmitKindTiming(TEXT("legacy_surface_bridge"), BridgeSurfaceTileSamples);
|
|
|
|
const double NativeTotal = NativeSurfaceRegionCompileSeconds
|
|
+ NativeSurfaceTileGenerationSeconds;
|
|
const double BridgeTotal = BridgeSurfaceRegionCompileSeconds
|
|
+ BridgeSurfaceTileGenerationSeconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] matched_surface_tile_timing=1 native_backend=heightfield2d bridge_backend=legacy_surface_bridge same_keys=1 tiles=%llu hashes=on native_region_compile_s=%.6f bridge_region_compile_s=%.6f native_tile_generation_s=%.6f bridge_tile_generation_s=%.6f native_compile_plus_tiles_s=%.6f bridge_compile_plus_tiles_s=%.6f speedup=%.3fx native_triangles=%llu bridge_triangles=%llu"),
|
|
static_cast<unsigned long long>(MatchedTimingSurfaceDesired.size()),
|
|
NativeSurfaceRegionCompileSeconds, BridgeSurfaceRegionCompileSeconds,
|
|
NativeSurfaceTileGenerationSeconds, BridgeSurfaceTileGenerationSeconds,
|
|
NativeTotal, BridgeTotal, BridgeTotal / FMath::Max(NativeTotal, 1.0e-9),
|
|
static_cast<unsigned long long>(NativeSurfaceTriangleCount),
|
|
static_cast<unsigned long long>(BridgeSurfaceTriangleCount));
|
|
|
|
const UWorld::InitializationValues WorldInitialization = UWorld::InitializationValues()
|
|
.AllowAudioPlayback(false)
|
|
.RequiresHitProxies(false)
|
|
.CreatePhysicsScene(true)
|
|
.CreateNavigation(false)
|
|
.CreateAISystem(false)
|
|
.ShouldSimulatePhysics(true)
|
|
.SetDefaultGameMode(AGameModeBase::StaticClass())
|
|
.SetTransactional(false);
|
|
UWorld* TestWorld = UWorld::CreateWorld(
|
|
EWorldType::Game, false, FName(TEXT("VoxelForgeCoreActorValidationWorld")),
|
|
GetTransientPackage(), false, ERHIFeatureLevel::Num, &WorldInitialization);
|
|
if (TestWorld == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the transient BuildHost actor test world.");
|
|
return false;
|
|
}
|
|
UGameInstance* TestGameInstance = NewObject<UGameInstance>(GEngine);
|
|
FWorldContext& TestWorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
|
|
TestWorldContext.OwningGameInstance = TestGameInstance;
|
|
TestWorldContext.SetCurrentWorld(TestWorld);
|
|
TestWorld->SetGameInstance(TestGameInstance);
|
|
TestWorld->SetShouldTick(false);
|
|
TestGameInstance->Init();
|
|
TestWorld->AddToRoot();
|
|
struct FTransientWorldCleanup
|
|
{
|
|
UWorld* World = nullptr;
|
|
bool bHasEngineWorldContext = false;
|
|
uint64 PreviousFrameCounter = GFrameCounter;
|
|
~FTransientWorldCleanup()
|
|
{
|
|
if (World != nullptr)
|
|
{
|
|
if (World->HasBegunPlay())
|
|
{
|
|
World->BeginTearingDown();
|
|
World->EndPlay(EEndPlayReason::Quit);
|
|
}
|
|
if (UGameInstance* GameInstance = World->GetGameInstance())
|
|
{
|
|
GameInstance->Shutdown();
|
|
}
|
|
if (bHasEngineWorldContext && GEngine != nullptr)
|
|
{
|
|
GEngine->DestroyWorldContext(World);
|
|
}
|
|
World->DestroyWorld(false);
|
|
if (World->IsRooted()) World->RemoveFromRoot();
|
|
}
|
|
GFrameCounter = PreviousFrameCounter;
|
|
}
|
|
} Cleanup{TestWorld, true, GFrameCounter};
|
|
const FTransform ActorTransform = FTransform::Identity;
|
|
AVoxelWorld* Actor = TestWorld->SpawnActorDeferred<AVoxelWorld>(
|
|
AVoxelWorld::StaticClass(), ActorTransform, nullptr, nullptr,
|
|
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
|
|
if (Actor == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the transient AVoxelWorld for Core validation.");
|
|
return false;
|
|
}
|
|
Actor->Settings = AuthoredSettings;
|
|
Actor->bManageAtmosphere = false;
|
|
Actor->bUseVoxelForgeCore = true;
|
|
Actor->CoreVoxelSizeCm = Arguments.CoreVoxelSizeCm;
|
|
Actor->CoreMesherRecipe = Arguments.CoreHybridMesherVersion == 2
|
|
? EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV2
|
|
: (Arguments.CoreHybridMesherVersion == 1
|
|
? EVoxelForgeCoreMesherRecipe::WorldAssemblerHybridV1
|
|
: EVoxelForgeCoreMesherRecipe::MarchingCubesV1);
|
|
Actor->CoreClipRadius = 3;
|
|
Actor->CoreMaxClipLevel = 8;
|
|
Actor->bCaptureVoxelForgeCoreHashesForValidation = true;
|
|
Actor->FinishSpawning(ActorTransform);
|
|
if (!TestWorld->SetGameMode(FURL()))
|
|
{
|
|
OutError = TEXT("Could not create the transient BuildHost GameMode for actor validation.");
|
|
return false;
|
|
}
|
|
TestWorld->InitializeActorsForPlay(FURL());
|
|
TestWorld->BeginPlay();
|
|
Actor->SetActorTickEnabled(true);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] world_initialized=1 recipe=%s world_begun=%d actor_initialized=%d actor_beginplay=%d"),
|
|
Arguments.CoreHybridMesherVersion == 2 ? TEXT("WorldAssemblerHybridV2")
|
|
: (Arguments.CoreHybridMesherVersion == 1 ? TEXT("WorldAssemblerHybridV1")
|
|
: TEXT("MarchingCubesV1")),
|
|
TestWorld->HasBegunPlay() ? 1 : 0, Actor->IsActorInitialized() ? 1 : 0,
|
|
Actor->HasActorBegunPlay() ? 1 : 0);
|
|
const float ActiveCoreVoxelSizeCm = Actor->CoreVoxelSizeCm;
|
|
const FString ActiveSeasonIdentity = Actor->GetCurrentSeasonContentHash();
|
|
const float AlternateCoreVoxelSizeCm = FMath::IsNearlyEqual(ActiveCoreVoxelSizeCm, 25.0f, 0.0001f)
|
|
? 20.0f : 25.0f;
|
|
Actor->CoreVoxelSizeCm = AlternateCoreVoxelSizeCm;
|
|
const FString AlternateSeasonIdentity = Actor->GetCurrentSeasonContentHash();
|
|
Actor->CoreVoxelSizeCm = ActiveCoreVoxelSizeCm;
|
|
if (ActiveSeasonIdentity.IsEmpty() || AlternateSeasonIdentity.IsEmpty()
|
|
|| ActiveSeasonIdentity == AlternateSeasonIdentity)
|
|
{
|
|
OutError = TEXT("Core voxel size did not distinguish the hashed season/world identity.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] voxel_size_identity=PASS active_cm=%.3f active_hash=%s alternate_cm=%.3f alternate_hash=%s distinct=1"),
|
|
ActiveCoreVoxelSizeCm, *ActiveSeasonIdentity, AlternateCoreVoxelSizeCm,
|
|
*AlternateSeasonIdentity);
|
|
if (Arguments.bCoreHybridMesher
|
|
&& !RunHybridPluginMesherFixtures(
|
|
Actor->Generator, Arguments.bCoreHybridMesherV2, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
auto DesiredTilesLoaded = [](const AVoxelWorld* World)
|
|
{
|
|
if (World == nullptr || World->DesiredSorted.IsEmpty()) return false;
|
|
for (const FVoxelTileKey& Key : World->DesiredSorted)
|
|
{
|
|
if (!World->LoadedTiles.Contains(Key)) return false;
|
|
}
|
|
return true;
|
|
};
|
|
auto CollisionSetSettled = [](const AVoxelWorld* World)
|
|
{
|
|
if (World == nullptr) return false;
|
|
for (const FVoxelTileKey& Key : World->CollisionDesiredTiles)
|
|
{
|
|
if (!World->CollisionReadyTiles.Contains(Key)
|
|
&& !World->CollisionNotRequiredTiles.Contains(Key))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
auto WaitForStable = [&](double TimeoutSeconds)
|
|
{
|
|
const double Deadline = FPlatformTime::Seconds() + TimeoutSeconds;
|
|
const double ProgressIntervalSeconds = 10.0;
|
|
double LastProgressSeconds = FPlatformTime::Seconds();
|
|
int32 TickCount = 0;
|
|
while (FPlatformTime::Seconds() < Deadline)
|
|
{
|
|
TestWorld->Tick(LEVELTICK_All, 0.016f);
|
|
// Match FTestWorldWrapper::TickTestWorld: each manually driven world tick must advance
|
|
// the global frame counter or registered actor ticks run only once in this harness.
|
|
++GFrameCounter;
|
|
++TickCount;
|
|
FPlatformProcess::Sleep(0.001f);
|
|
if (Actor->IsVoxelForgeCoreReady() && Actor->bAllChunksLoaded
|
|
&& Actor->PendingTiles.IsEmpty() && Actor->PendingCollisionCooks.IsEmpty()
|
|
&& Actor->PendingUnload.IsEmpty()
|
|
&& Actor->ActiveTaskCount.load(std::memory_order_acquire) == 0
|
|
&& DesiredTilesLoaded(Actor) && CollisionSetSettled(Actor))
|
|
{
|
|
return true;
|
|
}
|
|
const double NowSeconds = FPlatformTime::Seconds();
|
|
if (NowSeconds - LastProgressSeconds >= ProgressIntervalSeconds)
|
|
{
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] wait ticks=%d ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d"),
|
|
TickCount, Actor->IsVoxelForgeCoreReady() ? 1 : 0,
|
|
Actor->DesiredSorted.Num(), Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(),
|
|
Actor->PendingCollisionCooks.Num(),
|
|
Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
LastProgressSeconds = NowSeconds;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (!WaitForStable(600.0))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("AVoxelWorld Core initial clipmap did not settle: ready=%d desired=%d loaded=%d pending=%d cooks=%d active=%d"),
|
|
Actor->IsVoxelForgeCoreReady() ? 1 : 0, Actor->DesiredSorted.Num(),
|
|
Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(),
|
|
Actor->PendingCollisionCooks.Num(),
|
|
Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
return false;
|
|
}
|
|
|
|
TSet<FVoxelTileKey> ActualInitialDesiredSet;
|
|
for (const FVoxelTileKey& Key : Actor->DesiredSorted) ActualInitialDesiredSet.Add(Key);
|
|
if (ActualInitialDesiredSet.Num() != ExpectedInitialDesiredSet.Num())
|
|
{
|
|
OutError = FString::Printf(TEXT("Core actor desired %d tiles; standalone reference desired %d."),
|
|
ActualInitialDesiredSet.Num(), ExpectedInitialDesiredSet.Num());
|
|
return false;
|
|
}
|
|
for (const FVoxelTileKey& Key : ExpectedInitialDesiredSet)
|
|
{
|
|
if (!ActualInitialDesiredSet.Contains(Key))
|
|
{
|
|
OutError = FString::Printf(TEXT("Core actor initial clipmap omitted tile (%d,%d,%d) lod=%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level);
|
|
return false;
|
|
}
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedHashes.Find(Key);
|
|
if (Actual == nullptr || Expected == nullptr || !Actual->bHasHashes
|
|
|| Actual->Density != Expected->Density || Actual->Mesh != Expected->Mesh
|
|
|| Actual->Payload != Expected->Payload || Actual->Topology != Expected->Topology
|
|
|| Actual->Triangles != Expected->Triangles || Actual->bEmpty != Expected->bEmpty)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor tile hash mismatch at (%d,%d,%d) lod=%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
TSet<FVoxelTileKey> ExpectedCollisionSet;
|
|
int32 ExpectedBodyCount = 0;
|
|
int32 ExpectedNoBodyCount = 0;
|
|
int32 ActualNoBodyCount = 0;
|
|
for (int32 Z = -CollisionRadius; Z <= CollisionRadius; ++Z)
|
|
for (int32 Y = -CollisionRadius; Y <= CollisionRadius; ++Y)
|
|
for (int32 X = -CollisionRadius; X <= CollisionRadius; ++X)
|
|
{
|
|
const FVoxelTileKey Key(ColdStartCollisionCenter + FIntVector(X, Y, Z), 0);
|
|
ExpectedCollisionSet.Add(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Record = ExpectedHashes.Find(Key);
|
|
if (Record == nullptr)
|
|
{
|
|
OutError = FString::Printf(TEXT("Standalone reference omitted collision tile (%d,%d,%d)."),
|
|
X, Y, Z);
|
|
return false;
|
|
}
|
|
if (Record->bEmpty) ++ExpectedNoBodyCount;
|
|
else ++ExpectedBodyCount;
|
|
if (Actor->CollisionNotRequiredTiles.Contains(Key)) ++ActualNoBodyCount;
|
|
}
|
|
if (Actor->CollisionDesiredTiles.Num() != ExpectedCollisionSet.Num()
|
|
|| Actor->CollisionReadyTiles.Num() != ExpectedBodyCount
|
|
|| ActualNoBodyCount != ExpectedNoBodyCount)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core collision count mismatch: desired=%d/%d ready=%d/%d no_body=%d/%d."),
|
|
Actor->CollisionDesiredTiles.Num(), ExpectedCollisionSet.Num(),
|
|
Actor->CollisionReadyTiles.Num(), ExpectedBodyCount,
|
|
ActualNoBodyCount, ExpectedNoBodyCount);
|
|
return false;
|
|
}
|
|
for (const FVoxelTileKey& Key : ExpectedCollisionSet)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord& Record = ExpectedHashes.FindChecked(Key);
|
|
const bool bExpectedCollisionReady = !Record.bEmpty;
|
|
if (!Actor->CollisionDesiredTiles.Contains(Key)
|
|
|| (bExpectedCollisionReady && !Actor->CollisionReadyTiles.Contains(Key))
|
|
|| (!bExpectedCollisionReady && !Actor->CollisionNotRequiredTiles.Contains(Key)))
|
|
{
|
|
OutError = FString::Printf(TEXT("Core collision result differs for tile (%d,%d,%d)."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] pass phase=initial tiles=%llu collision_desired=%d collision_ready=%d collision_no_body=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
static_cast<unsigned long long>(ExpectedInitialTileCount), ExpectedCollisionSet.Num(),
|
|
Actor->CollisionReadyTiles.Num(), Actor->CollisionNotRequiredTiles.Num(),
|
|
static_cast<unsigned long long>(InitialAggregates[0]),
|
|
static_cast<unsigned long long>(InitialAggregates[1]),
|
|
static_cast<unsigned long long>(InitialAggregates[2]),
|
|
static_cast<unsigned long long>(InitialAggregates[3]));
|
|
|
|
if (Actor->ContentManager == nullptr)
|
|
{
|
|
OutError = TEXT("Core height agreement proof has no content manager.");
|
|
return false;
|
|
}
|
|
const FVector SurfaceLocalPosition(
|
|
SurfaceCameraVoxels.x * CoreVoxelSizeCm,
|
|
SurfaceCameraVoxels.y * CoreVoxelSizeCm,
|
|
SurfaceCameraVoxels.z * CoreVoxelSizeCm);
|
|
const FVector SurfaceWorldPosition = Actor->GetActorTransform().TransformPosition(SurfaceLocalPosition);
|
|
const int32 LegacyUnscaledStrateIndex = Manager->GetStrateIndex(SurfaceLocalPosition.Z);
|
|
const int32 ResolvedStrateIndex = Actor->GetStrateAtPosition(SurfaceWorldPosition);
|
|
float ContentGroundLocalZ = 0.0f;
|
|
bool bContentHasWater = false;
|
|
float ContentWaterLocalZ = -FLT_MAX;
|
|
if (ResolvedStrateIndex != SurfaceStrateIndex
|
|
|| !Actor->ContentManager->QueryNativeSurfaceAnchors(ResolvedStrateIndex,
|
|
FVector2D(SurfaceLocalPosition.X, SurfaceLocalPosition.Y),
|
|
ContentGroundLocalZ, bContentHasWater, ContentWaterLocalZ))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core height agreement failed: raw-strate=%d resolved-strate=%d content-anchor=missing."),
|
|
LegacyUnscaledStrateIndex, ResolvedStrateIndex);
|
|
return false;
|
|
}
|
|
const float ManagerLocalZ = Actor->ToStrateManagerLocalZ(SurfaceLocalPosition.Z);
|
|
float ManagerTopZ = 0.0f, ManagerBottomZ = 0.0f;
|
|
if (!Manager->GetStrateUnrealZRange(ManagerLocalZ, ManagerTopZ, ManagerBottomZ))
|
|
{
|
|
OutError = TEXT("Core height agreement failed: manager has no corrected strate bounds.");
|
|
return false;
|
|
}
|
|
const float AtmosphereTopLocalZ = Actor->FromStrateManagerLocalZ(ManagerTopZ);
|
|
const float AtmosphereBottomLocalZ = Actor->FromStrateManagerLocalZ(ManagerBottomZ);
|
|
const FVector GroundQueryLocal(
|
|
SurfaceLocalPosition.X, SurfaceLocalPosition.Y, ContentGroundLocalZ + CoreVoxelSizeCm);
|
|
const FVector GroundQueryWorld = Actor->GetActorTransform().TransformPosition(GroundQueryLocal);
|
|
float TerrainGroundWorldZ = 0.0f, TerrainCeilingWorldZ = 0.0f;
|
|
if (!Actor->GetVoxelSurfaceHeightAt(GroundQueryWorld, TerrainGroundWorldZ, TerrainCeilingWorldZ))
|
|
{
|
|
OutError = TEXT("Core height agreement failed: native terrain returned no ground anchor.");
|
|
return false;
|
|
}
|
|
const float ContentGroundWorldZ = Actor->GetActorTransform().TransformPosition(
|
|
FVector(SurfaceLocalPosition.X, SurfaceLocalPosition.Y, ContentGroundLocalZ)).Z;
|
|
const float GroundAgreementCm = FMath::Abs(TerrainGroundWorldZ - ContentGroundWorldZ);
|
|
if (GroundAgreementCm > 1.0f
|
|
|| ContentGroundLocalZ < AtmosphereBottomLocalZ
|
|
|| ContentGroundLocalZ > AtmosphereTopLocalZ
|
|
|| (bContentHasWater && (ContentWaterLocalZ < AtmosphereBottomLocalZ
|
|
|| ContentWaterLocalZ > AtmosphereTopLocalZ)))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core height agreement failed: terrain/content delta=%.3f cm ground=%.3f water=%.3f atmosphere=(%.3f,%.3f)."),
|
|
GroundAgreementCm, ContentGroundLocalZ, ContentWaterLocalZ,
|
|
AtmosphereBottomLocalZ, AtmosphereTopLocalZ);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] height_agreement=PASS legacy_unscaled_strate=%d corrected_strate=%d terrain_ground_cm=%.3f content_ground_cm=%.3f water_enabled=%d water_cm=%.3f atmosphere_bounds_cm=(%.3f,%.3f) ground_delta_cm=%.3f"),
|
|
LegacyUnscaledStrateIndex, ResolvedStrateIndex, TerrainGroundWorldZ,
|
|
ContentGroundLocalZ, bContentHasWater ? 1 : 0, ContentWaterLocalZ,
|
|
AtmosphereBottomLocalZ, AtmosphereTopLocalZ, GroundAgreementCm);
|
|
if (!Actor->bUseVoxelForgeCore)
|
|
{
|
|
OutError = TEXT("BuildHost height agreement fixture unexpectedly has Core disabled.");
|
|
return false;
|
|
}
|
|
Actor->bUseVoxelForgeCore = false;
|
|
float DisabledGroundLocalZ = 0.0f;
|
|
bool bDisabledHasWater = false;
|
|
float DisabledWaterLocalZ = -FLT_MAX;
|
|
const bool bNativeAnchorEscapedSwitch = Actor->ContentManager->QueryNativeSurfaceAnchors(
|
|
ResolvedStrateIndex, FVector2D(SurfaceLocalPosition.X, SurfaceLocalPosition.Y),
|
|
DisabledGroundLocalZ, bDisabledHasWater, DisabledWaterLocalZ);
|
|
Actor->bUseVoxelForgeCore = true;
|
|
if (bNativeAnchorEscapedSwitch)
|
|
{
|
|
OutError = TEXT("BuildHost native content anchor remained available with Core disabled.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] switch_off_content_gate=PASS native_anchor_when_disabled=0 legacy_voxel_cm=%.1f"),
|
|
VOXEL_SIZE);
|
|
|
|
AActor* Anchor = TestWorld->SpawnActor<AActor>(AActor::StaticClass());
|
|
if (Anchor == nullptr)
|
|
{
|
|
OutError = TEXT("Could not spawn the Core streaming anchor fixture.");
|
|
return false;
|
|
}
|
|
USceneComponent* AnchorRoot = NewObject<USceneComponent>(Anchor, NAME_None, RF_Transient);
|
|
if (AnchorRoot == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the Core streaming anchor root component.");
|
|
return false;
|
|
}
|
|
Anchor->SetRootComponent(AnchorRoot);
|
|
AnchorRoot->RegisterComponent();
|
|
const double CoreTileExtentCm = static_cast<double>(CHUNK_SIZE) * Arguments.CoreVoxelSizeCm;
|
|
const FVector OldAnchorPosition(CoreTileExtentCm * 20.0, 0.0, 0.0);
|
|
const FVector NewAnchorPosition(CoreTileExtentCm * 21.0, 0.0, 0.0);
|
|
Anchor->SetActorLocation(OldAnchorPosition);
|
|
Actor->RegisterStreamingAnchor(
|
|
Anchor, EVoxelAnchorPolicy::CollisionOnly, 0, 0, 0);
|
|
if (!WaitForStable(180.0))
|
|
{
|
|
OutError = TEXT("Core CollisionOnly anchor did not finish its initial tile request.");
|
|
return false;
|
|
}
|
|
const FVoxelTileKey OldAnchorKey(FIntVector(20, 0, 0), 0);
|
|
const FVoxelTileKey NewAnchorKey(FIntVector(21, 0, 0), 0);
|
|
if (!Actor->DesiredSorted.Contains(OldAnchorKey)
|
|
|| !Actor->LoadedTiles.Contains(OldAnchorKey)
|
|
|| !Actor->CollisionDesiredTiles.Contains(OldAnchorKey))
|
|
{
|
|
OutError = TEXT("Core did not request and load the original anchor tile.");
|
|
return false;
|
|
}
|
|
Anchor->SetActorLocation(NewAnchorPosition);
|
|
if (!WaitForStable(180.0))
|
|
{
|
|
OutError = TEXT("Core did not settle after moving the streaming anchor by one tile.");
|
|
return false;
|
|
}
|
|
if (!Actor->DesiredSorted.Contains(NewAnchorKey)
|
|
|| !Actor->LoadedTiles.Contains(NewAnchorKey)
|
|
|| !Actor->CollisionDesiredTiles.Contains(NewAnchorKey)
|
|
|| Actor->DesiredSorted.Contains(OldAnchorKey)
|
|
|| Actor->LoadedTiles.Contains(OldAnchorKey)
|
|
|| Actor->CollisionDesiredTiles.Contains(OldAnchorKey))
|
|
{
|
|
OutError = TEXT("Core anchor move did not request the entering tile and release the leaving tile.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] pass phase=anchor old=(20,0,0) new=(21,0,0) requested=1 released=1"));
|
|
|
|
const FVector CarveWorldPosition = Actor->GetActorTransform().TransformPosition(CarveLocalPositionCm);
|
|
const FVector InsideBrushWorldPosition = CarveWorldPosition + FVector(60.0f, 0.0f, 0.0f);
|
|
const FVector OutsideBrushWorldPosition = CarveWorldPosition + FVector(80.0f, 0.0f, 0.0f);
|
|
float CarveDensityBefore = 0.0f, InsideDensityBefore = 0.0f, OutsideDensityBefore = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, CarveDensityBefore)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(InsideBrushWorldPosition, InsideDensityBefore)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(OutsideBrushWorldPosition, OutsideDensityBefore))
|
|
{
|
|
OutError = TEXT("Core Blueprint carve fixture could not read its pre-edit density samples.");
|
|
return false;
|
|
}
|
|
Actor->CarveAtPosition(CarveWorldPosition, CarveBrushRadiusLegacyVoxels, 10.0f);
|
|
float CarveDensityAfter = 0.0f, InsideDensityAfter = 0.0f, OutsideDensityAfter = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, CarveDensityAfter)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(InsideBrushWorldPosition, InsideDensityAfter)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(OutsideBrushWorldPosition, OutsideDensityAfter))
|
|
{
|
|
OutError = TEXT("Core Blueprint carve fixture could not read its post-edit density samples.");
|
|
return false;
|
|
}
|
|
const float CarveCenterDelta = CarveDensityAfter - CarveDensityBefore;
|
|
const float CarveInsideDelta = InsideDensityAfter - InsideDensityBefore;
|
|
const float CarveOutsideDelta = OutsideDensityAfter - OutsideDensityBefore;
|
|
const float ExpectedBrushCenterDelta = static_cast<float>(10.0 * VOXEL_SIZE / CoreVoxelSizeCm);
|
|
if (!FMath::IsNearlyEqual(CarveCenterDelta, ExpectedBrushCenterDelta, 0.01f)
|
|
|| !(CarveInsideDelta > 0.0f && CarveInsideDelta < CarveCenterDelta)
|
|
|| !FMath::IsNearlyZero(CarveOutsideDelta, 0.001f))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Blueprint carve sign/size failed: center=%.4f expected=+%.4f inside60cm=%.4f outside80cm=%.4f; expected partial +, zero."),
|
|
CarveCenterDelta, ExpectedBrushCenterDelta, CarveInsideDelta, CarveOutsideDelta);
|
|
return false;
|
|
}
|
|
if (Actor->LastVoxelForgeCoreEditSequence != 1
|
|
|| Actor->LastVoxelForgeCoreEditDigest != ExpectedEditDigest
|
|
|| Actor->LastVoxelForgeCoreAffectedTiles != ExpectedAffectedTiles)
|
|
{
|
|
OutError = TEXT("Core actor carve transaction sequence, digest, or affected tile list differs from Standalone.");
|
|
return false;
|
|
}
|
|
if (!WaitForStable(240.0))
|
|
{
|
|
OutError = TEXT("Core actor carve remesh/collision work did not settle.");
|
|
return false;
|
|
}
|
|
for (const FVoxelTileKey& Key : ExpectedAffectedTiles)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedPostEditHashes.Find(Key);
|
|
if (Actual == nullptr || Expected == nullptr || !Actual->bHasHashes
|
|
|| Actual->Density != Expected->Density || Actual->Mesh != Expected->Mesh
|
|
|| Actual->Payload != Expected->Payload || Actual->Topology != Expected->Topology
|
|
|| Actual->Triangles != Expected->Triangles || Actual->bEmpty != Expected->bEmpty)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor edited tile hash differs from Standalone at (%d,%d,%d) lod=%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level);
|
|
return false;
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] pass phase=edit sequence=%llu digest=0x%016llx affected=%d target=(%d,%d,%d) lod=%d hash=0x%016llx"),
|
|
static_cast<unsigned long long>(Actor->LastVoxelForgeCoreEditSequence),
|
|
static_cast<unsigned long long>(Actor->LastVoxelForgeCoreEditDigest),
|
|
ExpectedAffectedTiles.Num(), CarveTargetTile.Coord.X, CarveTargetTile.Coord.Y,
|
|
CarveTargetTile.Coord.Z, CarveTargetTile.Level,
|
|
static_cast<unsigned long long>(ExpectedPostEditHashes.FindChecked(CarveTargetTile).Mesh));
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] blueprint_carve=PASS radius_legacy=6 radius_cm=%.3f strength_legacy=10 center_delta=%.4f expected_center_delta=%.4f inside_60cm_delta=%.4f outside_80cm_delta=%.4f"),
|
|
6.0 * static_cast<double>(VOXEL_SIZE), CarveCenterDelta, ExpectedBrushCenterDelta,
|
|
CarveInsideDelta, CarveOutsideDelta);
|
|
|
|
Actor->UnregisterStreamingAnchor(Anchor);
|
|
Anchor->Destroy();
|
|
if (!WaitForStable(180.0))
|
|
{
|
|
OutError = TEXT("Core did not settle after removing the CollisionOnly anchor fixture.");
|
|
return false;
|
|
}
|
|
|
|
APlayerController* TravelController = TestWorld->SpawnActor<APlayerController>(
|
|
APlayerController::StaticClass(), FVector::ZeroVector, FRotator::ZeroRotator);
|
|
APawn* TravelPawn = TestWorld->SpawnActor<APawn>(
|
|
APawn::StaticClass(), FVector::ZeroVector, FRotator::ZeroRotator);
|
|
if (TravelController == nullptr || TravelPawn == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create the BuildHost player/controller for the Core travel proof.");
|
|
return false;
|
|
}
|
|
USceneComponent* TravelPawnRoot = NewObject<USceneComponent>(TravelPawn, NAME_None, RF_Transient);
|
|
if (TravelPawnRoot == nullptr)
|
|
{
|
|
OutError = TEXT("Could not create a root component for the BuildHost travel pawn.");
|
|
return false;
|
|
}
|
|
TravelPawn->SetRootComponent(TravelPawnRoot);
|
|
TravelPawnRoot->RegisterComponent();
|
|
const FVector TravelWorldPosition(
|
|
TravelCameraVoxels.x * CoreVoxelSizeCm,
|
|
TravelCameraVoxels.y * CoreVoxelSizeCm,
|
|
TravelCameraVoxels.z * CoreVoxelSizeCm);
|
|
TravelPawn->SetActorLocation(TravelWorldPosition);
|
|
TravelController->Possess(TravelPawn);
|
|
if (TravelController->GetPawn() != TravelPawn)
|
|
{
|
|
OutError = TEXT("BuildHost player controller did not possess the travel pawn.");
|
|
return false;
|
|
}
|
|
if (!WaitForStable(300.0))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core did not page and settle around the traveled pawn: desired=%d loaded=%d pending=%d cooks=%d active=%d."),
|
|
Actor->DesiredSorted.Num(), Actor->LoadedTiles.Num(), Actor->PendingTiles.Num(),
|
|
Actor->PendingCollisionCooks.Num(),
|
|
Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
return false;
|
|
}
|
|
|
|
TSet<FVoxelTileKey> ActualTravelDesiredSet;
|
|
std::vector<VoxelForgeCore::TileKey> ActorTravelDesired;
|
|
ActorTravelDesired.reserve(static_cast<std::size_t>(Actor->DesiredSorted.Num()));
|
|
for (const FVoxelTileKey& Key : Actor->DesiredSorted)
|
|
{
|
|
ActualTravelDesiredSet.Add(Key);
|
|
ActorTravelDesired.push_back({{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level});
|
|
}
|
|
if (ActualTravelDesiredSet.Num() != ExpectedTravelDesiredSet.Num())
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor desired %d tiles at the lower landing; standalone Core expects %d."),
|
|
ActualTravelDesiredSet.Num(), ExpectedTravelDesiredSet.Num());
|
|
return false;
|
|
}
|
|
for (const FVoxelTileKey& Key : ExpectedTravelDesiredSet)
|
|
{
|
|
if (!ActualTravelDesiredSet.Contains(Key) || !Actor->LoadedTiles.Contains(Key))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor omitted or failed to load lower-landing tile (%d,%d,%d) lod=%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level);
|
|
return false;
|
|
}
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedTravelHashes.Find(Key);
|
|
if (Actual == nullptr || Expected == nullptr || !Actual->bHasHashes
|
|
|| Actual->Density != Expected->Density || Actual->Mesh != Expected->Mesh
|
|
|| Actual->Payload != Expected->Payload || Actual->Topology != Expected->Topology
|
|
|| Actual->Triangles != Expected->Triangles || Actual->bEmpty != Expected->bEmpty)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor lower-landing tile differs from standalone Core at (%d,%d,%d) lod=%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
FString ActorCoverageError;
|
|
std::int32_t ActorCoverageLocalTiles = 0;
|
|
std::int32_t ActorCoverageFaces = 0;
|
|
std::int32_t ActorTransitionFaces = 0;
|
|
if (!AuditCoreClipmapCoverage(ActorTravelDesired,
|
|
{TravelCameraVoxels.x, TravelCameraVoxels.y, TravelCameraVoxels.z}, 128,
|
|
ActorCoverageLocalTiles, ActorCoverageFaces, ActorTransitionFaces,
|
|
ActorCoverageError))
|
|
{
|
|
OutError = FString::Printf(TEXT("AVoxelWorld lower-landing coverage failed: %s"),
|
|
*ActorCoverageError);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] pass phase=surface_travel center=(%.3f,%.3f,%.3f) desired=%d loaded=%d local_tiles=%d checked_faces=%d lod_transition_faces=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
TravelCameraVoxels.x, TravelCameraVoxels.y, TravelCameraVoxels.z,
|
|
ActualTravelDesiredSet.Num(), Actor->LoadedTiles.Num(), ActorCoverageLocalTiles,
|
|
ActorCoverageFaces, ActorTransitionFaces,
|
|
static_cast<unsigned long long>(TravelAggregates[0]),
|
|
static_cast<unsigned long long>(TravelAggregates[1]),
|
|
static_cast<unsigned long long>(TravelAggregates[2]),
|
|
static_cast<unsigned long long>(TravelAggregates[3]));
|
|
|
|
const FVector SurfaceTravelWorldPosition(
|
|
SurfaceCameraVoxels.x * CoreVoxelSizeCm,
|
|
SurfaceCameraVoxels.y * CoreVoxelSizeCm,
|
|
SurfaceCameraVoxels.z * CoreVoxelSizeCm);
|
|
TravelPawn->SetActorLocation(SurfaceTravelWorldPosition);
|
|
auto WaitForSurfaceVisualTiles = [&]()
|
|
{
|
|
const double Deadline = FPlatformTime::Seconds() + 120.0;
|
|
while (FPlatformTime::Seconds() < Deadline)
|
|
{
|
|
TestWorld->Tick(LEVELTICK_All, 0.016f);
|
|
++GFrameCounter;
|
|
FPlatformProcess::Sleep(0.001f);
|
|
bool bVisualTilesReady = true;
|
|
for (const FVoxelTileKey& Key : ExpectedSurfaceTravelDesiredSet)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedSurfaceTravelHashes.Find(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
if (Expected == nullptr || Actual == nullptr || !Actual->bHasHashes
|
|
|| (!Expected->bEmpty && !Actor->LoadedTiles.Contains(Key)))
|
|
{
|
|
bVisualTilesReady = false;
|
|
break;
|
|
}
|
|
}
|
|
if (bVisualTilesReady)
|
|
{
|
|
for (const FVoxelTileKey& Key : ExpectedSurfaceCollisionSet)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedSurfaceTravelHashes.Find(Key);
|
|
const bool bExpectedBody = Expected != nullptr && !Expected->bEmpty;
|
|
if (!Actor->CollisionDesiredTiles.Contains(Key)
|
|
|| (bExpectedBody && !Actor->CollisionReadyTiles.Contains(Key))
|
|
|| (!bExpectedBody && !Actor->CollisionNotRequiredTiles.Contains(Key)))
|
|
{
|
|
bVisualTilesReady = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (bVisualTilesReady) return true;
|
|
}
|
|
return false;
|
|
};
|
|
if (!WaitForSurfaceVisualTiles())
|
|
{
|
|
int32 HashedSurfaceTiles = 0;
|
|
int32 MissingVisibleSurfaceTiles = 0;
|
|
for (const FVoxelTileKey& Key : ExpectedSurfaceTravelDesiredSet)
|
|
{
|
|
if (Actor->VoxelForgeCoreTileHashes.Contains(Key)) ++HashedSurfaceTiles;
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedSurfaceTravelHashes.Find(Key);
|
|
if (Expected != nullptr && !Expected->bEmpty && !Actor->LoadedTiles.Contains(Key))
|
|
++MissingVisibleSurfaceTiles;
|
|
}
|
|
OutError = FString::Printf(
|
|
TEXT("Core did not load the surface-strate visual set: expected=%d hashed=%d missing_visible=%d desired=%d loaded=%d pending=%d active=%d."),
|
|
ExpectedSurfaceTravelDesiredSet.Num(), HashedSurfaceTiles,
|
|
MissingVisibleSurfaceTiles, Actor->DesiredSorted.Num(), Actor->LoadedTiles.Num(),
|
|
Actor->PendingTiles.Num(), Actor->ActiveTaskCount.load(std::memory_order_acquire));
|
|
return false;
|
|
}
|
|
|
|
int32 ActualSurfaceCollisionReady = 0;
|
|
int32 ActualSurfaceCollisionNoBody = 0;
|
|
for (const FVoxelTileKey& Key : ExpectedSurfaceCollisionSet)
|
|
{
|
|
const FVoxelForgeCoreTileHashRecord& Expected = ExpectedSurfaceTravelHashes.FindChecked(Key);
|
|
if (!Actor->CollisionDesiredTiles.Contains(Key))
|
|
{
|
|
OutError = FString::Printf(TEXT("Native-surface collision tile (%d,%d,%d) was not requested."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
if (Expected.bEmpty)
|
|
{
|
|
if (!Actor->CollisionNotRequiredTiles.Contains(Key))
|
|
{
|
|
OutError = FString::Printf(TEXT("Native-surface empty tile (%d,%d,%d) has unexpected collision state."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
++ActualSurfaceCollisionNoBody;
|
|
}
|
|
else
|
|
{
|
|
if (!Actor->CollisionReadyTiles.Contains(Key))
|
|
{
|
|
OutError = FString::Printf(TEXT("Native-surface solid tile (%d,%d,%d) has no cooked collision body."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z);
|
|
return false;
|
|
}
|
|
++ActualSurfaceCollisionReady;
|
|
}
|
|
}
|
|
|
|
if (!HybridFixtureContacts.empty())
|
|
{
|
|
const FHybridFixtureContactProbe& FirstProbe = HybridFixtureContacts.front();
|
|
const FVoxelTileKey FixtureTileKey(FIntVector(
|
|
FMath::FloorToInt(FirstProbe.Expected.X / (CoreVoxelSizeCm * CHUNK_SIZE)),
|
|
FMath::FloorToInt(FirstProbe.Expected.Y / (CoreVoxelSizeCm * CHUNK_SIZE)),
|
|
FMath::FloorToInt(FirstProbe.Expected.Z / (CoreVoxelSizeCm * CHUNK_SIZE))), 0);
|
|
const FVoxelForgeCoreTileHashRecord* FixtureActorHash =
|
|
Actor->VoxelForgeCoreTileHashes.Find(FixtureTileKey);
|
|
const FVoxelForgeCoreTileHashRecord* FixtureExpectedHash =
|
|
ExpectedSurfaceTravelHashes.Find(FixtureTileKey);
|
|
if (!ExpectedHybridFixtureVisualMesh || FixtureExpectedHash == nullptr || FixtureActorHash == nullptr
|
|
|| !FixtureActorHash->bHasHashes
|
|
|| FixtureActorHash->Mesh != FixtureExpectedHash->Mesh
|
|
|| FixtureActorHash->Triangles != FixtureExpectedHash->Triangles)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Hybrid fixture contact could not verify the actor render mesh against its standalone Core triangles: tile=(%d,%d,%d) visual_mesh=%d actor_hash=%d expected_hash=%d."),
|
|
FixtureTileKey.Coord.X, FixtureTileKey.Coord.Y, FixtureTileKey.Coord.Z,
|
|
ExpectedHybridFixtureVisualMesh ? 1 : 0, FixtureActorHash ? 1 : 0,
|
|
FixtureExpectedHash ? 1 : 0);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeHybridVisualMesh] tile=(%d,%d,%d) actor_hash_match=1 mesh=0x%016llx triangles=%d source=standalone_core_render_triangles"),
|
|
FixtureTileKey.Coord.X, FixtureTileKey.Coord.Y, FixtureTileKey.Coord.Z,
|
|
static_cast<unsigned long long>(FixtureActorHash->Mesh), FixtureActorHash->Triangles);
|
|
|
|
FString HybridCollisionContactFailures;
|
|
for (const FHybridFixtureContactProbe& Probe : HybridFixtureContacts)
|
|
{
|
|
FHitResult PawnHit;
|
|
FHitResult PawnCapsuleHit;
|
|
FHitResult VisibilityHit;
|
|
FHitResult WorldStaticHit;
|
|
FCollisionQueryParams QueryParams(SCENE_QUERY_STAT(VoxelForgeHybridFixtureContact), true);
|
|
FCollisionQueryParams CapsuleQueryParams(
|
|
SCENE_QUERY_STAT(VoxelForgeHybridFixtureCapsuleContact), false);
|
|
const bool bPawnHit = TestWorld->LineTraceSingleByChannel(
|
|
PawnHit, Probe.Start, Probe.End, ECC_Pawn, QueryParams);
|
|
const FCollisionShape PlayerCapsule = FCollisionShape::MakeCapsule(8.0f, 18.0f);
|
|
const bool bPawnCapsuleHit = TestWorld->SweepSingleByChannel(
|
|
PawnCapsuleHit, Probe.Start, Probe.End, FQuat::Identity,
|
|
ECC_Pawn, PlayerCapsule, CapsuleQueryParams);
|
|
const bool bVisibilityHit = TestWorld->LineTraceSingleByChannel(
|
|
VisibilityHit, Probe.Start, Probe.End, ECC_Visibility, QueryParams);
|
|
const bool bWorldStaticHit = TestWorld->LineTraceSingleByChannel(
|
|
WorldStaticHit, Probe.Start, Probe.End, ECC_WorldStatic, QueryParams);
|
|
URealtimeMeshComponent* FixtureComponent = nullptr;
|
|
const FVoxelTileKey ProbeTile(FIntVector(
|
|
FMath::FloorToInt(Probe.Expected.X / (CoreVoxelSizeCm * CHUNK_SIZE)),
|
|
FMath::FloorToInt(Probe.Expected.Y / (CoreVoxelSizeCm * CHUNK_SIZE)),
|
|
FMath::FloorToInt(Probe.Expected.Z / (CoreVoxelSizeCm * CHUNK_SIZE))), 0);
|
|
FixtureComponent = Actor->TileComponents.FindRef(ProbeTile);
|
|
FHitResult ObjectHit;
|
|
bool bObjectHit = false;
|
|
if (IsValid(FixtureComponent))
|
|
{
|
|
FCollisionObjectQueryParams ObjectQueryParams;
|
|
ObjectQueryParams.AddObjectTypesToQuery(FixtureComponent->GetCollisionObjectType());
|
|
bObjectHit = TestWorld->LineTraceSingleByObjectType(
|
|
ObjectHit, Probe.Start, Probe.End, ObjectQueryParams, QueryParams);
|
|
}
|
|
const bool bPawnLineContact = bPawnHit && PawnHit.GetActor() == Actor
|
|
&& PawnHit.GetComponent() == FixtureComponent;
|
|
const bool bPawnCapsuleContact = bPawnCapsuleHit
|
|
&& PawnCapsuleHit.GetActor() == Actor
|
|
&& PawnCapsuleHit.GetComponent() == FixtureComponent;
|
|
const FHitResult* ContactHit = bPawnLineContact ? &PawnHit
|
|
: (bPawnCapsuleContact ? &PawnCapsuleHit : nullptr);
|
|
const FVector ContactDeltaCm = ContactHit != nullptr
|
|
? ContactHit->ImpactPoint - Probe.Expected : FVector::ZeroVector;
|
|
const double ExpectedPointErrorCm = ContactHit != nullptr
|
|
? FVector::Dist(ContactHit->ImpactPoint, Probe.Expected)
|
|
: TNumericLimits<double>::Max();
|
|
float DensityAtContact = TNumericLimits<float>::Max();
|
|
const bool bCoreDensityAtContact = ContactHit != nullptr
|
|
&& Actor->QueryVoxelForgeCoreDensityAt(ContactHit->ImpactPoint, DensityAtContact);
|
|
FHitResult ComponentHit;
|
|
const bool bComponentHit = IsValid(FixtureComponent)
|
|
&& FixtureComponent->LineTraceComponent(
|
|
ComponentHit, Probe.Start, Probe.End, QueryParams);
|
|
FBox RenderBounds(EForceInit::ForceInit);
|
|
FBox CollisionBounds(EForceInit::ForceInit);
|
|
const bool bHaveRenderCollisionBounds = GetHybridFixtureRenderAndCollisionBounds(
|
|
FixtureComponent, RenderBounds, CollisionBounds);
|
|
constexpr double BoundsToleranceCm = 0.25;
|
|
const bool bRenderCollisionBoundsMatch = bHaveRenderCollisionBounds
|
|
&& FVector::Dist(RenderBounds.Min, CollisionBounds.Min) <= BoundsToleranceCm
|
|
&& FVector::Dist(RenderBounds.Max, CollisionBounds.Max) <= BoundsToleranceCm;
|
|
const double VisualContactErrorCm = ContactHit != nullptr && bComponentHit
|
|
? FVector::Dist(ContactHit->ImpactPoint, ComponentHit.ImpactPoint)
|
|
: TNumericLimits<double>::Max();
|
|
FVector VisualImpact = bComponentHit ? ComponentHit.ImpactPoint : FVector::ZeroVector;
|
|
const bool bVisualHit = ContactHit != nullptr && bComponentHit
|
|
&& bRenderCollisionBoundsMatch;
|
|
// Le trace direct du composant et le BodySetup cuisine doivent suivre la meme section RMC.
|
|
// The direct component trace and cooked BodySetup must agree with the same RMC render section.
|
|
const double AnalyticContactToleranceCm = Arguments.bCoreHybridMesherV2
|
|
? 1.0 : CoreVoxelSizeCm;
|
|
constexpr double ComponentTraceToleranceCm = 2.0;
|
|
if (ContactHit == nullptr || ExpectedPointErrorCm > AnalyticContactToleranceCm
|
|
|| !bVisualHit || VisualContactErrorCm > ComponentTraceToleranceCm)
|
|
{
|
|
const FTransform ComponentTransform = IsValid(FixtureComponent)
|
|
? FixtureComponent->GetComponentTransform() : FTransform::Identity;
|
|
const FVector TransformedStart = ComponentTransform.TransformPosition(Probe.Start);
|
|
const FVector TransformedEnd = ComponentTransform.TransformPosition(Probe.End);
|
|
const FVector TransformedExpected = ComponentTransform.TransformPosition(Probe.Expected);
|
|
FHitResult TransformedWorldHit;
|
|
const bool bTransformedWorldHit = IsValid(FixtureComponent)
|
|
&& TestWorld->LineTraceSingleByChannel(
|
|
TransformedWorldHit, TransformedStart, TransformedEnd, ECC_Pawn, QueryParams);
|
|
float DensityStart = TNumericLimits<float>::Max();
|
|
float DensityExpected = TNumericLimits<float>::Max();
|
|
float DensityEnd = TNumericLimits<float>::Max();
|
|
const bool bCoreDensityStart = Actor->QueryVoxelForgeCoreDensityAt(
|
|
Probe.Start, DensityStart);
|
|
const bool bCoreDensityExpected = Actor->QueryVoxelForgeCoreDensityAt(
|
|
Probe.Expected, DensityExpected);
|
|
const bool bCoreDensityEnd = Actor->QueryVoxelForgeCoreDensityAt(
|
|
Probe.End, DensityEnd);
|
|
const FVector ComponentLocation = IsValid(FixtureComponent)
|
|
? FixtureComponent->GetComponentLocation() : FVector::ZeroVector;
|
|
const FVector ComponentOrigin = IsValid(FixtureComponent)
|
|
? FixtureComponent->Bounds.Origin : FVector::ZeroVector;
|
|
const FVector ComponentExtent = IsValid(FixtureComponent)
|
|
? FixtureComponent->Bounds.BoxExtent : FVector::ZeroVector;
|
|
const URealtimeMesh* FixtureMesh = IsValid(FixtureComponent)
|
|
? FixtureComponent->GetRealtimeMesh() : nullptr;
|
|
const FBoxSphereBounds MeshLocalBounds = FixtureMesh != nullptr
|
|
? FixtureMesh->GetLocalBounds()
|
|
: FBoxSphereBounds(FSphere(FVector::ZeroVector, 0.0f));
|
|
const UBodySetup* BodySetup = IsValid(FixtureComponent)
|
|
? FixtureComponent->GetBodySetup() : nullptr;
|
|
FVector BodyMeshMinimum(TNumericLimits<double>::Max());
|
|
FVector BodyMeshMaximum(-TNumericLimits<double>::Max());
|
|
int32 BodyMeshCount = 0;
|
|
if (BodySetup != nullptr)
|
|
{
|
|
BodyMeshCount = BodySetup->TriMeshGeometries.Num();
|
|
for (const Chaos::FTriangleMeshImplicitObjectPtr& BodyMesh : BodySetup->TriMeshGeometries)
|
|
{
|
|
if (!BodyMesh.IsValid()) continue;
|
|
const Chaos::FAABB3& BodyBounds = BodyMesh->BoundingBox();
|
|
const FVector BodyMinimum(
|
|
BodyBounds.Min()[0], BodyBounds.Min()[1], BodyBounds.Min()[2]);
|
|
const FVector BodyMaximum(
|
|
BodyBounds.Max()[0], BodyBounds.Max()[1], BodyBounds.Max()[2]);
|
|
BodyMeshMinimum.X = FMath::Min(BodyMeshMinimum.X, BodyMinimum.X);
|
|
BodyMeshMinimum.Y = FMath::Min(BodyMeshMinimum.Y, BodyMinimum.Y);
|
|
BodyMeshMinimum.Z = FMath::Min(BodyMeshMinimum.Z, BodyMinimum.Z);
|
|
BodyMeshMaximum.X = FMath::Max(BodyMeshMaximum.X, BodyMaximum.X);
|
|
BodyMeshMaximum.Y = FMath::Max(BodyMeshMaximum.Y, BodyMaximum.Y);
|
|
BodyMeshMaximum.Z = FMath::Max(BodyMeshMaximum.Z, BodyMaximum.Z);
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeHybridCollisionDiagnostic] fixture=%s tile=(%d,%d,%d) "
|
|
"ready=%d not_required=%d component=%d body=%d physics_state=%d "
|
|
"collision_enabled=%d pawn_response=%d visibility_response=%d object_type=%d "
|
|
"pawn_hit=%d pawn_capsule_hit=%d capsule_actor_match=%d capsule_component_match=%d component_trace_hit=%d component_trace_error_cm=%.3f bounds_match=%d "
|
|
"visibility_hit=%d world_static_hit=%d object_hit=%d component_hit=%d "
|
|
"transformed_world_hit=%d "
|
|
"component_location=(%.2f,%.2f,%.2f) bounds_origin=(%.2f,%.2f,%.2f) "
|
|
"component_rotation=(%.2f,%.2f,%.2f) component_scale=(%.4f,%.4f,%.4f) "
|
|
"transformed_expected=(%.2f,%.2f,%.2f) "
|
|
"bounds_extent=(%.2f,%.2f,%.2f) mesh_local_origin=(%.2f,%.2f,%.2f) "
|
|
"mesh_local_extent=(%.2f,%.2f,%.2f) body_trace_flag=%d double_sided=%d "
|
|
"body_created=%d body_failed=%d body_tri_meshes=%d "
|
|
"body_min=(%.2f,%.2f,%.2f) body_max=(%.2f,%.2f,%.2f) "
|
|
"core_density_valid=%d/%d/%d "
|
|
"core_density_start=%.4f core_density_expected=%.4f core_density_end=%.4f"),
|
|
*Probe.Name, ProbeTile.Coord.X, ProbeTile.Coord.Y, ProbeTile.Coord.Z,
|
|
Actor->CollisionReadyTiles.Contains(ProbeTile) ? 1 : 0,
|
|
Actor->CollisionNotRequiredTiles.Contains(ProbeTile) ? 1 : 0,
|
|
IsValid(FixtureComponent) ? 1 : 0,
|
|
IsValid(FixtureComponent) && FixtureComponent->GetBodySetup() != nullptr ? 1 : 0,
|
|
IsValid(FixtureComponent) && FixtureComponent->IsPhysicsStateCreated() ? 1 : 0,
|
|
IsValid(FixtureComponent)
|
|
? static_cast<int32>(FixtureComponent->GetCollisionEnabled()) : -1,
|
|
IsValid(FixtureComponent)
|
|
? static_cast<int32>(FixtureComponent->GetCollisionResponseToChannel(ECC_Pawn)) : -1,
|
|
IsValid(FixtureComponent)
|
|
? static_cast<int32>(FixtureComponent->GetCollisionResponseToChannel(ECC_Visibility)) : -1,
|
|
IsValid(FixtureComponent)
|
|
? static_cast<int32>(FixtureComponent->GetCollisionObjectType()) : -1,
|
|
bPawnHit ? 1 : 0, bPawnCapsuleHit ? 1 : 0,
|
|
bPawnCapsuleHit && PawnCapsuleHit.GetActor() == Actor ? 1 : 0,
|
|
bPawnCapsuleHit && PawnCapsuleHit.GetComponent() == FixtureComponent ? 1 : 0,
|
|
bComponentHit ? 1 : 0, VisualContactErrorCm,
|
|
bRenderCollisionBoundsMatch ? 1 : 0,
|
|
bVisibilityHit ? 1 : 0,
|
|
bWorldStaticHit ? 1 : 0, bObjectHit ? 1 : 0,
|
|
bComponentHit ? 1 : 0, bTransformedWorldHit ? 1 : 0,
|
|
ComponentLocation.X, ComponentLocation.Y, ComponentLocation.Z,
|
|
ComponentOrigin.X, ComponentOrigin.Y, ComponentOrigin.Z,
|
|
ComponentTransform.Rotator().Pitch, ComponentTransform.Rotator().Yaw,
|
|
ComponentTransform.Rotator().Roll,
|
|
ComponentTransform.GetScale3D().X, ComponentTransform.GetScale3D().Y,
|
|
ComponentTransform.GetScale3D().Z,
|
|
TransformedExpected.X, TransformedExpected.Y, TransformedExpected.Z,
|
|
ComponentExtent.X, ComponentExtent.Y, ComponentExtent.Z,
|
|
MeshLocalBounds.Origin.X, MeshLocalBounds.Origin.Y, MeshLocalBounds.Origin.Z,
|
|
MeshLocalBounds.BoxExtent.X, MeshLocalBounds.BoxExtent.Y, MeshLocalBounds.BoxExtent.Z,
|
|
BodySetup != nullptr ? static_cast<int32>(BodySetup->CollisionTraceFlag) : -1,
|
|
BodySetup != nullptr && BodySetup->bDoubleSidedGeometry ? 1 : 0,
|
|
BodySetup != nullptr && BodySetup->bCreatedPhysicsMeshes ? 1 : 0,
|
|
BodySetup != nullptr && BodySetup->bFailedToCreatePhysicsMeshes ? 1 : 0,
|
|
BodyMeshCount,
|
|
BodyMeshCount > 0 ? BodyMeshMinimum.X : 0.0,
|
|
BodyMeshCount > 0 ? BodyMeshMinimum.Y : 0.0,
|
|
BodyMeshCount > 0 ? BodyMeshMinimum.Z : 0.0,
|
|
BodyMeshCount > 0 ? BodyMeshMaximum.X : 0.0,
|
|
BodyMeshCount > 0 ? BodyMeshMaximum.Y : 0.0,
|
|
BodyMeshCount > 0 ? BodyMeshMaximum.Z : 0.0,
|
|
bCoreDensityStart ? 1 : 0,
|
|
bCoreDensityExpected ? 1 : 0,
|
|
bCoreDensityEnd ? 1 : 0,
|
|
DensityStart, DensityExpected, DensityEnd);
|
|
HybridCollisionContactFailures += FString::Printf(
|
|
TEXT("Hybrid cooked Pawn collision contact failed for %s: pawn_hit=%d pawn_capsule_hit=%d capsule_actor_match=%d capsule_component_match=%d component_trace_hit=%d component_trace_error_cm=%.3f bounds_match=%d expected_point_error_cm=%.3f expected_delta_cm=(%.3f,%.3f,%.3f) density_at_contact=%.6f density_query=%d visibility_hit=%d world_static_hit=%d expected=(%.2f,%.2f,%.2f) pawn_impact=(%.2f,%.2f,%.2f) capsule_impact=(%.2f,%.2f,%.2f)."),
|
|
*Probe.Name, bPawnHit ? 1 : 0, bPawnCapsuleHit ? 1 : 0,
|
|
bPawnCapsuleHit && PawnCapsuleHit.GetActor() == Actor ? 1 : 0,
|
|
bPawnCapsuleHit && PawnCapsuleHit.GetComponent() == FixtureComponent ? 1 : 0,
|
|
bComponentHit ? 1 : 0, VisualContactErrorCm,
|
|
bRenderCollisionBoundsMatch ? 1 : 0, ExpectedPointErrorCm,
|
|
ContactDeltaCm.X, ContactDeltaCm.Y, ContactDeltaCm.Z,
|
|
DensityAtContact, bCoreDensityAtContact ? 1 : 0,
|
|
bVisibilityHit ? 1 : 0, bWorldStaticHit ? 1 : 0,
|
|
Probe.Expected.X, Probe.Expected.Y, Probe.Expected.Z,
|
|
bPawnHit ? PawnHit.ImpactPoint.X : 0.0,
|
|
bPawnHit ? PawnHit.ImpactPoint.Y : 0.0,
|
|
bPawnHit ? PawnHit.ImpactPoint.Z : 0.0,
|
|
bPawnCapsuleHit ? PawnCapsuleHit.ImpactPoint.X : 0.0,
|
|
bPawnCapsuleHit ? PawnCapsuleHit.ImpactPoint.Y : 0.0,
|
|
bPawnCapsuleHit ? PawnCapsuleHit.ImpactPoint.Z : 0.0);
|
|
HybridCollisionContactFailures += TEXT("\n");
|
|
continue;
|
|
}
|
|
const TCHAR* ContactMethod = bPawnLineContact ? TEXT("PawnLine") : TEXT("PawnCapsule");
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeHybridCollisionContact] fixture=%s method=%s channel=Pawn hit=1 actor_match=1 component_match=1 actor_core_hash_match=1 render_collision_bounds_match=1 component_trace_error_cm=%.3f expected_point_error_cm=%.3f expected_delta_cm=(%.3f,%.3f,%.3f) density_at_contact=%.6f density_query=%d expected=(%.2f,%.2f,%.2f) impact=(%.2f,%.2f,%.2f) component_impact=(%.2f,%.2f,%.2f)"),
|
|
*Probe.Name, ContactMethod, VisualContactErrorCm, ExpectedPointErrorCm,
|
|
ContactDeltaCm.X, ContactDeltaCm.Y, ContactDeltaCm.Z,
|
|
DensityAtContact, bCoreDensityAtContact ? 1 : 0,
|
|
Probe.Expected.X, Probe.Expected.Y, Probe.Expected.Z,
|
|
ContactHit->ImpactPoint.X, ContactHit->ImpactPoint.Y, ContactHit->ImpactPoint.Z,
|
|
VisualImpact.X, VisualImpact.Y, VisualImpact.Z);
|
|
}
|
|
if (!HybridCollisionContactFailures.IsEmpty())
|
|
{
|
|
OutError = HybridCollisionContactFailures;
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeHybridCollisionContact] pass=1 probes=%d cooked_ready_tiles=%d"),
|
|
static_cast<int32>(HybridFixtureContacts.size()), ActualSurfaceCollisionReady);
|
|
}
|
|
|
|
TSet<FVoxelTileKey> ActualSurfaceVisualDesiredSet;
|
|
std::vector<VoxelForgeCore::TileKey> ActorSurfaceTravelDesired;
|
|
ActorSurfaceTravelDesired.reserve(static_cast<std::size_t>(Actor->DesiredSorted.Num()));
|
|
for (const FVoxelTileKey& Key : Actor->DesiredSorted)
|
|
{
|
|
if (!Actor->CollisionOnlyTiles.Contains(Key))
|
|
{
|
|
ActualSurfaceVisualDesiredSet.Add(Key);
|
|
ActorSurfaceTravelDesired.push_back({{Key.Coord.X, Key.Coord.Y, Key.Coord.Z}, Key.Level});
|
|
}
|
|
}
|
|
if (ActualSurfaceVisualDesiredSet.Num() != ExpectedSurfaceTravelDesiredSet.Num())
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor desired %d visual tiles at the surface-strate fixture; standalone Core expects %d."),
|
|
ActualSurfaceVisualDesiredSet.Num(), ExpectedSurfaceTravelDesiredSet.Num());
|
|
return false;
|
|
}
|
|
for (const FVoxelTileKey& Key : ExpectedSurfaceTravelDesiredSet)
|
|
{
|
|
if (!ActualSurfaceVisualDesiredSet.Contains(Key))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor omitted surface-strate visual tile (%d,%d,%d) lod=%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level);
|
|
return false;
|
|
}
|
|
const FVoxelForgeCoreTileHashRecord* Actual = Actor->VoxelForgeCoreTileHashes.Find(Key);
|
|
const FVoxelForgeCoreTileHashRecord* Expected = ExpectedSurfaceTravelHashes.Find(Key);
|
|
if (Actual == nullptr || Expected == nullptr || !Actual->bHasHashes
|
|
|| (!Expected->bEmpty && !Actor->LoadedTiles.Contains(Key)))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor surface-strate tile state is incomplete at (%d,%d,%d) lod=%d (actual=%d expected=%d hashed=%d loaded=%d)."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level,
|
|
Actual ? 1 : 0, Expected ? 1 : 0, Actual && Actual->bHasHashes ? 1 : 0,
|
|
Actor->LoadedTiles.Contains(Key) ? 1 : 0);
|
|
return false;
|
|
}
|
|
if (Actual->Density != Expected->Density || Actual->Mesh != Expected->Mesh
|
|
|| Actual->Payload != Expected->Payload || Actual->Topology != Expected->Topology
|
|
|| Actual->Triangles != Expected->Triangles || Actual->bEmpty != Expected->bEmpty)
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Core actor surface-strate hash mismatch at (%d,%d,%d) lod=%d; density=%016llx/%016llx mesh=%016llx/%016llx payload=%016llx/%016llx topology=%016llx/%016llx triangles=%d/%d empty=%d/%d."),
|
|
Key.Coord.X, Key.Coord.Y, Key.Coord.Z, Key.Level,
|
|
static_cast<unsigned long long>(Actual->Density),
|
|
static_cast<unsigned long long>(Expected->Density),
|
|
static_cast<unsigned long long>(Actual->Mesh),
|
|
static_cast<unsigned long long>(Expected->Mesh),
|
|
static_cast<unsigned long long>(Actual->Payload),
|
|
static_cast<unsigned long long>(Expected->Payload),
|
|
static_cast<unsigned long long>(Actual->Topology),
|
|
static_cast<unsigned long long>(Expected->Topology),
|
|
Actual->Triangles, Expected->Triangles, Actual->bEmpty ? 1 : 0, Expected->bEmpty ? 1 : 0);
|
|
return false;
|
|
}
|
|
}
|
|
FString SurfaceCoverageError;
|
|
std::int32_t ActorSurfaceLocalTiles = 0;
|
|
std::int32_t ActorSurfaceCheckedFaces = 0;
|
|
std::int32_t ActorSurfaceTransitionFaces = 0;
|
|
if (!AuditCoreClipmapCoverage(ActorSurfaceTravelDesired,
|
|
{SurfaceCameraVoxels.x, SurfaceCameraVoxels.y, SurfaceCameraVoxels.z}, 128,
|
|
ActorSurfaceLocalTiles, ActorSurfaceCheckedFaces, ActorSurfaceTransitionFaces,
|
|
SurfaceCoverageError))
|
|
{
|
|
OutError = FString::Printf(TEXT("AVoxelWorld surface-strate coverage failed: %s"),
|
|
*SurfaceCoverageError);
|
|
return false;
|
|
}
|
|
if (!bNativeBiomeFieldValidated || NativeBiomeIdsSampled < 2)
|
|
{
|
|
OutError = TEXT("Core native SurfaceWorld biome validation did not complete.");
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] pass phase=surface_strate_travel center=(%.3f,%.3f,%.3f) desired=%d loaded=%d fine_compiled=%llu regions=%llu local_tiles=%d checked_faces=%d lod_transition_faces=%d collision_desired=%d collision_ready=%d collision_no_body=%d native_surface_biome_id=%u native_biomes_sampled=%d density=0x%016llx mesh=0x%016llx payload=0x%016llx topology=0x%016llx"),
|
|
SurfaceCameraVoxels.x, SurfaceCameraVoxels.y, SurfaceCameraVoxels.z,
|
|
ActualSurfaceVisualDesiredSet.Num(), Actor->LoadedTiles.Num(),
|
|
static_cast<unsigned long long>(SurfaceTravelFineTilesCompiled),
|
|
static_cast<unsigned long long>(SurfaceTravelRegionsBuilt), ActorSurfaceLocalTiles,
|
|
ActorSurfaceCheckedFaces, ActorSurfaceTransitionFaces, ExpectedSurfaceCollisionSet.Num(),
|
|
ActualSurfaceCollisionReady, ActualSurfaceCollisionNoBody,
|
|
NativeSurfaceBiomeId, NativeBiomeIdsSampled,
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[0]),
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[1]),
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[2]),
|
|
static_cast<unsigned long long>(SurfaceTravelAggregates[3]));
|
|
const FVector FillInsideWorldPosition = CarveWorldPosition + FVector(60.0f, 0.0f, 0.0f);
|
|
const FVector FillOutsideWorldPosition = CarveWorldPosition + FVector(80.0f, 0.0f, 0.0f);
|
|
float FillDensityBefore = 0.0f, FillInsideDensityBefore = 0.0f, FillOutsideDensityBefore = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, FillDensityBefore)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(FillInsideWorldPosition, FillInsideDensityBefore)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(FillOutsideWorldPosition, FillOutsideDensityBefore))
|
|
{
|
|
OutError = TEXT("Core Blueprint fill fixture could not read its pre-edit size samples.");
|
|
return false;
|
|
}
|
|
Actor->FillAtPosition(CarveWorldPosition, 6.0f, 10.0f);
|
|
float FillDensityAfter = 0.0f, FillInsideDensityAfter = 0.0f, FillOutsideDensityAfter = 0.0f;
|
|
if (!Actor->QueryVoxelForgeCoreDensityAt(CarveWorldPosition, FillDensityAfter)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(FillInsideWorldPosition, FillInsideDensityAfter)
|
|
|| !Actor->QueryVoxelForgeCoreDensityAt(FillOutsideWorldPosition, FillOutsideDensityAfter))
|
|
{
|
|
OutError = TEXT("Core Blueprint fill fixture could not read its post-edit size samples.");
|
|
return false;
|
|
}
|
|
const float FillCenterDelta = FillDensityAfter - FillDensityBefore;
|
|
const float FillInsideDelta = FillInsideDensityAfter - FillInsideDensityBefore;
|
|
const float FillOutsideDelta = FillOutsideDensityAfter - FillOutsideDensityBefore;
|
|
const float ExpectedFillCenterDelta = -ExpectedBrushCenterDelta;
|
|
if (!FMath::IsNearlyEqual(FillCenterDelta, ExpectedFillCenterDelta, 0.01f)
|
|
|| !(FillInsideDelta < 0.0f && FillInsideDelta > FillCenterDelta)
|
|
|| !FMath::IsNearlyZero(FillOutsideDelta, 0.001f))
|
|
{
|
|
OutError = FString::Printf(
|
|
TEXT("Blueprint fill sign/size failed: center=%.4f expected=%.4f inside60cm=%.4f outside80cm=%.4f; expected partial -, zero."),
|
|
FillCenterDelta, ExpectedFillCenterDelta, FillInsideDelta, FillOutsideDelta);
|
|
return false;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCoreWorldTest] blueprint_fill=PASS radius_legacy=6 radius_cm=%.3f strength_legacy=10 center_delta=%.4f expected_center_delta=%.4f inside_60cm_delta=%.4f outside_80cm_delta=%.4f"),
|
|
6.0 * static_cast<double>(VOXEL_SIZE), FillCenterDelta, ExpectedFillCenterDelta,
|
|
FillInsideDelta, FillOutsideDelta);
|
|
if (!RunCoreContentPlacementCostAudit(Actor, SurfaceLocalPosition,
|
|
TEXT("SurfaceWorld"), OutError))
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet()
|
|
{
|
|
IsClient = false;
|
|
IsServer = false;
|
|
IsEditor = true;
|
|
LogToConsole = true;
|
|
}
|
|
|
|
int32 RunExploreCase(const FString& Params, FString* OutJson)
|
|
{
|
|
const double MainStartSeconds = FPlatformTime::Seconds();
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] invoked: %s"), FCommandLine::Get());
|
|
|
|
FExploreArguments Arguments;
|
|
FString Error;
|
|
if (!ParseArguments(Params, Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] %s"), *Error);
|
|
return 1;
|
|
}
|
|
|
|
// A commandlet can run several fresh manager lifetimes in one process. Release completed
|
|
// immutable proof caches from the preceding case before constructing this one.
|
|
VoxelDensityOps::TrimSharedRoomGraphCache();
|
|
|
|
// Every case starts from a disabled, empty diagnostic state. The world/cache objects are
|
|
// local to this invocation and their owner/lifetime keys prevent stale worker caches from
|
|
// serving the next case's generation.
|
|
VoxelDensityProfile::Reset();
|
|
VoxelDensityProfile::SetMode(
|
|
Arguments.IsDensityProfilingEnabled()
|
|
? ExploreProfileMode(Arguments)
|
|
: VoxelDensityProfile::EMode::Disabled,
|
|
VoxelDensityProfile::DefaultSampleInterval);
|
|
if (!IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] Could not create output directory '%s'."),
|
|
*Arguments.OutDirectory);
|
|
return 1;
|
|
}
|
|
|
|
if (Arguments.bCoreWorldTest)
|
|
{
|
|
if (!RunCoreWorldActorIntegrationTest(Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeCoreWorldTest] failed: %s"), *Error);
|
|
return 1;
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCoreWorldTest] pass complete=1"));
|
|
return 0;
|
|
}
|
|
|
|
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<double>(Arguments.BudgetMinutes) * 60.0);
|
|
|
|
const double SetupStartSeconds = FPlatformTime::Seconds();
|
|
FExploreWorld World;
|
|
if (!World.Build(Arguments, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] %s"), *Error);
|
|
return 1;
|
|
}
|
|
const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds;
|
|
|
|
FExploreStackSamplerSession StackSamplerSession;
|
|
if (Arguments.bSampleStacks
|
|
&& !StackSamplerSession.Start(
|
|
Arguments.SampleStacksIntervalUs, Arguments.OutDirectory))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] stack sampler was requested but could not be started."));
|
|
return 1;
|
|
}
|
|
|
|
FExploreRunOutput Output;
|
|
bool bRequestedModeFailed = false;
|
|
if (Arguments.bTunnelCoreProbe)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bProbeOk = !Budget.bTruncated
|
|
&& RunTunnelCoreProbe(Arguments, World, Output.TunnelCoreProbe, Budget);
|
|
if (!bProbeOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
if (bProbeOk && !Budget.bTruncated)
|
|
{
|
|
Budget.CompleteMode(TEXT("tunnelcoreprobe"));
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] tunnel core probe %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start,
|
|
bProbeOk ? TEXT("ok")
|
|
: (Budget.bTruncated ? TEXT("truncated") : TEXT("error")));
|
|
}
|
|
if (Arguments.bDensitySlice)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bDensitySliceOk = !Budget.bTruncated
|
|
&& RunDensitySlice(Arguments, World, Output.DensitySlice, Budget);
|
|
if (!bDensitySliceOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] density slice failed: %s"),
|
|
*Output.DensitySlice.RefusalReason);
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] density slice %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start,
|
|
bDensitySliceOk ? TEXT("ok")
|
|
: (Budget.bTruncated ? TEXT("truncated") : TEXT("error")));
|
|
}
|
|
if (Arguments.bOpBounds)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bOpBoundsOk = !Budget.bTruncated
|
|
&& RunOpBounds(Arguments, World, Budget, Error);
|
|
if (!bOpBoundsOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] opbounds failed: %s"), *Error);
|
|
}
|
|
if (bOpBoundsOk && !Budget.bTruncated)
|
|
{
|
|
Budget.CompleteMode(TEXT("opbounds"));
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] opbounds %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start,
|
|
bOpBoundsOk ? TEXT("ok") : (Budget.bTruncated ? TEXT("truncated") : TEXT("error")));
|
|
}
|
|
|
|
if (Arguments.bProfileLod)
|
|
{
|
|
const FIntVector ProbeOrigin(0, 0, World.TargetBottomWorldZ);
|
|
const int32 FullRes = World.Settings
|
|
? FMath::Max(1, World.Settings->FullResClipLevels)
|
|
: 2;
|
|
const int32 CoarseCells = World.Settings
|
|
? FMath::Clamp(World.Settings->CoarseTileCells, 4, CHUNK_SIZE)
|
|
: 16;
|
|
const int32 CutMin = World.Settings
|
|
? World.Settings->GetEffectiveStrateContentCutMinLevel()
|
|
: 9;
|
|
for (int32 Level = 0; Level <= 4; ++Level)
|
|
{
|
|
// Match AVoxelWorld::LoadTile exactly: the probe is a production tile at this LOD,
|
|
// including the configured coarse sample count. Using 1<<Level here would measure
|
|
// a denser mesh than the game generates at levels >= FullResClipLevels.
|
|
const int32 Extent = CHUNK_SIZE << Level;
|
|
const int32 Cells = Level < FullRes ? CHUNK_SIZE : CoarseCells;
|
|
const int32 Step = FMath::Max(1, Extent / Cells);
|
|
VoxelDensityProfile::Reset();
|
|
VoxelDensityProfile::SetMode(
|
|
ExploreProfileMode(Arguments),
|
|
VoxelDensityProfile::DefaultSampleInterval);
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bBanded = Level >= CutMin;
|
|
const FVoxelMeshData Mesh = World.Mesher->GenerateMesh(
|
|
ProbeOrigin, Step, Cells, nullptr,
|
|
bBanded ? World.TargetBottomWorldZ : INT32_MIN,
|
|
bBanded ? World.TargetTopWorldZ - 1 : INT32_MAX);
|
|
const double Seconds = FPlatformTime::Seconds() - Start;
|
|
const VoxelDensityProfile::FSnapshot Profile = VoxelDensityProfile::Snapshot();
|
|
const int32 DensityIndex = static_cast<int32>(VoxelDensityProfile::EBucket::GetDensityAt);
|
|
const uint64 Calls = Profile.Calls[DensityIndex];
|
|
const uint64 Lookups = Profile.Counters[static_cast<int32>(VoxelDensityProfile::ECounter::TunnelCacheLookup)];
|
|
const uint64 Hits = Profile.Counters[static_cast<int32>(VoxelDensityProfile::ECounter::TunnelCacheHit)];
|
|
const uint64 SdfBuilds = Profile.Counters[static_cast<int32>(VoxelDensityProfile::ECounter::SdfCacheBuild)];
|
|
const double DensityUs = static_cast<double>(Profile.WallCycles[DensityIndex])
|
|
* FPlatformTime::GetSecondsPerCycle64() * 1.0e6;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeLODProfile] level=%d step=%d cells=%d banded=%d seconds=%.6f density_calls=%llu "
|
|
"density_us_per_call=%.3f sdf_builds=%llu cache_lookups=%llu cache_hits=%llu cache_misses=%llu "
|
|
"hit_rate=%.3f vertices=%d triangles=%d mesh_empty=%d"),
|
|
Level, Step, Cells, bBanded ? 1 : 0, Seconds,
|
|
static_cast<unsigned long long>(Calls),
|
|
Calls > 0 ? DensityUs / static_cast<double>(Calls) : 0.0,
|
|
static_cast<unsigned long long>(SdfBuilds),
|
|
static_cast<unsigned long long>(Lookups),
|
|
static_cast<unsigned long long>(Hits),
|
|
static_cast<unsigned long long>(Profile.Counters[static_cast<int32>(VoxelDensityProfile::ECounter::TunnelCacheMiss)]),
|
|
Lookups > 0 ? static_cast<double>(Hits) / static_cast<double>(Lookups) : 0.0,
|
|
Mesh.Vertices.Num(), Mesh.Triangles.Num() / 3,
|
|
Mesh.IsEmpty() ? 1 : 0);
|
|
}
|
|
if (Arguments.IsDensityProfilingEnabled())
|
|
{
|
|
VoxelDensityProfile::Reset();
|
|
VoxelDensityProfile::SetMode(
|
|
ExploreProfileMode(Arguments),
|
|
VoxelDensityProfile::DefaultSampleInterval);
|
|
}
|
|
else
|
|
{
|
|
VoxelDensityProfile::SetMode(VoxelDensityProfile::EMode::Disabled);
|
|
}
|
|
}
|
|
|
|
if (Budget.ShouldStop(TEXT("setup")))
|
|
{
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeExplore] setup consumed the wall-clock budget; no mode was started."));
|
|
}
|
|
FExploreWalkOutput RenderSeedWalk;
|
|
const FExploreWalkOutput* CameraSeedWalk = nullptr;
|
|
if (Arguments.bWalk)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bWalkOk = RunWalk(Arguments, World, Output.Walk, Budget);
|
|
Output.WalkSeconds = FPlatformTime::Seconds() - Start;
|
|
if (!bWalkOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] walk %.3fs (%s)"),
|
|
Output.WalkSeconds, *Output.Walk.Status);
|
|
CameraSeedWalk = &Output.Walk;
|
|
if (bWalkOk && !Budget.bTruncated)
|
|
{
|
|
Budget.CompleteMode(TEXT("walk"));
|
|
}
|
|
}
|
|
else if (Arguments.bRender)
|
|
{
|
|
if (Arguments.bRenderCameraOverride)
|
|
{
|
|
// Controlled diagnostic renders can supply a player-fit seed captured from a baseline
|
|
// run. This keeps the mesh region, camera pose, and raster target identical while a
|
|
// single generator writer is toggled. The density guard in RunRender still refuses a
|
|
// stale/inside-rock pose.
|
|
RenderSeedWalk.CameraSeedPoses.Add(Arguments.RenderCamera);
|
|
RenderSeedWalk.CameraSeedSources.Add(TEXT("render_camera_override"));
|
|
RenderSeedWalk.Status = TEXT("override");
|
|
Output.WalkSeconds = 0.0;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] render camera seed override (%.3f, %.3f, %.3f)"),
|
|
Arguments.RenderCamera.X, Arguments.RenderCamera.Y, Arguments.RenderCamera.Z);
|
|
}
|
|
else
|
|
{
|
|
// A render-only invocation still has to obey the inside-walkable-space contract. Run
|
|
// the same focused walk as a private seed pass; it is intentionally omitted from the
|
|
// render-only JSON so the selected mode remains render.
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bWalkOk = RunWalk(Arguments, World, RenderSeedWalk, Budget);
|
|
Output.WalkSeconds = FPlatformTime::Seconds() - Start;
|
|
if (!bWalkOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] render camera seed walk %.3fs (%s)"),
|
|
Output.WalkSeconds, *RenderSeedWalk.Status);
|
|
}
|
|
CameraSeedWalk = &RenderSeedWalk;
|
|
// Keep the private render seed capture available to the run-level shared-grid report;
|
|
// the walk object is still omitted from the public JSON when walk was not requested.
|
|
Output.Walk = MoveTemp(RenderSeedWalk);
|
|
CameraSeedWalk = &Output.Walk;
|
|
}
|
|
|
|
// A profile report gets a genuine unprofiled reference for the same canonical mesh region.
|
|
// The reference world is fresh, profiling is disabled for it, and its geometry hash is
|
|
// compared with the profiled world below. This is deliberately a diagnostic second pass;
|
|
// it cannot affect the profiled world's generation or its deterministic output.
|
|
if (Arguments.IsDensityProfilingEnabled()
|
|
&& (Arguments.bRender || Arguments.bExport)
|
|
&& (CameraSeedWalk != nullptr || Arguments.bExport)
|
|
&& !Budget.bTruncated)
|
|
{
|
|
Output.ProfilerComparison.Scope = TEXT("canonical_mesh_export");
|
|
VoxelDensityProfile::SetMode(VoxelDensityProfile::EMode::Disabled);
|
|
FExploreWorld ReferenceWorld;
|
|
FString ReferenceError;
|
|
if (ReferenceWorld.Build(Arguments, ReferenceError))
|
|
{
|
|
FExploreBudget ReferenceBudget(MainStartSeconds, Budget.LimitSeconds);
|
|
const double ReferenceStart = FPlatformTime::Seconds();
|
|
if (EnsureExploreMesh(
|
|
Arguments, ReferenceWorld, CameraSeedWalk, ReferenceBudget, ReferenceError)
|
|
&& ReferenceWorld.bExploreMeshComplete)
|
|
{
|
|
Output.ProfilerComparison.bAvailable = true;
|
|
Output.ProfilerComparison.UnprofiledSeconds = ReferenceWorld.ExploreMeshSeconds;
|
|
Output.ProfilerComparison.UnprofiledGeometryHash = ReferenceWorld.ExploreGeometryHash;
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeProfiler] unprofiled reference failed after %.3fs: %s"),
|
|
FPlatformTime::Seconds() - ReferenceStart, *ReferenceError);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeProfiler] could not build unprofiled reference world: %s"),
|
|
*ReferenceError);
|
|
}
|
|
VoxelDensityProfile::Reset();
|
|
VoxelDensityProfile::SetMode(
|
|
ExploreProfileMode(Arguments),
|
|
VoxelDensityProfile::DefaultSampleInterval);
|
|
}
|
|
if (Arguments.bRender)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
bool bRenderOk = false;
|
|
if (!Budget.bTruncated && CameraSeedWalk != nullptr)
|
|
{
|
|
bRenderOk = RunRender(
|
|
Arguments, World, *CameraSeedWalk, Output.Render, Budget);
|
|
}
|
|
if (!bRenderOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
if (Budget.bTruncated && Output.Render.Status.IsEmpty())
|
|
{
|
|
Output.Render.Status = TEXT("truncated");
|
|
Output.Render.bTruncated = true;
|
|
Output.Render.RefusalReason = TEXT("The wall-clock budget stopped before rendering.");
|
|
}
|
|
if (bRenderOk && !Budget.bTruncated)
|
|
{
|
|
Budget.CompleteMode(TEXT("render"));
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start, *Output.Render.Status);
|
|
}
|
|
if (Arguments.bFailureFocusRender && Arguments.bRender && Arguments.bWalk
|
|
&& !Budget.bTruncated)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
if (!RunFailureBoundaryRender(Arguments, World, Output.Walk, Output.Render, Budget))
|
|
{
|
|
if (!Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] failure-focus render could not produce a boundary image."));
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] failure-focus render %.3fs (ok)"),
|
|
FPlatformTime::Seconds() - Start);
|
|
}
|
|
}
|
|
if (Arguments.bExport)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bExportOk = !Budget.bTruncated
|
|
&& RunExport(Arguments, World, CameraSeedWalk, Output.Export, Budget);
|
|
if (!bExportOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
if (Budget.bTruncated && Output.Export.Status.IsEmpty())
|
|
{
|
|
Output.Export.Status = TEXT("truncated");
|
|
Output.Export.bTruncated = true;
|
|
Output.Export.RefusalReason = TEXT("The wall-clock budget stopped before export.");
|
|
}
|
|
if (bExportOk && !Budget.bTruncated)
|
|
{
|
|
Budget.CompleteMode(TEXT("export"));
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] export %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start, *Output.Export.Status);
|
|
}
|
|
|
|
// Stop before the report is assembled, while World still owns the generation state. This
|
|
// guarantees that the sampler's summary describes only this case and that all worker stacks
|
|
// have been released before the commandlet tears the world down.
|
|
StackSamplerSession.Stop();
|
|
|
|
// A phase can finish just inside its own check and cross the deadline while its artifact is
|
|
// being closed. Mark that state before serialising the report so the JSON never claims a full
|
|
// run after the budget has actually expired.
|
|
Budget.ShouldStop(TEXT("report"));
|
|
if (Budget.bTruncated)
|
|
{
|
|
if (Arguments.bWalk && Output.Walk.Status.IsEmpty())
|
|
{
|
|
Output.Walk.Status = TEXT("truncated");
|
|
Output.Walk.RefusalReason = TEXT("The wall-clock budget stopped before walk completion.");
|
|
}
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeExplore] truncated during %s after %.3fs; completed modes=%s"),
|
|
*Budget.TruncatedDuring,
|
|
Budget.ElapsedSeconds(),
|
|
*FString::Join(Budget.CompletedModes, TEXT(",")));
|
|
}
|
|
Output.BudgetSeconds = Budget.LimitSeconds;
|
|
Output.ElapsedSeconds = FPlatformTime::Seconds() - MainStartSeconds;
|
|
Output.bTruncated = Budget.bTruncated;
|
|
Output.TruncatedDuring = Budget.TruncatedDuring;
|
|
Output.CompletedModes = Budget.CompletedModes;
|
|
|
|
Output.Profile = VoxelDensityProfile::Snapshot();
|
|
Output.bHasProfile = true;
|
|
if (Arguments.IsDensityProfilingEnabled())
|
|
{
|
|
if (Output.ProfilerComparison.bAvailable)
|
|
{
|
|
Output.ProfilerComparison.ProfiledSeconds = World.ExploreMeshSeconds;
|
|
Output.ProfilerComparison.ProfiledGeometryHash = World.ExploreGeometryHash;
|
|
Output.ProfilerComparison.DiscrepancySeconds =
|
|
Output.ProfilerComparison.ProfiledSeconds
|
|
- Output.ProfilerComparison.UnprofiledSeconds;
|
|
Output.ProfilerComparison.DiscrepancyPercent =
|
|
Output.ProfilerComparison.UnprofiledSeconds > 0.0
|
|
? Output.ProfilerComparison.DiscrepancySeconds
|
|
* 100.0 / Output.ProfilerComparison.UnprofiledSeconds
|
|
: 0.0;
|
|
Output.ProfilerComparison.Ratio =
|
|
Output.ProfilerComparison.UnprofiledSeconds > 0.0
|
|
? Output.ProfilerComparison.ProfiledSeconds
|
|
/ Output.ProfilerComparison.UnprofiledSeconds
|
|
: 0.0;
|
|
Output.ProfilerComparison.bGeometryEqual =
|
|
!Output.ProfilerComparison.ProfiledGeometryHash.IsEmpty()
|
|
&& Output.ProfilerComparison.ProfiledGeometryHash
|
|
== Output.ProfilerComparison.UnprofiledGeometryHash;
|
|
const bool bWithinTarget = FMath::Abs(Output.ProfilerComparison.DiscrepancyPercent) <= 20.0;
|
|
const bool bSelfCheckPassed = bWithinTarget
|
|
&& Output.ProfilerComparison.bGeometryEqual;
|
|
Output.ProfilerComparison.SelfCheck = bSelfCheckPassed ? TEXT("passed") : TEXT("FAILED");
|
|
if (bSelfCheckPassed)
|
|
{
|
|
UE_LOG(
|
|
LogTemp, Display,
|
|
TEXT("[VoxelForgeProfiler] profiled_total=%.6fs unprofiled_total=%.6fs "
|
|
"discrepancy=%.2f%% ratio=%.3fx geometry_equal=%s self_check=%s"),
|
|
Output.ProfilerComparison.ProfiledSeconds,
|
|
Output.ProfilerComparison.UnprofiledSeconds,
|
|
Output.ProfilerComparison.DiscrepancyPercent,
|
|
Output.ProfilerComparison.Ratio,
|
|
Output.ProfilerComparison.bGeometryEqual ? TEXT("yes") : TEXT("no"),
|
|
*Output.ProfilerComparison.SelfCheck);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(
|
|
LogTemp, Error,
|
|
TEXT("[VoxelForgeProfiler] profiled_total=%.6fs unprofiled_total=%.6fs "
|
|
"discrepancy=%.2f%% ratio=%.3fx geometry_equal=%s self_check=%s"),
|
|
Output.ProfilerComparison.ProfiledSeconds,
|
|
Output.ProfilerComparison.UnprofiledSeconds,
|
|
Output.ProfilerComparison.DiscrepancyPercent,
|
|
Output.ProfilerComparison.Ratio,
|
|
Output.ProfilerComparison.bGeometryEqual ? TEXT("yes") : TEXT("no"),
|
|
*Output.ProfilerComparison.SelfCheck);
|
|
}
|
|
if (!bSelfCheckPassed)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Output.ProfilerComparison.Scope = TEXT("canonical_mesh_export");
|
|
Output.ProfilerComparison.SelfCheck = TEXT("not_available");
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeProfiler] self-check could not obtain an unprofiled reference."));
|
|
bRequestedModeFailed = true;
|
|
}
|
|
}
|
|
|
|
// Build the payload twice before adding the assertion itself. This is deliberately a string
|
|
// comparison over a fixed-order writer, not a TMap/pretty-printer whose key order could drift.
|
|
const FString Payload = BuildExploreJson(Arguments, World, Output, false, 0);
|
|
const FString PayloadRepeat = BuildExploreJson(Arguments, World, Output, false, 0);
|
|
if (Payload.IsEmpty() || PayloadRepeat.IsEmpty())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] JSON writer failed before report creation."));
|
|
return 1;
|
|
}
|
|
// Hash the exact UTF-8 representation that is written to disk. This keeps the reported
|
|
// digest independent of TCHAR width (and therefore meaningful when the same run is compared
|
|
// across editor platforms).
|
|
FTCHARToUTF8 PayloadUtf8(*Payload);
|
|
const uint32 PayloadCrc32 = FCrc::MemCrc32(PayloadUtf8.Get(), PayloadUtf8.Length());
|
|
const bool bPayloadRepeatEqual = Payload == PayloadRepeat;
|
|
FString Json = BuildExploreJson(
|
|
Arguments, World, Output, bPayloadRepeatEqual, PayloadCrc32);
|
|
const FString JsonRepeat = BuildExploreJson(
|
|
Arguments, World, Output, bPayloadRepeatEqual, PayloadCrc32);
|
|
if (Json.IsEmpty() || JsonRepeat.IsEmpty())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] JSON writer failed during report creation."));
|
|
return 1;
|
|
}
|
|
const bool bFinalRepeatEqual = Json == JsonRepeat;
|
|
if (!bFinalRepeatEqual)
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] deterministic JSON assertion failed before write."));
|
|
bRequestedModeFailed = true;
|
|
Json = BuildExploreJson(Arguments, World, Output, false, PayloadCrc32);
|
|
}
|
|
|
|
const FString ReportPath = FPaths::Combine(Arguments.OutDirectory, TEXT("explore.json"));
|
|
if (!FFileHelper::SaveStringToFile(
|
|
Json,
|
|
*ReportPath,
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] Could not write '%s'."), *ReportPath);
|
|
return 1;
|
|
}
|
|
if (OutJson != nullptr)
|
|
{
|
|
*OutJson = Json;
|
|
}
|
|
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] setup %.3fs, total %.3fs; report=%s; json_deterministic=%s"),
|
|
SetupSeconds,
|
|
FPlatformTime::Seconds() - MainStartSeconds,
|
|
*ReportPath,
|
|
(bPayloadRepeatEqual && bFinalRepeatEqual) ? TEXT("yes") : TEXT("no"));
|
|
|
|
if (Arguments.IsDensityProfilingEnabled())
|
|
{
|
|
const VoxelDensityProfile::FSnapshot Profile = VoxelDensityProfile::Snapshot();
|
|
const double CycleToMicroseconds = FPlatformTime::GetSecondsPerCycle64() * 1.0e6;
|
|
const uint64 DensityCalls = Profile.Calls[static_cast<int32>(VoxelDensityProfile::EBucket::GetDensityAt)];
|
|
const uint64 DensityCycles = Profile.WallCycles[static_cast<int32>(VoxelDensityProfile::EBucket::GetDensityAt)];
|
|
const double DensityTotalUs = static_cast<double>(DensityCycles) * CycleToMicroseconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeDensityProfile] total calls=%llu total_us=%.3f us_per_call=%.6f"),
|
|
static_cast<unsigned long long>(DensityCalls), DensityTotalUs,
|
|
DensityCalls > 0 ? DensityTotalUs / static_cast<double>(DensityCalls) : 0.0);
|
|
|
|
const uint64 CacheTotalStaticBytes = Profile.TunnelCacheStaticBytes
|
|
+ Profile.RoomGraphCacheStaticBytes;
|
|
const uint64 CacheTotalDynamicBytes = Profile.TunnelCacheDynamicBytes
|
|
+ Profile.RoomGraphCacheDynamicBytes;
|
|
const uint64 CacheTotalBytes = CacheTotalStaticBytes + CacheTotalDynamicBytes;
|
|
const uint64 CacheWorkers = FMath::Max(
|
|
Profile.TunnelCacheWorkers, Profile.RoomGraphCacheWorkers);
|
|
const uint64 CacheValidEntries = Profile.TunnelCacheValidEntries
|
|
+ Profile.RoomGraphCacheValidEntries;
|
|
const uint64 CacheEntryBytes = Profile.TunnelCacheEntryBytes
|
|
+ Profile.RoomGraphCacheEntryBytes;
|
|
const uint64 CacheCapacityEntries = Profile.TunnelCacheCapacityEntries
|
|
+ Profile.RoomGraphCacheCapacityEntries;
|
|
const uint64 CacheLargestEntryBytes = FMath::Max(
|
|
Profile.TunnelCacheLargestEntryBytes,
|
|
Profile.RoomGraphCacheLargestEntryBytes);
|
|
const uint64 CacheLargestWorkerBytes = FMath::Max(
|
|
Profile.TunnelCacheLargestWorkerBytes,
|
|
Profile.RoomGraphCacheLargestWorkerBytes);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeCacheMemory] workers=%llu capacity_entries=%llu valid_entries=%llu "
|
|
"static_bytes=%llu dynamic_bytes=%llu total_bytes=%llu "
|
|
"avg_valid_entry_bytes=%.1f largest_entry_bytes=%llu "
|
|
"avg_worker_bytes=%.1f largest_worker_bytes=%llu "
|
|
"tunnel_avg_entry_bytes=%.1f tunnel_largest_entry_bytes=%llu "
|
|
"tunnel_largest_worker_valid=%llu "
|
|
"roomgraph_avg_entry_bytes=%.1f roomgraph_largest_entry_bytes=%llu "
|
|
"roomgraph_largest_worker_valid=%llu "
|
|
"tunnel_workers=%llu tunnel_capacity=%llu tunnel_valid=%llu "
|
|
"tunnel_static=%llu tunnel_dynamic=%llu roomgraph_workers=%llu "
|
|
"roomgraph_capacity=%llu roomgraph_valid=%llu roomgraph_static=%llu "
|
|
"roomgraph_dynamic=%llu"),
|
|
static_cast<unsigned long long>(CacheWorkers),
|
|
static_cast<unsigned long long>(CacheCapacityEntries),
|
|
static_cast<unsigned long long>(CacheValidEntries),
|
|
static_cast<unsigned long long>(CacheTotalStaticBytes),
|
|
static_cast<unsigned long long>(CacheTotalDynamicBytes),
|
|
static_cast<unsigned long long>(CacheTotalBytes),
|
|
CacheValidEntries > 0
|
|
? static_cast<double>(CacheEntryBytes) / static_cast<double>(CacheValidEntries)
|
|
: 0.0,
|
|
static_cast<unsigned long long>(CacheLargestEntryBytes),
|
|
CacheWorkers > 0
|
|
? static_cast<double>(CacheTotalBytes) / static_cast<double>(CacheWorkers)
|
|
: 0.0,
|
|
static_cast<unsigned long long>(CacheLargestWorkerBytes),
|
|
Profile.TunnelCacheValidEntries > 0
|
|
? static_cast<double>(Profile.TunnelCacheEntryBytes)
|
|
/ static_cast<double>(Profile.TunnelCacheValidEntries)
|
|
: 0.0,
|
|
static_cast<unsigned long long>(Profile.TunnelCacheLargestEntryBytes),
|
|
static_cast<unsigned long long>(Profile.TunnelCacheLargestWorkerValidEntries),
|
|
Profile.RoomGraphCacheValidEntries > 0
|
|
? static_cast<double>(Profile.RoomGraphCacheEntryBytes)
|
|
/ static_cast<double>(Profile.RoomGraphCacheValidEntries)
|
|
: 0.0,
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheLargestEntryBytes),
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheLargestWorkerValidEntries),
|
|
static_cast<unsigned long long>(Profile.TunnelCacheWorkers),
|
|
static_cast<unsigned long long>(Profile.TunnelCacheCapacityEntries),
|
|
static_cast<unsigned long long>(Profile.TunnelCacheValidEntries),
|
|
static_cast<unsigned long long>(Profile.TunnelCacheStaticBytes),
|
|
static_cast<unsigned long long>(Profile.TunnelCacheDynamicBytes),
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheWorkers),
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheCapacityEntries),
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheValidEntries),
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheStaticBytes),
|
|
static_cast<unsigned long long>(Profile.RoomGraphCacheDynamicBytes));
|
|
|
|
const VoxelDensityProfile::EBucket DensityPhases[] = {
|
|
VoxelDensityProfile::EBucket::DensityPrologue,
|
|
VoxelDensityProfile::EBucket::DensityCore,
|
|
VoxelDensityProfile::EBucket::DensityDisturbances,
|
|
VoxelDensityProfile::EBucket::DensityStructuralPosts,
|
|
VoxelDensityProfile::EBucket::DensityBoundarySeal,
|
|
VoxelDensityProfile::EBucket::DensityDiffLayer,
|
|
VoxelDensityProfile::EBucket::DensityTail,
|
|
};
|
|
uint64 DensityAttributionCycles = 0;
|
|
uint64 DensityAttributionCalls = 0;
|
|
for (const VoxelDensityProfile::EBucket Phase : DensityPhases)
|
|
{
|
|
const int32 PhaseIndex = static_cast<int32>(Phase);
|
|
DensityAttributionCycles += Profile.WallCycles[PhaseIndex];
|
|
DensityAttributionCalls += Profile.Calls[PhaseIndex];
|
|
}
|
|
const double DensityAttributionUs =
|
|
static_cast<double>(DensityAttributionCycles) * CycleToMicroseconds;
|
|
const double DensityUnattributedUs = DensityTotalUs - DensityAttributionUs;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeDensityProfile] attribution_sum_us=%.3f unattributed_us=%.3f "
|
|
"coverage=%.2f%% phase_calls=%llu"),
|
|
DensityAttributionUs, DensityUnattributedUs,
|
|
DensityTotalUs > 0.0 ? DensityAttributionUs * 100.0 / DensityTotalUs : 0.0,
|
|
static_cast<unsigned long long>(DensityAttributionCalls));
|
|
|
|
for (int32 Index = 0; Index < VoxelDensityProfile::CounterCount; ++Index)
|
|
{
|
|
const VoxelDensityProfile::ECounter Counter =
|
|
static_cast<VoxelDensityProfile::ECounter>(Index);
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeDensityProfile] counter=%s value=%llu per_density_call=%.3f"),
|
|
VoxelDensityProfile::CounterName(Counter),
|
|
static_cast<unsigned long long>(Profile.Counters[Index]),
|
|
DensityCalls > 0
|
|
? static_cast<double>(Profile.Counters[Index])
|
|
/ static_cast<double>(DensityCalls)
|
|
: 0.0);
|
|
}
|
|
|
|
for (int32 Index = 0; Index < VoxelDensityProfile::BucketCount; ++Index)
|
|
{
|
|
const uint64 Calls = Profile.Calls[Index];
|
|
if (Calls == 0 || Index == static_cast<int32>(VoxelDensityProfile::EBucket::GetDensityAt))
|
|
{
|
|
continue;
|
|
}
|
|
const double TotalUs = static_cast<double>(Profile.WallCycles[Index]) * CycleToMicroseconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeDensityProfile] op=%s calls=%llu total_us=%.3f us_per_call=%.6f"),
|
|
VoxelDensityProfile::BucketName(static_cast<VoxelDensityProfile::EBucket>(Index)),
|
|
static_cast<unsigned long long>(Calls), TotalUs,
|
|
TotalUs / static_cast<double>(Calls));
|
|
}
|
|
|
|
const VoxelDensityProfile::EBucket MesherBuckets[] = {
|
|
VoxelDensityProfile::EBucket::MesherDensityGrid,
|
|
VoxelDensityProfile::EBucket::MesherCellClassification,
|
|
VoxelDensityProfile::EBucket::MesherGradientNormals,
|
|
VoxelDensityProfile::EBucket::MesherVertexInterpolation,
|
|
VoxelDensityProfile::EBucket::MesherStreamBuilding,
|
|
VoxelDensityProfile::EBucket::MesherOther,
|
|
};
|
|
uint64 MesherAttributionCycles = 0;
|
|
uint64 MesherAttributionCalls = 0;
|
|
for (const VoxelDensityProfile::EBucket Bucket : MesherBuckets)
|
|
{
|
|
const int32 BucketIndex = static_cast<int32>(Bucket);
|
|
MesherAttributionCycles += Profile.WallCycles[BucketIndex];
|
|
MesherAttributionCalls += Profile.Calls[BucketIndex];
|
|
}
|
|
const int32 MesherTotalIndex = static_cast<int32>(
|
|
VoxelDensityProfile::EBucket::MesherGenerateMesh);
|
|
const double MesherTotalUs = static_cast<double>(
|
|
Profile.WallCycles[MesherTotalIndex]) * CycleToMicroseconds;
|
|
const double MesherAttributionUs = static_cast<double>(
|
|
MesherAttributionCycles) * CycleToMicroseconds;
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeMesherProfile] total_calls=%llu total_us=%.3f "
|
|
"attribution_sum_us=%.3f unattributed_us=%.3f coverage=%.2f%% "
|
|
"phase_calls=%llu"),
|
|
static_cast<unsigned long long>(Profile.Calls[MesherTotalIndex]),
|
|
MesherTotalUs, MesherAttributionUs, MesherTotalUs - MesherAttributionUs,
|
|
MesherTotalUs > 0.0 ? MesherAttributionUs * 100.0 / MesherTotalUs : 0.0,
|
|
static_cast<unsigned long long>(MesherAttributionCalls));
|
|
VoxelDensityProfile::SetMode(VoxelDensityProfile::EMode::Disabled);
|
|
}
|
|
VoxelDensityOps::ReportSharedRoomGraphCacheFootprint();
|
|
VoxelDensityOps::TrimSharedRoomGraphCache();
|
|
// Per-case isolation boundary. The UObject world dies with this function; worker-local
|
|
// caches are keyed by the fresh generator/manager lifetime and the diagnostics are reset before
|
|
// the next case. No case may inherit profiler counters or a live profiler mode.
|
|
VoxelDensityProfile::Reset();
|
|
VoxelDensityProfile::SetMode(VoxelDensityProfile::EMode::Disabled);
|
|
return bRequestedModeFailed ? 2 : 0;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
FString SanitizeBatchCaseId(const FString& InId, int32 Index)
|
|
{
|
|
FString Result;
|
|
for (const TCHAR Character : InId)
|
|
{
|
|
const bool bAsciiAlphaNumeric =
|
|
(Character >= TEXT('a') && Character <= TEXT('z'))
|
|
|| (Character >= TEXT('A') && Character <= TEXT('Z'))
|
|
|| (Character >= TEXT('0') && Character <= TEXT('9'));
|
|
if (bAsciiAlphaNumeric || Character == TEXT('_') || Character == TEXT('-'))
|
|
{
|
|
Result.AppendChar(Character);
|
|
}
|
|
}
|
|
if (Result.IsEmpty())
|
|
{
|
|
Result = FString::Printf(TEXT("case_%03d"), Index);
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
bool JsonNumber(const TSharedPtr<FJsonObject>& Object, const TCHAR* Key, double& OutValue)
|
|
{
|
|
if (!Object.IsValid() || !Object->HasField(Key))
|
|
{
|
|
return false;
|
|
}
|
|
OutValue = Object->GetNumberField(Key);
|
|
return FMath::IsFinite(OutValue);
|
|
}
|
|
|
|
bool JsonString(const TSharedPtr<FJsonObject>& Object, const TCHAR* Key, FString& OutValue)
|
|
{
|
|
if (!Object.IsValid() || !Object->HasField(Key))
|
|
{
|
|
return false;
|
|
}
|
|
OutValue = Object->GetStringField(Key);
|
|
return true;
|
|
}
|
|
|
|
bool JsonBool(const TSharedPtr<FJsonObject>& Object, const TCHAR* Key, bool& OutValue)
|
|
{
|
|
if (!Object.IsValid() || !Object->HasField(Key))
|
|
{
|
|
return false;
|
|
}
|
|
OutValue = Object->GetBoolField(Key);
|
|
return true;
|
|
}
|
|
|
|
bool BuildBatchCaseParams(
|
|
const TSharedPtr<FJsonObject>& Case,
|
|
const FString& CaseOutDirectory,
|
|
FString& OutParams,
|
|
FString& OutError)
|
|
{
|
|
if (!Case.IsValid())
|
|
{
|
|
OutError = TEXT("Each batch case must be a JSON object.");
|
|
return false;
|
|
}
|
|
|
|
double Number = 0.0;
|
|
int32 Seed = 0;
|
|
if (JsonNumber(Case, TEXT("seed"), Number))
|
|
{
|
|
Seed = FMath::RoundToInt(Number);
|
|
}
|
|
FString Archetype = TEXT("Maze");
|
|
JsonString(Case, TEXT("archetype"), Archetype);
|
|
int32 Slot = 4;
|
|
if (JsonNumber(Case, TEXT("slot"), Number))
|
|
{
|
|
Slot = FMath::RoundToInt(Number);
|
|
}
|
|
int32 ExportSize = DefaultExportSize;
|
|
if (JsonNumber(Case, TEXT("export_size"), Number))
|
|
{
|
|
ExportSize = FMath::RoundToInt(Number);
|
|
}
|
|
int32 ExportStep = DefaultExportStep;
|
|
if (JsonNumber(Case, TEXT("export_step"), Number)
|
|
|| JsonNumber(Case, TEXT("step"), Number))
|
|
{
|
|
ExportStep = FMath::RoundToInt(Number);
|
|
}
|
|
if (JsonNumber(Case, TEXT("lod"), Number))
|
|
{
|
|
const int32 Lod = FMath::RoundToInt(Number);
|
|
if (Lod < 0 || Lod > 3)
|
|
{
|
|
OutError = TEXT("case.lod must be in [0,3].");
|
|
return false;
|
|
}
|
|
ExportStep = 1 << Lod;
|
|
}
|
|
|
|
bool bReuseDensityGrid = true;
|
|
JsonBool(Case, TEXT("density_grid_reuse"), bReuseDensityGrid);
|
|
bool bBlockEarlyOut = false;
|
|
JsonBool(Case, TEXT("block_early_out"), bBlockEarlyOut);
|
|
int32 MeshMinBatchSize = 1;
|
|
if (JsonNumber(Case, TEXT("mesh_min_batch_size"), Number))
|
|
{
|
|
MeshMinBatchSize = FMath::Clamp(FMath::RoundToInt(Number), 1, 64);
|
|
}
|
|
|
|
FString Modes = TEXT("export");
|
|
JsonString(Case, TEXT("modes"), Modes);
|
|
bool bProfileDensity = false;
|
|
JsonBool(Case, TEXT("profile_density"), bProfileDensity);
|
|
bool bProfileDensityFull = false;
|
|
JsonBool(Case, TEXT("profile_density_full"), bProfileDensityFull);
|
|
bProfileDensity |= bProfileDensityFull;
|
|
bool bPerfAttribution = false;
|
|
JsonBool(Case, TEXT("perf_attribution"), bPerfAttribution);
|
|
bool bSampleStacks = false;
|
|
JsonBool(Case, TEXT("sample_stacks"), bSampleStacks);
|
|
int32 SampleStacksIntervalUs = static_cast<int32>(FVoxelStackSampler::DefaultIntervalUs);
|
|
if (JsonNumber(Case, TEXT("sample_stacks_interval_us"), Number))
|
|
{
|
|
SampleStacksIntervalUs = FMath::RoundToInt(Number);
|
|
bSampleStacks = true;
|
|
}
|
|
bool bProfileLod = false;
|
|
JsonBool(Case, TEXT("profile_lod"), bProfileLod);
|
|
bool bOpBounds = false;
|
|
JsonBool(Case, TEXT("op_bounds"), bOpBounds);
|
|
bool bFailureFocus = false;
|
|
JsonBool(Case, TEXT("failure_focus_render"), bFailureFocus);
|
|
bool bTunnelFloorEnabled = true;
|
|
const bool bHasTunnelFloorEnabled = JsonBool(
|
|
Case, TEXT("tunnel_floor_enabled"), bTunnelFloorEnabled);
|
|
bool bTunnelFloorTerracing = true;
|
|
const bool bHasTunnelFloorTerracing = JsonBool(
|
|
Case, TEXT("tunnel_floor_terracing"), bTunnelFloorTerracing);
|
|
|
|
OutParams = FString::Printf(
|
|
TEXT("-seed=%d -archetype=%s -slot=%d -modes=%s "
|
|
"-exportsize=%d -exportstep=%d -densitygridreuse=%d "
|
|
"-blockearlyout=%d -meshminbatch=%d "
|
|
"-failurefocus=%d -out=\"%s\""),
|
|
Seed, *Archetype, Slot, *Modes,
|
|
ExportSize, ExportStep, bReuseDensityGrid ? 1 : 0,
|
|
bBlockEarlyOut ? 1 : 0, MeshMinBatchSize,
|
|
bFailureFocus ? 1 : 0, *CaseOutDirectory);
|
|
|
|
if (bHasTunnelFloorEnabled)
|
|
{
|
|
OutParams += FString::Printf(
|
|
TEXT(" -tunnelfloor=%d"), bTunnelFloorEnabled ? 1 : 0);
|
|
}
|
|
if (bHasTunnelFloorTerracing)
|
|
{
|
|
OutParams += FString::Printf(
|
|
TEXT(" -tunnelfloorterracing=%d"), bTunnelFloorTerracing ? 1 : 0);
|
|
}
|
|
|
|
if (bProfileDensity)
|
|
{
|
|
OutParams += bProfileDensityFull
|
|
? TEXT(" -profiledensityfull")
|
|
: TEXT(" -profiledensity");
|
|
}
|
|
if (bPerfAttribution)
|
|
{
|
|
OutParams += TEXT(" -perfattribution");
|
|
}
|
|
if (bSampleStacks)
|
|
{
|
|
OutParams += FString::Printf(
|
|
TEXT(" -samplestacks=%d"), SampleStacksIntervalUs);
|
|
}
|
|
if (bProfileLod)
|
|
{
|
|
OutParams += TEXT(" -profilelod");
|
|
}
|
|
if (bOpBounds)
|
|
{
|
|
OutParams += TEXT(" -opbounds");
|
|
}
|
|
|
|
const TPair<const TCHAR*, const TCHAR*> IntegerFields[] = {
|
|
{ TEXT("render_width"), TEXT("renderwidth") },
|
|
{ TEXT("render_height"), TEXT("renderheight") },
|
|
{ TEXT("max_walk_cells"), TEXT("maxwalkcells") },
|
|
};
|
|
for (const auto& Field : IntegerFields)
|
|
{
|
|
if (JsonNumber(Case, Field.Key, Number))
|
|
{
|
|
OutParams += FString::Printf(TEXT(" -%s=%d"),
|
|
Field.Value, FMath::RoundToInt(Number));
|
|
}
|
|
}
|
|
const TPair<const TCHAR*, const TCHAR*> FloatFields[] = {
|
|
{ TEXT("render_step"), TEXT("renderstep") },
|
|
{ TEXT("render_max_distance"), TEXT("rendermaxdistance") },
|
|
{ TEXT("surface_roughness"), TEXT("surfaceroughness") },
|
|
{ TEXT("roughness_frequency"), TEXT("roughnessfrequency") },
|
|
{ TEXT("room_mouth_rise_strength"), TEXT("roommouthrise") },
|
|
{ TEXT("room_mouth_rise_blend_voxels"), TEXT("roommouthriseblend") },
|
|
{ TEXT("tunnel_floor_step_height"), TEXT("tunnelfloorstep") },
|
|
{ TEXT("tunnel_floor_max_ledge_height"), TEXT("tunnelfloormaxledge") },
|
|
{ TEXT("tunnel_floor_gentle_slope"), TEXT("tunnelfloorgentle") },
|
|
};
|
|
for (const auto& Field : FloatFields)
|
|
{
|
|
if (JsonNumber(Case, Field.Key, Number))
|
|
{
|
|
OutParams += FString::Printf(TEXT(" -%s=%.9g"), Field.Value, Number);
|
|
}
|
|
}
|
|
|
|
FString RoughnessType;
|
|
if (JsonString(Case, TEXT("roughness_noise_type"), RoughnessType))
|
|
{
|
|
OutParams += FString::Printf(TEXT(" -roughnesstype=%s"), *RoughnessType);
|
|
}
|
|
|
|
const TPair<const TCHAR*, const TCHAR*> IntegerFloorFields[] = {
|
|
{ TEXT("tunnel_floor_ledge_count"), TEXT("tunnelfloorledges") },
|
|
{ TEXT("tunnel_floor_max_ledges"), TEXT("tunnelfloormaxledges") },
|
|
};
|
|
for (const auto& Field : IntegerFloorFields)
|
|
{
|
|
if (JsonNumber(Case, Field.Key, Number))
|
|
{
|
|
OutParams += FString::Printf(TEXT(" -%s=%d"),
|
|
Field.Value, FMath::RoundToInt(Number));
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
int32 RunBatch(const FString& Params)
|
|
{
|
|
FString BatchPath;
|
|
FString OutText;
|
|
if (!FParse::Value(*Params, TEXT("batch="), BatchPath)
|
|
|| !FParse::Value(*Params, TEXT("out="), OutText))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] batch requires -batch=<json> and an absolute -out=<Saved path>."));
|
|
return 1;
|
|
}
|
|
if (FPaths::IsRelative(OutText))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] batch -out must be absolute and under '%s'."),
|
|
*VoxelForgeSavedRoot());
|
|
return 1;
|
|
}
|
|
const FString BatchOutDirectory = FPaths::ConvertRelativePathToFull(OutText);
|
|
if (!IsUnderVoxelForgeSaved(BatchOutDirectory))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] batch -out must be under '%s' (got '%s')."),
|
|
*VoxelForgeSavedRoot(), *BatchOutDirectory);
|
|
return 1;
|
|
}
|
|
if (!IFileManager::Get().MakeDirectory(*BatchOutDirectory, true))
|
|
{
|
|
UE_LOG(LogTemp, Error,
|
|
TEXT("[VoxelForgeExplore] could not create batch output '%s'."),
|
|
*BatchOutDirectory);
|
|
return 1;
|
|
}
|
|
|
|
BatchPath = FPaths::ConvertRelativePathToFull(BatchPath);
|
|
FString BatchText;
|
|
if (!FFileHelper::LoadFileToString(BatchText, *BatchPath))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] could not read batch file '%s'."), *BatchPath);
|
|
return 1;
|
|
}
|
|
TSharedPtr<FJsonObject> Root;
|
|
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(BatchText);
|
|
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] batch file is not valid JSON."));
|
|
return 1;
|
|
}
|
|
const TArray<TSharedPtr<FJsonValue>>* Cases = nullptr;
|
|
if (!Root->TryGetArrayField(TEXT("cases"), Cases) || Cases == nullptr || Cases->Num() == 0)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] batch JSON needs a non-empty 'cases' array."));
|
|
return 1;
|
|
}
|
|
if (Cases->Num() > 256)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] batch is capped at 256 cases."));
|
|
return 1;
|
|
}
|
|
|
|
double BatchBudgetMinutes = 29.0;
|
|
double Number = 0.0;
|
|
if (JsonNumber(Root, TEXT("budget_minutes"), Number))
|
|
{
|
|
BatchBudgetMinutes = Number;
|
|
}
|
|
if (FParse::Value(*Params, TEXT("batchbudget="), Number))
|
|
{
|
|
BatchBudgetMinutes = Number;
|
|
}
|
|
BatchBudgetMinutes = FMath::Clamp(BatchBudgetMinutes, 0.01, 30.0);
|
|
const double BatchStartSeconds = FPlatformTime::Seconds();
|
|
bool bTruncated = false;
|
|
int32 CompletedCases = 0;
|
|
int32 FailedCases = 0;
|
|
TSet<FString> UsedCaseIds;
|
|
TArray<TSharedPtr<FJsonValue>> CaseReports;
|
|
CaseReports.Reserve(Cases->Num());
|
|
|
|
for (int32 Index = 0; Index < Cases->Num(); ++Index)
|
|
{
|
|
const double RemainingSeconds = BatchBudgetMinutes * 60.0
|
|
- (FPlatformTime::Seconds() - BatchStartSeconds);
|
|
if (RemainingSeconds <= 0.25)
|
|
{
|
|
bTruncated = true;
|
|
break;
|
|
}
|
|
const TSharedPtr<FJsonObject> Case = (*Cases)[Index]->AsObject();
|
|
FString CaseId;
|
|
JsonString(Case, TEXT("id"), CaseId);
|
|
CaseId = SanitizeBatchCaseId(CaseId, Index);
|
|
const FString BaseCaseId = CaseId;
|
|
int32 Suffix = 1;
|
|
while (UsedCaseIds.Contains(CaseId))
|
|
{
|
|
CaseId = FString::Printf(TEXT("%s_%d"), *BaseCaseId, Suffix++);
|
|
}
|
|
UsedCaseIds.Add(CaseId);
|
|
FString CaseOutDirectory = FPaths::Combine(BatchOutDirectory, TEXT("cases"), CaseId);
|
|
FString CaseParams;
|
|
FString CaseError;
|
|
if (!BuildBatchCaseParams(Case, CaseOutDirectory, CaseParams, CaseError))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] batch case %d (%s): %s"),
|
|
Index, *CaseId, *CaseError);
|
|
TSharedPtr<FJsonObject> CaseReport = MakeShared<FJsonObject>();
|
|
CaseReport->SetStringField(TEXT("case_id"), CaseId);
|
|
CaseReport->SetNumberField(TEXT("case_index"), Index);
|
|
CaseReport->SetNumberField(TEXT("case_exit_code"), 1);
|
|
CaseReport->SetStringField(TEXT("status"), TEXT("invalid"));
|
|
CaseReport->SetStringField(TEXT("error"), CaseError);
|
|
CaseReports.Add(MakeShared<FJsonValueObject>(CaseReport));
|
|
++CompletedCases;
|
|
++FailedCases;
|
|
continue;
|
|
}
|
|
|
|
double CaseBudgetMinutes = FMath::Min(25.0, RemainingSeconds / 60.0);
|
|
if (JsonNumber(Case, TEXT("budget_minutes"), Number))
|
|
{
|
|
CaseBudgetMinutes = FMath::Min(CaseBudgetMinutes, Number);
|
|
}
|
|
CaseBudgetMinutes = FMath::Clamp(CaseBudgetMinutes, 0.01, 30.0);
|
|
CaseParams += FString::Printf(TEXT(" -budget=%.9g"), CaseBudgetMinutes);
|
|
|
|
FString CaseJson;
|
|
const int32 CaseResult = RunExploreCase(CaseParams, &CaseJson);
|
|
TSharedPtr<FJsonObject> CaseReport;
|
|
const TSharedRef<TJsonReader<>> CaseReader = TJsonReaderFactory<>::Create(CaseJson);
|
|
if (!FJsonSerializer::Deserialize(CaseReader, CaseReport) || !CaseReport.IsValid())
|
|
{
|
|
CaseReport = MakeShared<FJsonObject>();
|
|
CaseReport->SetStringField(TEXT("error"), TEXT("case did not produce a JSON report"));
|
|
}
|
|
CaseReport->SetStringField(TEXT("case_id"), CaseId);
|
|
CaseReport->SetNumberField(TEXT("case_index"), Index);
|
|
CaseReport->SetNumberField(TEXT("case_exit_code"), CaseResult);
|
|
CaseReports.Add(MakeShared<FJsonValueObject>(CaseReport));
|
|
++CompletedCases;
|
|
if (CaseResult != 0)
|
|
{
|
|
++FailedCases;
|
|
}
|
|
|
|
// RunExploreCase owns a complete transient UObject world. Release that case's strong
|
|
// roots before starting the next one and collect now; otherwise a batch retains every
|
|
// completed mesh/settings/manager graph until the commandlet exits, making peak RSS grow
|
|
// with the number of cases even though no report needs those objects afterward.
|
|
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
|
}
|
|
|
|
TSharedPtr<FJsonObject> BatchReport = MakeShared<FJsonObject>();
|
|
BatchReport->SetNumberField(TEXT("schema_version"), 2);
|
|
BatchReport->SetStringField(TEXT("tool"), TEXT("VoxelForgeExplore"));
|
|
BatchReport->SetBoolField(TEXT("batch"), true);
|
|
BatchReport->SetStringField(TEXT("input_file"), BatchPath);
|
|
BatchReport->SetStringField(TEXT("output_directory"), BatchOutDirectory);
|
|
BatchReport->SetNumberField(TEXT("budget_minutes"), BatchBudgetMinutes);
|
|
BatchReport->SetNumberField(TEXT("elapsed_seconds"), FPlatformTime::Seconds() - BatchStartSeconds);
|
|
BatchReport->SetBoolField(TEXT("truncated"), bTruncated);
|
|
BatchReport->SetNumberField(TEXT("completed_cases"), CompletedCases);
|
|
BatchReport->SetNumberField(TEXT("failed_cases"), FailedCases);
|
|
BatchReport->SetArrayField(TEXT("cases"), MoveTemp(CaseReports));
|
|
|
|
FString BatchJson;
|
|
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&BatchJson);
|
|
if (!FJsonSerializer::Serialize(BatchReport.ToSharedRef(), Writer)
|
|
|| !FFileHelper::SaveStringToFile(
|
|
BatchJson,
|
|
*FPaths::Combine(BatchOutDirectory, TEXT("batch.json")),
|
|
FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] could not write batch.json."));
|
|
return 1;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] batch completed=%d failed=%d truncated=%s report=%s"),
|
|
CompletedCases, FailedCases, bTruncated ? TEXT("yes") : TEXT("no"),
|
|
*FPaths::Combine(BatchOutDirectory, TEXT("batch.json")));
|
|
return FailedCases > 0 || bTruncated ? 2 : 0;
|
|
}
|
|
}
|
|
|
|
int32 UVoxelForgeExploreCommandlet::Main(const FString& Params)
|
|
{
|
|
if (FParse::Param(*Params, TEXT("coreassetupgradeaudit")))
|
|
{
|
|
// Load the untouched BuildHost copies and emit their in-memory conversions; never save a package.
|
|
// Charge les copies BuildHost intactes et affiche les conversions en memoire, sans sauver de package.
|
|
UVoxelSettings* SettingsAsset = LoadObject<UVoxelSettings>(nullptr,
|
|
TEXT("/Game/VoxelForge/DA_Settings.DA_Settings"));
|
|
if (SettingsAsset == nullptr)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeAssetMigrationAudit] missing DA_Settings copy"));
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeAssetMigrationAudit] asset=%s old_gap_chunks=%d new_gap_m=%.6f layout_chunk_m=%.6f"),
|
|
*SettingsAsset->GetPathName(), SettingsAsset->InterStrateGapChunks,
|
|
SettingsAsset->InterStrateGapMeters, SettingsAsset->StrateLayoutChunkSizeMeters);
|
|
|
|
const TCHAR* StratePaths[] = {
|
|
TEXT("/Game/VoxelForge/DA_Strate1.DA_Strate1"),
|
|
TEXT("/Game/VoxelForge/DA_Strate2.DA_Strate2"),
|
|
TEXT("/Game/VoxelForge/DA_Strate3.DA_Strate3"),
|
|
TEXT("/Game/VoxelForge/DA_Strate4.DA_Strate4")
|
|
};
|
|
for (const TCHAR* Path : StratePaths)
|
|
{
|
|
UVoxelStrateDefinition* Definition = LoadObject<UVoxelStrateDefinition>(nullptr, Path);
|
|
if (Definition == nullptr)
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeAssetMigrationAudit] missing strate copy %s"), Path);
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeAssetMigrationAudit] asset=%s old_height_chunks=%d new_height_m=%.6f old_transition_chunks=%d new_transition_m=%.6f"),
|
|
*Definition->GetPathName(), Definition->StrateHeightInChunks,
|
|
Definition->StrateHeightMeters, Definition->TransitionBlendChunks,
|
|
Definition->TransitionWidthMeters);
|
|
}
|
|
return 0;
|
|
}
|
|
if (FParse::Param(*Params, TEXT("step2f")))
|
|
{
|
|
return RunStep2f(Params);
|
|
}
|
|
if (FParse::Param(*Params, TEXT("step3scale")))
|
|
{
|
|
return RunStep3ScaleBenchmark(Params);
|
|
}
|
|
if (FParse::Param(*Params, TEXT("step2e")))
|
|
{
|
|
return RunStep2e(Params);
|
|
}
|
|
if (FParse::Param(*Params, TEXT("step2d")))
|
|
{
|
|
return RunStep2d(Params);
|
|
}
|
|
const bool bRegionEvalSwitch = FParse::Param(*Params, TEXT("regioneval"));
|
|
int32 RegionEvalFlag = 0;
|
|
const bool bRegionEvalValue = FParse::Value(*Params, TEXT("regioneval="), RegionEvalFlag);
|
|
if (bRegionEvalSwitch || (bRegionEvalValue && RegionEvalFlag != 0))
|
|
{
|
|
return RunRegionEval(Params);
|
|
}
|
|
|
|
FString SymbolizeCsvPath;
|
|
if (FParse::Value(*Params, TEXT("symbolizestacks="), SymbolizeCsvPath))
|
|
{
|
|
FString SymbolizedOutputPath;
|
|
FString SymbolPath;
|
|
FParse::Value(*Params, TEXT("symbolizedout="), SymbolizedOutputPath);
|
|
FParse::Value(*Params, TEXT("symbolpath="), SymbolPath);
|
|
|
|
FString SummaryPath;
|
|
FString Error;
|
|
if (!VoxelForgeOfflineStackSymbolizer::Run(
|
|
SymbolizeCsvPath, SymbolizedOutputPath, SymbolPath, SummaryPath, Error))
|
|
{
|
|
UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] offline stack symbolization failed: %s"),
|
|
*Error);
|
|
return 2;
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore] offline stack symbolization completed summary=%s"),
|
|
*SummaryPath);
|
|
return 0;
|
|
}
|
|
|
|
FString BatchPath;
|
|
if (FParse::Value(*Params, TEXT("batch="), BatchPath))
|
|
{
|
|
return RunBatch(Params);
|
|
}
|
|
return RunExploreCase(Params, nullptr);
|
|
}
|