Editor-only brick API (<= 8^3 points per call, TunnelNetwork/Underwater op-stack routes, 1.16% scalar fallback). Exact: 0 Q16/sign disagreements on the full 259^3 ring and the five-seed probe set, same hash on 1 and 6 workers, reach shrink still caught (18,107 Q16 / 735 sign). Speed: 69.19 s wall, 17.6 us/sample vs live 7.04 s, 1.65 us/sample. Stack sampler (inclusive): BuildChunkCache 93.5%, PropagateSdfOverBox 92.5%, BuildActiveOpList 92.6% -- each brick rebuilds the cache windows and active-op lists; EvalBlock itself is 3.6%. Live path attribution (inclusive, 68,934 stacks): op-stack RoomGraph EvalBlock 54.4%, SDF and spatial candidate scans ~28%, tunnel core 18.4%, passage floor segment 15.0%, structural posts 14.6%, noise octaves 2.6%, disturbances 0.6%, diff layer 0.3%. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
9566 lines
423 KiB
C++
9566 lines
423 KiB
C++
#include "VoxelForgeExploreCommandlet.h"
|
||
#include "VoxelStackOfflineSymbolizer.h"
|
||
#include "VoxelForgeRegionEvalExperiment.h"
|
||
|
||
#include "CoreMinimal.h"
|
||
#include "Async/ParallelFor.h"
|
||
#include "Commandlets/Commandlet.h"
|
||
#include "Containers/StringConv.h"
|
||
#include "HAL/FileManager.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/UObjectGlobals.h"
|
||
#include "VoxelCaveMorphology.h"
|
||
#include "VoxelDiffLayer.h"
|
||
#include "VoxelDensityOpStack.h"
|
||
#include "VoxelDensityAblation.h"
|
||
#include "VoxelDensityProfile.h"
|
||
#include "VoxelGenerator.h"
|
||
#include "VoxelGeometryHash.h"
|
||
#include "VoxelMarchingCubesMesher.h"
|
||
#include "VoxelNoise.h"
|
||
#include "VoxelPassageGeometry.h"
|
||
#include "VoxelSettings.h"
|
||
#include "VoxelStackSampler.h"
|
||
#include "VoxelStrateDefinition.h"
|
||
#include "VoxelStrateManager.h"
|
||
#include "VoxelStrateMeasure.h"
|
||
#include "VoxelStrateComposer.h"
|
||
#include "VoxelTypes.h"
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <limits>
|
||
|
||
namespace
|
||
{
|
||
using FExploreJsonWriter = TJsonWriter<TCHAR, TCondensedJsonPrintPolicy<TCHAR>>;
|
||
|
||
constexpr int32 DefaultRenderWidth = 512;
|
||
constexpr int32 DefaultRenderHeight = 288;
|
||
constexpr float DefaultRenderStepVoxels = 0.25f;
|
||
constexpr float DefaultRenderMaxDistanceVoxels = 160.0f;
|
||
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;
|
||
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 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);
|
||
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("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;
|
||
}
|
||
|
||
// The historical explorer contract needs two adjacent mouths around an interior slot. A
|
||
// parity export can instead target the game's authored single-strate layout, which has no
|
||
// such mouths and is valid for export-only mode. Build() still validates the slot against
|
||
// the loaded settings layout.
|
||
const int32 MinimumSlot = OutArguments.bGameRuntimeConfig ? 0 : 1;
|
||
const int32 MaximumSlot = OutArguments.bGameRuntimeConfig
|
||
? 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 an interior slot in [1,%d] so arrival and departure mouths exist."),
|
||
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 (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;
|
||
}
|
||
};
|
||
|
||
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));
|
||
}
|
||
|
||
const int32 NumStrates = 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 = 64.0f;
|
||
Settings->OriginSpineRadius = 14.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 (bUseAuthoredStrate)
|
||
{
|
||
// The owner’s snapshot states that every slot uses DA_Strate3. Clone it per slot so
|
||
// commandlet overrides never mutate a loaded package or leak between slots.
|
||
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:
|
||
// 4 chunks * 32 voxels * 0.25 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 = 4;
|
||
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;
|
||
}
|
||
};
|
||
|
||
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 bBrickProgram,
|
||
float ReachScale,
|
||
bool bFullRing,
|
||
const TArray<int64>* RequestedIndices,
|
||
int32 RequestedSamples,
|
||
bool bCountCandidates,
|
||
bool bKeepDensities,
|
||
FRegionEvalPass& OutPass,
|
||
FString& OutError,
|
||
int32 RequestedWorkerCount = VoxelForgeRegionEval::WorkerCount)
|
||
{
|
||
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, bBrickProgram, RequestedWorkerCount);
|
||
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.bBrickProgram
|
||
? TEXT("editor_region_brick_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_brick_points"), static_cast<double>(Pass.Timing.EvaluatedSamples));
|
||
Json->SetNumberField(TEXT("brick_count"), static_cast<double>(Pass.Timing.BrickCount));
|
||
Json->SetNumberField(TEXT("fallback_points"), static_cast<double>(Pass.Timing.FallbackSamples));
|
||
Json->SetNumberField(TEXT("fallback_share"), Pass.Timing.FallbackShare);
|
||
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("candidate_visits_per_sample"), Pass.Visits.PerSample);
|
||
Json->SetNumberField(TEXT("room_candidate_visits"), static_cast<double>(Pass.Visits.Room));
|
||
Json->SetNumberField(TEXT("tunnel_candidate_visits"), static_cast<double>(Pass.Visits.Tunnel));
|
||
Json->SetNumberField(TEXT("tunnel_core_candidate_visits"), static_cast<double>(Pass.Visits.TunnelCore));
|
||
Json->SetNumberField(TEXT("passage_candidate_visits"), static_cast<double>(Pass.Visits.Passage));
|
||
Json->SetNumberField(TEXT("tunnel_cache_lookups"), static_cast<double>(Pass.Visits.TunnelCacheLookups));
|
||
Json->SetNumberField(TEXT("tunnel_cache_hits"), static_cast<double>(Pass.Visits.TunnelCacheHits));
|
||
Json->SetNumberField(TEXT("tunnel_cache_misses"), static_cast<double>(Pass.Visits.TunnelCacheMisses));
|
||
Json->SetNumberField(TEXT("sdf_cache_builds"), static_cast<double>(Pass.Visits.SdfCacheBuilds));
|
||
Json->SetNumberField(TEXT("candidate_counter_samples"), static_cast<double>(Pass.Visits.SampleCount));
|
||
Json->SetNumberField(TEXT("attribution_sample_interval"), Pass.Visits.AttributionSampleInterval);
|
||
Json->SetStringField(TEXT("attribution_basis"), TEXT("sampled VoxelDensityProfile phases; diagnostic only, excluded from clean timing"));
|
||
Json->SetNumberField(TEXT("get_density_at_attributed_ns_per_sample"), Pass.Visits.GetDensityAtAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("density_prologue_attributed_ns_per_sample"), Pass.Visits.DensityPrologueAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("density_core_attributed_ns_per_sample"), Pass.Visits.DensityCoreAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("density_disturbances_attributed_ns_per_sample"), Pass.Visits.DensityDisturbancesAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("density_structural_posts_attributed_ns_per_sample"), Pass.Visits.DensityStructuralPostsAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("density_boundary_seal_attributed_ns_per_sample"), Pass.Visits.DensityBoundarySealAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("density_diff_layer_attributed_ns_per_sample"), Pass.Visits.DensityDiffLayerAttributedNsPerSample);
|
||
Json->SetNumberField(TEXT("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("brick_count"), WorkerHashPass.Timing.BrickCount);
|
||
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;
|
||
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;
|
||
|
||
// 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 BrickStackSamplerSession;
|
||
if (!BrickStackSamplerSession.Start(
|
||
Arguments.SampleStacksIntervalUs, Arguments.OutDirectory,
|
||
TEXT("region_eval_brick_ring")))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeRegionEval] brick stack sampler could not be started."));
|
||
return 2;
|
||
}
|
||
FRegionEvalPass ProfiledBrickPass;
|
||
if (!RunRegionEvalPass(
|
||
Arguments, Seeds[0], true, true, 1.0f, true, nullptr,
|
||
RequestedSamples, false, false, ProfiledBrickPass, Error))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeRegionEval] brick attribution run failed: %s"), *Error);
|
||
return 2;
|
||
}
|
||
FVoxelStackSampler::FSummary BrickStackSummary;
|
||
if (!BrickStackSamplerSession.Stop(BrickStackSummary))
|
||
{
|
||
UE_LOG(LogTemp, Error,
|
||
TEXT("[VoxelForgeRegionEval] brick stack capture retained no worker samples."));
|
||
return 2;
|
||
}
|
||
UE_LOG(LogTemp, Display,
|
||
TEXT("[VoxelForgeRegionEval] brick stack capture registered=%llu retained=%llu "
|
||
"raw=%s summary=%s"),
|
||
static_cast<unsigned long long>(BrickStackSummary.RegisteredThreadSamples),
|
||
static_cast<unsigned long long>(BrickStackSummary.RetainedSamples),
|
||
*BrickStackSummary.RawSamplesPath, *BrickStackSummary.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"), 1);
|
||
Report->SetStringField(TEXT("route"), TEXT("editor-only explore commandlet; required Unreal composer, manager, and generator types"));
|
||
Report->SetStringField(TEXT("evaluator"), TEXT("editor-only 8^3 region brick program; block-evaluated composer operator core and canonical per-point final-field writers; unsupported routes use counted live fallbacks"));
|
||
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->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 stay in canonical GetDensityAt tail; this is the candidate-bin ablation, not the full region compiler"));
|
||
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;
|
||
}
|
||
|
||
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, 0.25f);
|
||
Writer->WriteObjectStart(TEXT("region_bounds_metres"));
|
||
Writer->WriteObjectStart(TEXT("min"));
|
||
Writer->WriteValue(TEXT("x"), static_cast<double>(Export.RegionOrigin.X) * 0.25);
|
||
Writer->WriteValue(TEXT("y"), static_cast<double>(Export.RegionOrigin.Y) * 0.25);
|
||
Writer->WriteValue(TEXT("z"), static_cast<double>(Export.RegionOrigin.Z) * 0.25);
|
||
Writer->WriteObjectEnd();
|
||
Writer->WriteObjectStart(TEXT("max_exclusive"));
|
||
Writer->WriteValue(TEXT("x"), static_cast<double>(Export.RegionOrigin.X + Export.RegionSize) * 0.25);
|
||
Writer->WriteValue(TEXT("y"), static_cast<double>(Export.RegionOrigin.Y + Export.RegionSize) * 0.25);
|
||
Writer->WriteValue(TEXT("z"), static_cast<double>(Export.RegionOrigin.Z + Export.RegionSize) * 0.25);
|
||
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 * 25.0f,
|
||
Axis->FinalPeriodicPeriodVoxels,
|
||
Axis->FinalPeriodicMeasuredLengthVoxels, Axis->FinalPeriodicMeasuredSamples,
|
||
Axis->ValidCoreCrossings, Axis->CorePeriodicAmplitudeVoxels,
|
||
Axis->CorePeriodicAmplitudeVoxels * 25.0f,
|
||
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 * 25.0f,
|
||
Mouth.MaxFloorPeriodicAmplitudeVoxels,
|
||
Mouth.MaxFloorPeriodicAmplitudeVoxels * 25.0f,
|
||
Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels,
|
||
Mouth.MaxLocalMedianFloorPeriodicAmplitudeVoxels * 25.0f,
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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) * 25.0);
|
||
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 * 25.0f <= 2.0f
|
||
&& Output.TunnelCoreProbe.Mouth.MaxFloorPeriodicAmplitudeVoxels * 25.0f <= 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"), 0.25);
|
||
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) * 0.25);
|
||
Writer->WriteValue(TEXT("target_top_m_exclusive"), static_cast<double>(World.TargetTopWorldZ) * 0.25);
|
||
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, 0.25f);
|
||
}
|
||
if (Output.Walk.bHasDeparture)
|
||
{
|
||
WriteJsonVector(*Writer, TEXT("departure_mouth_voxels"), Output.Walk.DepartureVoxels, 1.0f);
|
||
WriteJsonVector(*Writer, TEXT("departure_mouth_metres"), Output.Walk.DepartureVoxels, 0.25f);
|
||
}
|
||
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, 0.25f);
|
||
}
|
||
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, 0.25f);
|
||
}
|
||
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, 0.25f);
|
||
}
|
||
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, 0.25f);
|
||
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;
|
||
}
|
||
|
||
} // 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;
|
||
}
|
||
|
||
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)
|
||
{
|
||
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);
|
||
}
|