TunnelNetwork density evaluation was 45x more expensive than Maze for identical work: 2,097,152 voxels, 64 tiles, same mesher, same machine. Profiling killed every hypothesis I brought to it -- box classification skipping 0/64 tiles was true but irrelevant (export bypasses classification), near-surface early-outs fired at 49.7%, the room cache built 340 entries for 2.74M samples, and the per-room overrides resolved once per sample rather than once per modifier. The cause was the wrapper, not the stack. A mesher tile samples an EXPANDED grid (-1..Cells+1), so one worker crosses several exact chunk coordinates while meshing a single tile. The generator's cache held only the last coordinate, so every boundary crossing rebuilt the resolved parameters, the whole 22-operator stack and the tunnel-core cache. On one representative tile: 3,675 refetches, 38.0s. The entire operator stack was 18.5s of a 4,272s total -- 0.43%. Replaced with a 128-slot direct-mapped thread_local cache keyed on owner, chunk, layout version, params fingerprint and generator type. Collisions force a safe rebuild, never a stale read. No shared mutable state, no order-dependent reduction. TunnelNetwork 344.5s / 164.267 us/vox -> 18.4s / 8.763 us/vox (18.7x) Maze 7.4s / 3.535 us/vox -> 1.08s / 0.513 us/vox ( 6.9x) same tile 38.0s -> 0.51s ; refetches 3675 -> 27 Output is BYTE-IDENTICAL, before vs after and across repeats, both archetypes: TunnelNetwork 50d6b4d0..f23126, Maze 364f7489..2fbe99. The permission to change the field was not needed. Also in this commit, from the two preceding tasks: - Explorer renders from the MESH instead of raymarching. It meshes the region once through the canonical UVoxelMarchingCubesMesher, then CPU-rasterises via a BVH. A viewpoint went from ~16 minutes to 0.155s; 8 views now cost 1.25s of drawing. No render pixel samples the density field. - One sampled grid and player-fit mask per run, shared by render/walk/export, instead of the same 368s walk computed twice. - A wall-clock budget (25 min default, hard 30 max) that stops cleanly and records truncated/truncated_during/completed_modes rather than overshooting. - ParallelFor across the measurement sampling; the suite was ~10% of a 16-thread machine. - -surfaceroughness= on the commandlet, and a surface-relief metric in export mode (edge lengths, dihedral distribution, face-vs-smooth normal divergence) so "does this look like rock" is a number, not a screenshot argument. - Smooth vertex normals in the rasteriser: FVoxelMeshData::Normals is the density-gradient normal the engine shades with, and the raster path was recomputing a face normal over it. - ScaleDiagnosis: baked candidate indices are now derived from the current corpus, so a legitimate field change stops breaking the test. NOT verified, and not claimed: - TunnelNetworkSpineEquivalence exceeded the 30-minute cap and was stopped. Inconclusive, not passing. The identical hashes are strong evidence the op-stack/legacy equivalence holds, but the test has not confirmed it. - CrossPlatformDigest passes but reports 2 samples within 1e-6 of the isosurface; FMath::Sin/Cos leaves Windows/Linux equivalence unproven. Pre-existing, not caused by this change. - The cache's memory footprint (128 entries x ~16 workers) is unmeasured. What the roughness sweep settled, for the record: SurfaceRoughness 0 -> 8 moves cost by 0.3% (163.2 / 163.2 / 163.7 us), so roughness was never the cost. It does reach the surface (dihedral p50 1.1 -> 2.2 -> 5.9), which kills my claim that the density-channel operator contributed nothing. TunnelNetwork's hard seams are present at roughness 0 (4.4% of edges past 40 deg vs Maze's 1.0%), so they are structural -- in how primitives are unioned -- not a roughness deficiency. More roughness makes them worse, not better. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
4049 lines
163 KiB
C++
4049 lines
163 KiB
C++
#include "VoxelForgeExploreCommandlet.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 "IImageWrapper.h"
|
|
#include "IImageWrapperModule.h"
|
|
#include "Misc/CommandLine.h"
|
|
#include "Misc/Crc.h"
|
|
#include "Misc/FileHelper.h"
|
|
#include "Misc/Parse.h"
|
|
#include "Misc/Paths.h"
|
|
#include "Modules/ModuleManager.h"
|
|
#include "Policies/CondensedJsonPrintPolicy.h"
|
|
#include "Serialization/Archive.h"
|
|
#include "Serialization/JsonWriter.h"
|
|
#include "UObject/Package.h"
|
|
#include "UObject/StrongObjectPtr.h"
|
|
#include "UObject/UObjectGlobals.h"
|
|
#include "VoxelDiffLayer.h"
|
|
#include "VoxelGenerator.h"
|
|
#include "VoxelMarchingCubesMesher.h"
|
|
#include "VoxelNoise.h"
|
|
#include "VoxelSettings.h"
|
|
#include "VoxelStrateDefinition.h"
|
|
#include "VoxelStrateManager.h"
|
|
#include "VoxelStrateMeasure.h"
|
|
#include "VoxelTypes.h"
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
|
|
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 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;
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
ECaveGeneratorType Archetype = ECaveGeneratorType::Maze;
|
|
int32 Slot = 4;
|
|
bool bRender = true;
|
|
bool bWalk = true;
|
|
bool bExport = true;
|
|
bool bFailureFocusRender = false;
|
|
FString OutDirectory;
|
|
|
|
int32 RenderWidth = DefaultRenderWidth;
|
|
int32 RenderHeight = DefaultRenderHeight;
|
|
float RenderStepVoxels = DefaultRenderStepVoxels;
|
|
float RenderMaxDistanceVoxels = DefaultRenderMaxDistanceVoxels;
|
|
int32 ExportSize = DefaultExportSize;
|
|
int32 MaxWalkCells = DefaultMaxWalkCells;
|
|
float BudgetMinutes = DefaultBudgetMinutes;
|
|
bool bSurfaceRoughnessOverride = false;
|
|
float SurfaceRoughness = 0.0f;
|
|
|
|
FString CanonicalModes() const
|
|
{
|
|
FString Result;
|
|
if (bRender) Result += TEXT("render,");
|
|
if (bWalk) Result += TEXT("walk,");
|
|
if (bExport) Result += TEXT("export,");
|
|
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;
|
|
|
|
FParse::Value(*Params, TEXT("seed="), OutArguments.Seed);
|
|
FParse::Value(*Params, TEXT("archetype="), ArchetypeText);
|
|
FParse::Value(*Params, TEXT("slot="), OutArguments.Slot);
|
|
FParse::Value(*Params, TEXT("modes="), ModesText);
|
|
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("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;
|
|
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("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, or all."), *Mode);
|
|
return false;
|
|
}
|
|
}
|
|
if (!OutArguments.bRender && !OutArguments.bWalk && !OutArguments.bExport)
|
|
{
|
|
OutError = TEXT("At least one mode must be selected.");
|
|
return false;
|
|
}
|
|
|
|
if (OutArguments.Slot < 1 || OutArguments.Slot > MaxSyntheticStrates - 2)
|
|
{
|
|
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 no longer budgets 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.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.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 (OutText.IsEmpty())
|
|
{
|
|
OutText = FString::Printf(
|
|
TEXT("Saved/VoxelForge/Explore/seed%d_%s_slot%d"),
|
|
OutArguments.Seed,
|
|
ArchetypeName(OutArguments.Archetype),
|
|
OutArguments.Slot);
|
|
}
|
|
if (FPaths::IsRelative(OutText))
|
|
{
|
|
OutText = FPaths::Combine(FPaths::ProjectDir(), OutText);
|
|
}
|
|
OutArguments.OutDirectory = FPaths::ConvertRelativePathToFull(OutText);
|
|
FPaths::NormalizeDirectoryName(OutArguments.OutDirectory);
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
FIntVector ExploreMeshOrigin = FIntVector::ZeroValue;
|
|
int32 ExploreMeshSize = 0;
|
|
int32 ExploreMeshTileCount = 0;
|
|
int32 ExploreMeshTilesCompleted = 0;
|
|
double ExploreMeshSeconds = 0.0;
|
|
bool bExploreMeshBuilt = false;
|
|
bool bExploreMeshComplete = false;
|
|
FString ExploreMeshError;
|
|
|
|
// 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 int32 NumStrates = Arguments.Slot + 2;
|
|
if (NumStrates > MaxSyntheticStrates)
|
|
{
|
|
OutError = TEXT("The synthetic layout would exceed the commandlet strate cap.");
|
|
return false;
|
|
}
|
|
|
|
Settings = TStrongObjectPtr<UVoxelSettings>(
|
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
|
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;
|
|
|
|
Definitions.Reserve(NumStrates);
|
|
for (int32 Index = 0; Index < NumStrates; ++Index)
|
|
{
|
|
UVoxelStrateDefinition* Definition = NewObject<UVoxelStrateDefinition>(
|
|
GetTransientPackage(), NAME_None, RF_Transient);
|
|
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;
|
|
// Exercise the production operator-stack opt-in where it is implemented (Maze),
|
|
// while the runtime switch safely falls back for other archetypes.
|
|
Definition->bUseOperatorStack = true;
|
|
|
|
const TSoftObjectPtr<UVoxelStrateDefinition> SoftDefinition(Definition);
|
|
Settings->FixedStrates.Add(Index, SoftDefinition);
|
|
Settings->StratePool.Add(SoftDefinition);
|
|
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Definition));
|
|
}
|
|
|
|
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 (!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;
|
|
}
|
|
if (Arguments.bSurfaceRoughnessOverride)
|
|
{
|
|
// This is deliberately applied after layout construction and only to the target slot's
|
|
// resolved definition. Passage placement and every neighbouring strate retain their
|
|
// authored/default values; only the sampled target field gets the experiment knob.
|
|
switch (Arguments.Archetype)
|
|
{
|
|
case ECaveGeneratorType::FlatPlain:
|
|
case ECaveGeneratorType::CrystalChamber:
|
|
Target.Definition->SlabParams.FloorRoughness = Arguments.SurfaceRoughness;
|
|
Target.Definition->SlabParams.CeilingRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::Maze:
|
|
Target.Definition->MazeParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::SurfaceWorld:
|
|
Target.Definition->SurfaceParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::VerticalShafts:
|
|
Target.Definition->VerticalShaftParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::FloatingIslands:
|
|
Target.Definition->FloatingIslandParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
case ECaveGeneratorType::TunnelNetwork:
|
|
case ECaveGeneratorType::Underwater:
|
|
default:
|
|
Target.Definition->GenerationParams.SurfaceRoughness = Arguments.SurfaceRoughness;
|
|
break;
|
|
}
|
|
}
|
|
TargetBottomWorldZ = Target.BottomChunkZ * CHUNK_SIZE;
|
|
TargetTopWorldZ = (Target.TopChunkZ + 1) * CHUNK_SIZE;
|
|
TargetBoundarySealThickness = BoundarySealForArchetype(
|
|
*Target.Definition, Arguments.Archetype);
|
|
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());
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
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;
|
|
bool bTruncated = false;
|
|
};
|
|
|
|
struct FExploreRunOutput
|
|
{
|
|
FExploreRenderOutput Render;
|
|
FExploreWalkOutput Walk;
|
|
FExploreExportOutput Export;
|
|
double BudgetSeconds = 0.0;
|
|
double ElapsedSeconds = 0.0;
|
|
bool bTruncated = false;
|
|
FString TruncatedDuring;
|
|
TArray<FString> CompletedModes;
|
|
};
|
|
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
bool EnsureExploreMesh(
|
|
const FExploreArguments& Arguments,
|
|
FExploreWorld& World,
|
|
const FExploreWalkOutput* CameraSeedWalk,
|
|
FExploreBudget& Budget,
|
|
FString& OutError)
|
|
{
|
|
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.ExploreMeshOrigin = ChooseExploreMeshOrigin(
|
|
Arguments,
|
|
CameraSeedWalk,
|
|
World.ExploreMeshSize,
|
|
FMath::FloorToInt(0.5f * static_cast<float>(
|
|
World.TargetBottomWorldZ + World.TargetTopWorldZ)));
|
|
World.ExploreMeshSeconds = 0.0;
|
|
|
|
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. Skirts are presentation geometry for LOD seams and would extend outside the
|
|
// bounded explorer region, so the shared mesh uses the export setting.
|
|
World.Mesher->bGenerateSkirts = false;
|
|
|
|
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;
|
|
}
|
|
|
|
// 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);
|
|
std::atomic<bool> bTileWorkCancelled(false);
|
|
const double TileWorkDeadline = Budget.StartSeconds + Budget.LimitSeconds - 0.25;
|
|
ParallelFor(
|
|
World.ExploreMeshTileCount,
|
|
[&](int32 LinearTileIndex)
|
|
{
|
|
if (bTileWorkCancelled.load(std::memory_order_relaxed)
|
|
|| FPlatformTime::Seconds() >= TileWorkDeadline)
|
|
{
|
|
bTileWorkCancelled.store(true, std::memory_order_relaxed);
|
|
return;
|
|
}
|
|
|
|
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,
|
|
1,
|
|
CHUNK_SIZE);
|
|
|
|
FString TileError;
|
|
if (!ValidateCanonicalTile(TileMeshes[LinearTileIndex], TileError))
|
|
{
|
|
TileErrors[LinearTileIndex] = MoveTemp(TileError);
|
|
bTileWorkCancelled.store(true, std::memory_order_relaxed);
|
|
return;
|
|
}
|
|
TileCompleted[LinearTileIndex] = 1;
|
|
});
|
|
|
|
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.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;
|
|
}
|
|
|
|
World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds;
|
|
World.bExploreMeshComplete = true;
|
|
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));
|
|
};
|
|
|
|
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 (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;
|
|
|
|
// Temporary seed-14 probe while diagnosing the fitted VerticalShafts junction. This is
|
|
// intentionally narrow and will be removed after the floor/tree seam is corrected.
|
|
if (Arguments.Archetype == ECaveGeneratorType::VerticalShafts && Arguments.Seed == 14)
|
|
{
|
|
const FVector ProbeA = OutOutput.ArrivalVoxels;
|
|
const FVector ProbeB(-100.8f, -26.9f, ProbeA.Z);
|
|
for (int32 Index = 0; Index <= 10; ++Index)
|
|
{
|
|
const float T = static_cast<float>(Index) / 10.0f;
|
|
const FVector P = FMath::Lerp(ProbeA, ProbeB, T);
|
|
const float Dm704 = World.Generator->GetDensityAt(P.X, P.Y, -704.0f);
|
|
const float Dm702 = World.Generator->GetDensityAt(P.X, P.Y, -702.0f);
|
|
const float Dm7005 = World.Generator->GetDensityAt(P.X, P.Y, -700.5f);
|
|
const float Dm700 = World.Generator->GetDensityAt(P.X, P.Y, -700.0f);
|
|
const float Dm6995 = World.Generator->GetDensityAt(P.X, P.Y, -699.5f);
|
|
const float Dm697 = World.Generator->GetDensityAt(P.X, P.Y, -697.0f);
|
|
float MinBody = FLT_MAX;
|
|
int32 NumSolidBody = 0;
|
|
int32 SolidOffsetX = 0;
|
|
int32 SolidOffsetY = 0;
|
|
int32 SolidBodyRow = -1;
|
|
for (int32 OffsetY = -1; OffsetY <= 1; ++OffsetY)
|
|
{
|
|
for (int32 OffsetX = -1; OffsetX <= 1; ++OffsetX)
|
|
{
|
|
for (int32 BodyRow = 0; BodyRow < 8; ++BodyRow)
|
|
{
|
|
const float BodyDensity = World.Generator->GetDensityAt(
|
|
P.X + static_cast<float>(OffsetX),
|
|
P.Y + static_cast<float>(OffsetY),
|
|
-699.5f + static_cast<float>(BodyRow));
|
|
if (BodyDensity < MinBody)
|
|
{
|
|
MinBody = BodyDensity;
|
|
SolidOffsetX = OffsetX;
|
|
SolidOffsetY = OffsetY;
|
|
SolidBodyRow = BodyRow;
|
|
}
|
|
NumSolidBody += BodyDensity <= 0.0f ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
UE_LOG(LogTemp, Display,
|
|
TEXT("[VoxelForgeExplore][TemporaryShaftProbe] t=%.2f xy=(%.2f,%.2f) "
|
|
"d[-704,-702,-700.5,-700,-699.5,-697]=[%.3f,%.3f,%.3f,%.3f,%.3f,%.3f] "
|
|
"bodyMin=%.3f bodySolid=%d/72 firstMinOffset=(%d,%d) row=%d"),
|
|
T, P.X, P.Y, Dm704, Dm702, Dm7005, Dm700, Dm6995, Dm697,
|
|
MinBody, NumSolidBody, SolidOffsetX, SolidOffsetY, SolidBodyRow);
|
|
}
|
|
}
|
|
|
|
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 WriteObjTile(
|
|
FArchive& Archive,
|
|
const FVoxelMeshData& MeshData,
|
|
int32 VertexOffset,
|
|
FString& OutError)
|
|
{
|
|
if (!ValidateCanonicalTile(MeshData, OutError))
|
|
{
|
|
return false;
|
|
}
|
|
bool bOk = true;
|
|
for (const FVector& Vertex : MeshData.Vertices)
|
|
{
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("v %.9f %.9f %.9f\n"),
|
|
Vertex.X / 100.0,
|
|
Vertex.Y / 100.0,
|
|
Vertex.Z / 100.0));
|
|
}
|
|
for (const FVector2D& UV : MeshData.UVs)
|
|
{
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("vt %.9f %.9f\n"), UV.X, UV.Y));
|
|
}
|
|
for (const FVector& Normal : MeshData.Normals)
|
|
{
|
|
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;
|
|
const int32 FirstCeilingTriangle = FMath::Clamp(
|
|
NumTriangles - MeshData.NumCeilingTriangles, 0, NumTriangles);
|
|
if (NumTriangles > 0)
|
|
{
|
|
bOk = bOk && WriteUtf8(Archive, TEXT("usemtl VoxelGround\n"));
|
|
}
|
|
for (int32 Triangle = 0; Triangle < NumTriangles; ++Triangle)
|
|
{
|
|
if (Triangle == FirstCeilingTriangle)
|
|
{
|
|
bOk = bOk && WriteUtf8(Archive, TEXT("usemtl VoxelCeiling\n"));
|
|
}
|
|
const int32 Base = Triangle * 3;
|
|
const int32 SourceA = MeshData.Triangles[Base];
|
|
const int32 SourceB = MeshData.Triangles[Base + 1];
|
|
const int32 SourceC = MeshData.Triangles[Base + 2];
|
|
if (SourceA < 0 || SourceB < 0 || SourceC < 0
|
|
|| SourceA >= MeshData.Vertices.Num()
|
|
|| SourceB >= MeshData.Vertices.Num()
|
|
|| SourceC >= MeshData.Vertices.Num())
|
|
{
|
|
OutError = TEXT("Canonical mesher returned an out-of-range triangle index.");
|
|
return false;
|
|
}
|
|
const int32 A = SourceA + 1 + VertexOffset;
|
|
const int32 B = SourceB + 1 + VertexOffset;
|
|
const int32 C = SourceC + 1 + VertexOffset;
|
|
bOk = bOk && WriteUtf8(Archive, FString::Printf(
|
|
TEXT("f %d/%d/%d %d/%d/%d %d/%d/%d\n"),
|
|
A, A, A, B, B, B, C, C, C));
|
|
}
|
|
if (!bOk || Archive.IsError())
|
|
{
|
|
OutError = TEXT("Could not write a canonical OBJ tile.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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"), 3);
|
|
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);
|
|
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("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("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride);
|
|
if (Arguments.bSurfaceRoughnessOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("surface_roughness"),
|
|
static_cast<double>(Arguments.SurfaceRoughness));
|
|
}
|
|
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();
|
|
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;
|
|
}
|
|
if (!WriteObjHeader(*Archive))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = TEXT("Could not write the OBJ header.");
|
|
return false;
|
|
}
|
|
|
|
if (!MeasureExploreMesh(World.ExploreMesh, OutOutput.SurfaceMetrics, Error))
|
|
{
|
|
OutOutput.Status = TEXT("error");
|
|
OutOutput.RefusalReason = Error;
|
|
return false;
|
|
}
|
|
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;
|
|
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.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 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;
|
|
}
|
|
if (!OutOutput.bTruncated)
|
|
{
|
|
OutOutput.Status = TEXT("ok");
|
|
}
|
|
return !OutOutput.bTruncated;
|
|
}
|
|
|
|
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("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"), 3);
|
|
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("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride);
|
|
if (Arguments.bSurfaceRoughnessOverride)
|
|
{
|
|
Writer->WriteValue(TEXT("surface_roughness"),
|
|
static_cast<double>(Arguments.SurfaceRoughness));
|
|
}
|
|
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"));
|
|
Writer->WriteArrayEnd();
|
|
Writer->WriteValue(TEXT("failure_focus_render"), Arguments.bFailureFocusRender);
|
|
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 -out=Saved/VoxelForge/Explore/<runid>"),
|
|
Arguments.Seed,
|
|
ArchetypeName(Arguments.Archetype),
|
|
Arguments.Slot,
|
|
*Arguments.CanonicalModes()));
|
|
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("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 six-neighbour graph; 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("start_snapped"), Report.bStartSnapped);
|
|
Writer->WriteValue(TEXT("goal_snapped"), Report.bGoalSnapped);
|
|
Writer->WriteValue(TEXT("route_retries"), Report.NumRouteRetries);
|
|
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("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("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("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("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("truncated"), Output.Export.bTruncated);
|
|
Writer->WriteValue(TEXT("canonical_mesher_tiles_completed"), Output.Export.MesherTilesCompleted);
|
|
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();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet()
|
|
{
|
|
IsClient = false;
|
|
IsServer = false;
|
|
IsEditor = true;
|
|
LogToConsole = true;
|
|
}
|
|
|
|
int32 UVoxelForgeExploreCommandlet::Main(const FString& Params)
|
|
{
|
|
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;
|
|
}
|
|
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;
|
|
|
|
FExploreRunOutput Output;
|
|
if (Budget.ShouldStop(TEXT("setup")))
|
|
{
|
|
UE_LOG(LogTemp, Warning,
|
|
TEXT("[VoxelForgeExplore] setup consumed the wall-clock budget; no mode was started."));
|
|
}
|
|
bool bRequestedModeFailed = false;
|
|
FExploreWalkOutput RenderSeedWalk;
|
|
const FExploreWalkOutput* CameraSeedWalk = nullptr;
|
|
if (Arguments.bWalk)
|
|
{
|
|
const double Start = FPlatformTime::Seconds();
|
|
const bool bWalkOk = RunWalk(Arguments, World, Output.Walk, Budget);
|
|
if (!bWalkOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] walk %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start, *Output.Walk.Status);
|
|
CameraSeedWalk = &Output.Walk;
|
|
if (bWalkOk && !Budget.bTruncated)
|
|
{
|
|
Budget.CompleteMode(TEXT("walk"));
|
|
}
|
|
}
|
|
else if (Arguments.bRender)
|
|
{
|
|
// 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);
|
|
if (!bWalkOk && !Budget.bTruncated)
|
|
{
|
|
bRequestedModeFailed = true;
|
|
}
|
|
UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render camera seed walk %.3fs (%s)"),
|
|
FPlatformTime::Seconds() - Start, *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;
|
|
}
|
|
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);
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
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"));
|
|
return bRequestedModeFailed ? 2 : 0;
|
|
}
|