perf(gen): a thrashing one-slot cache cost 18.7x -- and the world is unchanged

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
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-08 20:31:56 +02:00
1 parent d97373c5a8
commit e08b49a183
7 files changed
+2274 -450

No files matched your search

@@ -19,12 +19,16 @@
namespace
{
constexpr int32 DiagnosisSeed = 0;
constexpr int32 MazeCandidateIndex = 50;
// These are single-region seed-0 identities after the Maze default/range change updates the
// corpus content hash. Keep the probes on real candidates rather than silently testing fixture
// defaults when a historical index moves to another family.
constexpr int32 TunnelCandidateIndex = 4;
constexpr int32 ShaftCandidateIndex = 6;
// These are single-region seed-0 identities in the current corpus asset/default set. Keep the
// probes on real candidates rather than silently testing fixture defaults when a field changes.
constexpr int32 CrystalCandidateIndex = 50;
constexpr int32 FlatCandidateIndex = 28;
constexpr int32 FloatingCandidateIndex = 4;
constexpr int32 MazeCandidateIndex = 9;
constexpr int32 SurfaceCandidateIndex = 11;
constexpr int32 TunnelCandidateIndex = 20;
constexpr int32 UnderwaterCandidateIndex = 6;
constexpr int32 ShaftCandidateIndex = 10;
constexpr int32 RadialSampleMaxVoxels = 48;
constexpr int32 FineDiagnosisMaxCells = 4000000;
@@ -845,6 +849,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
return false;
}
// The corpus asset changed after d97373c; keep the current-corpus identity probes explicit
// while the historical H3 rows below remain comparison baselines.
FTestWorld World;
World.Build(DiagnosisSeed, 2, true, 8);
if (!World.IsValid())
@@ -864,11 +870,14 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
Corpus, DiagnosisSeed, TunnelCandidateIndex, false);
const FVoxelStrateComposerCandidate ShaftCandidate = VF_RollStrateCandidate(
Corpus, DiagnosisSeed, ShaftCandidateIndex, false);
TestTrue(TEXT("seed 0 index 50 is a valid Maze candidate"),
TestTrue(FString::Printf(TEXT("seed %d index %d is a valid Maze candidate"),
DiagnosisSeed, MazeCandidateIndex),
MazeCandidate.bValid && MazeCandidate.Archetype == ECaveGeneratorType::Maze);
TestTrue(TEXT("seed 0 index 61 is a valid TunnelNetwork candidate"),
TestTrue(FString::Printf(TEXT("seed %d index %d is a valid TunnelNetwork candidate"),
DiagnosisSeed, TunnelCandidateIndex),
TunnelCandidate.bValid && TunnelCandidate.Archetype == ECaveGeneratorType::TunnelNetwork);
TestTrue(TEXT("seed 0 index 34 is a valid VerticalShafts candidate"),
TestTrue(FString::Printf(TEXT("seed %d index %d is a valid VerticalShafts candidate"),
DiagnosisSeed, ShaftCandidateIndex),
ShaftCandidate.bValid && ShaftCandidate.Archetype == ECaveGeneratorType::VerticalShafts);
// H1 — direct GetDensityAt radial crossings. The Maze is the fourth one-based layout slot
@@ -1151,6 +1160,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
PartABase.InteriorMarginVoxels = -1;
FGeneratorSampler GeneratorSampler(*World.Generator);
const FString MazeDiagnosisLabel = FString::Printf(
TEXT("Maze seed=%d index=%d slot=4"), DiagnosisSeed, MazeCandidateIndex);
auto RunPartAWindow =
[&](const FString& WindowLabel, const FVoxelStrateMeasureSettings& Settings)
{
@@ -1161,7 +1172,7 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
VF_BoundarySealForCandidate(MazeCandidate),
ArrivalPoint, DeparturePoint, Settings, &Metrics);
VF_ReportPlayerFitRow(
*this, TEXT("Maze seed=0 index=50 slot=4"), WindowLabel,
*this, *MazeDiagnosisLabel, WindowLabel,
Settings.SampleStep, Metrics, Diagnostics);
return TTuple<FVoxelStrateMetrics, FVoxelConnectivityDiagnostics>(
MoveTemp(Metrics), Diagnostics);
@@ -1213,9 +1224,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
}
}
// H3 — the eight fixed showcase identities, measured at step 1 on the topology-aware fitted
// window. Origin-rooted families include (0,0) before the MaxCells refusal check.
// The Maze identity is intentionally the requested seed 0 / index 50 / fourth one-based slot.
// H3 — the eight fixed current-corpus identities, measured at step 1 on the topology-aware
// fitted window. Origin-rooted families include (0,0) before the MaxCells refusal check.
struct FShowcaseSelection
{
ECaveGeneratorType Archetype;
@@ -1225,14 +1235,14 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
const TCHAR* Label;
};
const FShowcaseSelection Selections[] = {
{ ECaveGeneratorType::CrystalChamber, 0, 28, FTestWorld::SlotFlatPlain, TEXT("CrystalChamber") },
{ ECaveGeneratorType::FlatPlain, 0, 37, FTestWorld::SlotFlatPlain, TEXT("FlatPlain") },
{ ECaveGeneratorType::FloatingIslands, 0, 9, FTestWorld::SlotFlatPlain, TEXT("FloatingIslands") },
{ ECaveGeneratorType::Maze, 0, 50, FTestWorld::SlotMaze, TEXT("Maze") },
{ ECaveGeneratorType::SurfaceWorld, 0, 27, FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld") },
{ ECaveGeneratorType::TunnelNetwork, 0, 4, FTestWorld::SlotFlatPlain, TEXT("TunnelNetwork") },
{ ECaveGeneratorType::Underwater, 0, 16, FTestWorld::SlotFlatPlain, TEXT("Underwater") },
{ ECaveGeneratorType::VerticalShafts, 0, 6, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") },
{ ECaveGeneratorType::CrystalChamber, 0, CrystalCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("CrystalChamber") },
{ ECaveGeneratorType::FlatPlain, 0, FlatCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("FlatPlain") },
{ ECaveGeneratorType::FloatingIslands, 0, FloatingCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("FloatingIslands") },
{ ECaveGeneratorType::Maze, 0, MazeCandidateIndex, FTestWorld::SlotMaze, TEXT("Maze") },
{ ECaveGeneratorType::SurfaceWorld, 0, SurfaceCandidateIndex, FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld") },
{ ECaveGeneratorType::TunnelNetwork, 0, TunnelCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("TunnelNetwork") },
{ ECaveGeneratorType::Underwater, 0, UnderwaterCandidateIndex,FTestWorld::SlotFlatPlain, TEXT("Underwater") },
{ ECaveGeneratorType::VerticalShafts, 0, ShaftCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") },
};
// Baseline rows from the pre-fix fitted-window report. The supplied VerticalShafts row is
+124 -7
View File
@@ -477,6 +477,41 @@ namespace
};
static thread_local FTunnelCoreCacheState GTunnelCoreCache;
// A mesher tile samples an expanded grid (-1..Cells+1), so one worker visits several exact
// chunk coordinates even while it is generating one tile. The old CP_* cache retained only
// the last coordinate and consequently rebuilt the whole TunnelNetwork stack whenever the
// sampling cursor crossed a chunk boundary. Keep a deterministic direct-mapped set of states
// instead: collisions only cause a safe rebuild, never a stale read or an order-dependent
// result. Native TunnelNetwork/Underwater states are immutable after construction; the active
// pointer below lets the hot sample path read the stored stack and tunnel-core cache without
// moving them on every voxel.
constexpr int32 TunnelDensityCacheSlotCount = 128;
struct FTunnelNetworkDensityCacheEntry
{
bool bValid = false;
uint64 OwnerId = 0;
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
uint32 LayoutVersion = 0xFFFFFFFFu;
ECaveGeneratorType GenType = ECaveGeneratorType::TunnelNetwork;
FStrateGenerationParams Tunnel;
uint32 TunnelFingerprint = 0xFFFFFFFFu;
FStrateDisturbanceParams Disturbance;
bool bUseOpStack = false;
FVoxelOpStack OpStack;
FTunnelCoreCacheState TunnelCore;
};
FORCEINLINE int32 TunnelDensityCacheSlot(const FIntVector& Chunk)
{
uint32 Hash = static_cast<uint32>(Chunk.X) * 0x9E3779B9u;
Hash ^= static_cast<uint32>(Chunk.Y) * 0x85EBCA6Bu;
Hash ^= static_cast<uint32>(Chunk.Z) * 0xC2B2AE35u;
Hash ^= Hash >> 16;
return static_cast<int32>(Hash & (TunnelDensityCacheSlotCount - 1));
}
void PrepareTunnelCoreCache(
const UVoxelStrateManager& Manager,
const FIntVector& ChunkCoord,
@@ -702,7 +737,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
FMath::FloorToInt(WorldZ / CHUNK_SIZE)
);
if (StrateManager && StrateManager->IsGapChunk(ChunkCoord))
const bool bIsGapChunk = StrateManager && StrateManager->IsGapChunk(ChunkCoord);
if (StrateManager && bIsGapChunk)
{
// SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel
// through it, but the (0,0) descent stays solid here so the player digs the gap
@@ -762,6 +799,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté.
thread_local FVoxelOpStack CP_OpStack;
thread_local bool CP_UseOpStack = false;
thread_local FTunnelNetworkDensityCacheEntry CP_TunnelDensityCache[TunnelDensityCacheSlotCount];
thread_local FTunnelNetworkDensityCacheEntry* CP_ActiveTunnelDensityCache = nullptr;
// Cooked-season recipes and editor candidate recipes share this immutable worker-local
// hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only.
thread_local bool CP_UseCustomRecipe = false;
@@ -778,9 +817,50 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
#endif
const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
if (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)
FVoxelOpStack* ActiveOpStack = &CP_OpStack;
FTunnelCoreCacheState* ActiveTunnelCoreCache = &GTunnelCoreCache;
bool bLoadedTunnelDensityCache = false;
if (!bIsGapChunk)
{
FTunnelNetworkDensityCacheEntry& CachedEntry =
CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)];
const bool bCacheKeyMatches = CachedEntry.bValid
&& CachedEntry.OwnerId == DensityCacheOwnerId
&& CachedEntry.Chunk == ChunkCoord
&& CachedEntry.LayoutVersion == LayoutVersion
&& (CachedEntry.GenType == ECaveGeneratorType::TunnelNetwork
|| CachedEntry.GenType == ECaveGeneratorType::Underwater);
if (bCacheKeyMatches)
{
if (CP_ActiveTunnelDensityCache != &CachedEntry)
{
CP_OwnerId = CachedEntry.OwnerId;
CP_Version = CachedEntry.LayoutVersion;
CP_Chunk = CachedEntry.Chunk;
CP_GenType = CachedEntry.GenType;
CP_Tunnel = CachedEntry.Tunnel;
CP_TunnelFP = CachedEntry.TunnelFingerprint;
CP_Dist = CachedEntry.Disturbance;
CP_UseOpStack = CachedEntry.bUseOpStack;
CP_UseCustomRecipe = false;
#if WITH_EDITOR
CP_UseComposerRegions = false;
#endif
CP_ActiveTunnelDensityCache = &CachedEntry;
}
ActiveOpStack = &CachedEntry.OpStack;
ActiveTunnelCoreCache = &CachedEntry.TunnelCore;
bLoadedTunnelDensityCache = true;
}
}
const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
if (!bLoadedTunnelDensityCache
&& (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version))
{
CP_ActiveTunnelDensityCache = nullptr;
// La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext
// ayant servi à classer ses cellules : sur un changement de version elle est périmée
// même si la boîte couvre encore la requête. Même invalidation quand le propriétaire
@@ -976,6 +1056,43 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
}
}
}
const bool bNativeTunnelCacheCandidate = !CP_UseCustomRecipe
&& (CP_GenType == ECaveGeneratorType::TunnelNetwork
|| CP_GenType == ECaveGeneratorType::Underwater);
#if WITH_EDITOR
const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate
&& !CP_UseComposerRegions;
#else
const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate;
#endif
if (!bLoadedTunnelDensityCache && bCanStoreTunnelDensityCache)
{
FTunnelNetworkDensityCacheEntry& CachedEntry =
CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)];
CachedEntry.bValid = false;
CachedEntry.OwnerId = DensityCacheOwnerId;
CachedEntry.Chunk = ChunkCoord;
CachedEntry.LayoutVersion = LayoutVersion;
CachedEntry.GenType = CP_GenType;
CachedEntry.Tunnel = CP_Tunnel;
CachedEntry.TunnelFingerprint = CP_TunnelFP;
CachedEntry.Disturbance = CP_Dist;
CachedEntry.bUseOpStack = CP_UseOpStack;
CachedEntry.OpStack = MoveTemp(CP_OpStack);
CachedEntry.TunnelCore = MoveTemp(GTunnelCoreCache);
GTunnelCoreCache = FTunnelCoreCacheState();
CachedEntry.bValid = true;
CP_ActiveTunnelDensityCache = &CachedEntry;
ActiveOpStack = &CachedEntry.OpStack;
ActiveTunnelCoreCache = &CachedEntry.TunnelCore;
}
else if (!bLoadedTunnelDensityCache)
{
ActiveOpStack = &CP_OpStack;
ActiveTunnelCoreCache = &GTunnelCoreCache;
}
}
// Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention
@@ -983,7 +1100,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// de diff qui suivent ne voient aucune différence.
if (CP_UseOpStack)
{
Result = CP_OpStack.EvalMC(WorldX, WorldY, WorldZ);
Result = ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ);
}
else switch (CP_GenType)
{
@@ -1153,11 +1270,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
// bridge over a graph tunnel. Reassert the native cached tunnel core here, after every
// solid floor writer but before the global XY seal. This cache is built once per chunk,
// never once per voxel.
if (GTunnelCoreCache.bValid)
if (ActiveTunnelCoreCache->bValid)
{
const bool bTunnelSupportFloor =
VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint(
WorldX, WorldY, WorldZ, GTunnelCoreCache.Cache);
WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache);
if (bTunnelSupportFloor)
{
Result = FMath::Min(
@@ -1165,7 +1282,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
-FMath::Max(LandingBaseDensity * 2.0f, 1.0f));
}
const float CoreSDF = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(
WorldX, WorldY, WorldZ, GTunnelCoreCache.Cache);
WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache);
if (!bTunnelSupportFloor
&& CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels)
{
+127 -33
View File
@@ -455,6 +455,8 @@ namespace VoxelStrateMeasurePrivate
// only when it explicitly requested a capture.
Destination.Air = MoveTemp(Source.Air);
Destination.Density = MoveTemp(Source.Density);
Destination.PlayerFitMask.Reset();
Destination.PlayerFitCellCount = 0;
}
void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ)
@@ -1374,6 +1376,37 @@ namespace VoxelStrateMeasurePrivate
? 0.0f : static_cast<float>(MinimumDistance);
}
int64 CountAirCells(
const FSampleGrid& Grid,
const TArray<uint8>* EligibilityMask = nullptr)
{
TArray<int64> AirCountsByZ;
AirCountsByZ.Init(0, Grid.NumZ);
ParallelFor(Grid.NumZ, [&](int32 Z)
{
int64 Count = 0;
for (int32 Y = 0; Y < Grid.NumY; ++Y)
{
for (int32 X = 0; X < Grid.NumX; ++X)
{
const int32 Cell = Grid.Index(X, Y, Z);
Count += Grid.Air[Cell] != 0u
&& (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u)
? 1
: 0;
}
}
AirCountsByZ[Z] = Count;
});
int64 Total = 0;
for (int32 Z = 0; Z < Grid.NumZ; ++Z)
{
Total += AirCountsByZ[Z];
}
return Total;
}
void BuildPlayerFitMetrics(
const FSampleGrid& Grid,
const FVoxelStrateMeasureSettings& Settings,
@@ -1453,10 +1486,7 @@ namespace VoxelStrateMeasurePrivate
OutMetrics.SampledMinY = Grid.MinY;
OutMetrics.SampledMaxY = Grid.MaxY;
OutMetrics.NumSampled = Grid.CellCount;
for (const uint8 bAir : Grid.Air)
{
OutMetrics.NumAir += bAir != 0u ? 1 : 0;
}
OutMetrics.NumAir = CountAirCells(Grid);
OutMetrics.NumSolid = static_cast<int64>(Grid.CellCount) - OutMetrics.NumAir;
OutMetrics.AirFraction = OutMetrics.NumSampled > 0
? static_cast<float>(static_cast<double>(OutMetrics.NumAir)
@@ -1487,11 +1517,7 @@ namespace VoxelStrateMeasurePrivate
TArray<int32> Components;
int32 NumComponents = 0;
int64 NumAir = 0;
for (const uint8 bAir : Grid.Air)
{
NumAir += bAir != 0u ? 1 : 0;
}
const int64 NumAir = CountAirCells(Grid);
int64 LargestComponentCells = 0;
int32 LargestComponentLowestCell = INDEX_NONE;
@@ -1537,8 +1563,21 @@ namespace VoxelStrateMeasurePrivate
TArray<uint8> WalkableColumns;
WalkableColumns.Init(0u, static_cast<int32>(XYColumnCount64));
for (int32 Z = 0; Z < Grid.NumZ; ++Z)
// Candidate poses read only the immutable sampled field. Keep every worker's clearance
// values and compact column flags private, then merge by fixed Z order so the median and
// projected surface graph remain deterministic.
TArray<int64> WalkableCountsByZ;
WalkableCountsByZ.Init(0, Grid.NumZ);
TArray<TArray<int32>> ClearanceValuesByZ;
ClearanceValuesByZ.SetNum(Grid.NumZ);
TArray<TArray<uint8>> WalkableColumnsByZ;
WalkableColumnsByZ.SetNum(Grid.NumZ);
ParallelFor(Grid.NumZ, [&](int32 Z)
{
TArray<int32>& SliceClearanceValues = ClearanceValuesByZ[Z];
TArray<uint8>& SliceWalkableColumns = WalkableColumnsByZ[Z];
SliceWalkableColumns.Init(0u, static_cast<int32>(XYColumnCount64));
int64 SliceWalkableCount = 0;
for (int32 Y = 0; Y < Grid.NumY; ++Y)
{
for (int32 X = 0; X < Grid.NumX; ++X)
@@ -1563,8 +1602,8 @@ namespace VoxelStrateMeasurePrivate
}
if (!bHasHeadroom) continue;
++NumWalkable;
WalkableColumns[X + Grid.NumX * Y] = 1u;
++SliceWalkableCount;
SliceWalkableColumns[X + Grid.NumX * Y] = 1u;
int32 ClearanceCells = 0;
while (static_cast<int64>(Z) + ClearanceCells < Grid.NumZ
&& Grid.Air[Grid.Index(X, Y, Z + ClearanceCells)] != 0u)
@@ -1573,10 +1612,25 @@ namespace VoxelStrateMeasurePrivate
}
const int64 ClearanceVoxels = static_cast<int64>(ClearanceCells)
* Settings.SampleStep;
ClearanceValues.Add(ClearanceVoxels > INT32_MAX
SliceClearanceValues.Add(ClearanceVoxels > INT32_MAX
? INT32_MAX : static_cast<int32>(ClearanceVoxels));
}
}
WalkableCountsByZ[Z] = SliceWalkableCount;
});
for (int32 Z = 0; Z < Grid.NumZ; ++Z)
{
NumWalkable += WalkableCountsByZ[Z];
const TArray<uint8>& SliceWalkableColumns = WalkableColumnsByZ[Z];
for (int32 Column = 0; Column < SliceWalkableColumns.Num(); ++Column)
{
if (SliceWalkableColumns[Column] != 0u)
{
WalkableColumns[Column] = 1u;
}
}
ClearanceValues.Append(ClearanceValuesByZ[Z]);
}
InOutMetrics.WalkableFraction = NumAir > 0
@@ -1654,15 +1708,29 @@ namespace VoxelStrateMeasurePrivate
TArray<int32> Distances;
BuildManhattanDistanceToSolid(Grid, Distances);
TArray<TArray<float>> FeatureScalesByZ;
FeatureScalesByZ.SetNum(Grid.NumZ);
ParallelFor(Grid.NumZ, [&](int32 Z)
{
TArray<float>& SliceFeatureScales = FeatureScalesByZ[Z];
for (int32 Y = 0; Y < Grid.NumY; ++Y)
{
for (int32 X = 0; X < Grid.NumX; ++X)
{
const int32 Index = Grid.Index(X, Y, Z);
if (Grid.Air[Index] != 0u)
{
SliceFeatureScales.Add(static_cast<float>(Distances[Index])
* static_cast<float>(Settings.SampleStep));
}
}
}
});
TArray<float> FeatureScales;
FeatureScales.Reserve(static_cast<int32>(NumAir));
for (int32 Index = 0; Index < Grid.CellCount; ++Index)
for (int32 Z = 0; Z < Grid.NumZ; ++Z)
{
if (Grid.Air[Index] != 0u)
{
FeatureScales.Add(static_cast<float>(Distances[Index])
* static_cast<float>(Settings.SampleStep));
}
FeatureScales.Append(FeatureScalesByZ[Z]);
}
InOutMetrics.MedianFeatureScale = MedianFloat(FeatureScales);
}
@@ -2220,13 +2288,7 @@ namespace VoxelStrateMeasurePrivate
int64 LargestComponentCells = 0;
int32 LargestComponentLowestCell = INDEX_NONE;
int32 NumComponentsAtLeast1Pct = 0;
int64 NumAir = 0;
for (int32 Index = 0; Index < Grid.CellCount; ++Index)
{
NumAir += Grid.Air[Index] != 0u
&& (EligibilityMask == nullptr || (*EligibilityMask)[Index] != 0u)
? 1 : 0;
}
const int64 NumAir = CountAirCells(Grid, EligibilityMask);
FloodFillAir(
Grid,
Components,
@@ -2284,10 +2346,16 @@ namespace VoxelStrateMeasurePrivate
OutDiagnostics.StartComponentCells == LargestComponentCells;
OutDiagnostics.bGoalComponentIsLargest =
OutDiagnostics.GoalComponentCells == LargestComponentCells;
OutDiagnostics.StartToGoalComponentDistanceCells = DistanceToComponent(
Grid, Start, GoalComponent, Components);
OutDiagnostics.GoalToStartComponentDistanceCells = DistanceToComponent(
Grid, Goal, StartComponent, Components);
TArray<float> ComponentDistances;
ComponentDistances.Init(0.0f, 2);
ParallelFor(2, [&](int32 DistanceIndex)
{
ComponentDistances[DistanceIndex] = DistanceIndex == 0
? DistanceToComponent(Grid, Start, GoalComponent, Components)
: DistanceToComponent(Grid, Goal, StartComponent, Components);
});
OutDiagnostics.StartToGoalComponentDistanceCells = ComponentDistances[0];
OutDiagnostics.GoalToStartComponentDistanceCells = ComponentDistances[1];
}
EVoxelConnectivityResult QueryConnectivity(
@@ -2702,7 +2770,12 @@ FVoxelStrateMetrics VF_MeasureStrate(
Result.SampledMaxX = Grid.MaxX;
Result.SampledMinY = Grid.MinY;
Result.SampledMaxY = Grid.MaxY;
VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result);
TArray<uint8> PlayerFitMask;
VoxelStrateMeasurePrivate::BuildMetricsFromGrid(
Grid,
Settings,
Result,
OutSampleGrid != nullptr ? &PlayerFitMask : nullptr);
Result.bValid = Result.NumSampled > 0;
if (!Result.bValid)
{
@@ -2711,6 +2784,8 @@ FVoxelStrateMetrics VF_MeasureStrate(
if (OutSampleGrid != nullptr && Result.bValid)
{
VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid);
OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask);
OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells;
}
return Result;
}
@@ -2748,7 +2823,12 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler(
Result.SampledMaxX = Grid.MaxX;
Result.SampledMinY = Grid.MinY;
Result.SampledMaxY = Grid.MaxY;
VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result);
TArray<uint8> PlayerFitMask;
VoxelStrateMeasurePrivate::BuildMetricsFromGrid(
Grid,
Settings,
Result,
OutSampleGrid != nullptr ? &PlayerFitMask : nullptr);
Result.bValid = Result.NumSampled > 0;
if (!Result.bValid)
{
@@ -2757,6 +2837,8 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler(
if (OutSampleGrid != nullptr && Result.bValid)
{
VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid);
OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask);
OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells;
}
return Result;
}
@@ -2951,11 +3033,16 @@ bool VF_MeasurePlayerFitWalkWithSampler(
const FVector& BVoxel,
const FVoxelStrateMeasureSettings& Settings,
FVoxelPlayerFitWalkReport& OutReport,
const TArray<FVector>* ComponentProbePoints)
const TArray<FVector>* ComponentProbePoints,
FVoxelStrateSampleGrid* OutSampleGrid)
{
using namespace VoxelStrateMeasurePrivate;
OutReport = FVoxelPlayerFitWalkReport();
if (OutSampleGrid != nullptr)
{
*OutSampleGrid = FVoxelStrateSampleGrid();
}
OutReport.CapsuleRadiusMeters = Settings.PlayerCapsuleRadiusVoxels
* FVoxelPlayerCapsuleConstants::VoxelSizeMeters;
OutReport.CapsuleWidthMeters = 2.0f * OutReport.CapsuleRadiusMeters;
@@ -3271,6 +3358,13 @@ bool VF_MeasurePlayerFitWalkWithSampler(
{
OutReport.RefusalReason = Diagnostics.RefusalReason;
}
if (OutSampleGrid != nullptr)
{
ExportSampleGrid(Grid, *OutSampleGrid);
OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask);
OutSampleGrid->PlayerFitCellCount = NumPlayerFitCells;
}
return true;
}
+21 -1
View File
@@ -142,6 +142,12 @@ struct VOXELFORGE_API FVoxelStrateSampleGrid
// empty on legacy/binary-only callers. This is never used by generation.
TArray<float> Density;
// 1 = the exact player-fit pose predicate accepted this cell, 0 = rejected. This is
// populated when a player-fit walk explicitly captures its shared grid. It is kept beside
// Air so render, walk, and diagnostics can consume one sampled field and one fit mask.
TArray<uint8> PlayerFitMask;
int64 PlayerFitCellCount = 0;
FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const
{
return static_cast<int32>(
@@ -163,6 +169,19 @@ struct VOXELFORGE_API FVoxelStrateSampleGrid
{
return IsValid() && Density.Num() == CellCount;
}
bool HasPlayerFitMask() const
{
return IsValid() && PlayerFitMask.Num() == CellCount;
}
bool ContainsPoint(const FVector& Point) const
{
return IsValid()
&& Point.X >= MinX && Point.X < MaxX
&& Point.Y >= MinY && Point.Y < MaxY
&& Point.Z >= MinZ && Point.Z < MaxZ;
}
};
/** Metrics derived from one coarse grid and its single air flood fill. */
@@ -510,4 +529,5 @@ VOXELFORGE_API bool VF_MeasurePlayerFitWalkWithSampler(
const FVector& BVoxel,
const FVoxelStrateMeasureSettings& Settings,
FVoxelPlayerFitWalkReport& OutReport,
const TArray<FVector>* ComponentProbePoints = nullptr);
const TArray<FVector>* ComponentProbePoints = nullptr,
FVoxelStrateSampleGrid* OutSampleGrid = nullptr);
File diff suppressed because it is too large. Load diff