perf(gen): the landing work was per-voxel -- 342s to 2.6s, same world

The owner played 64f5d61 and reported the truth: "generation still takes ages...
TunnelNetwork doesn't even finish generating, as big as the surface world" while
SurfaceWorld completed in under 10s, and stopping PIE hung for 30+ seconds.

e08b49a's 21x was real and irrelevant. It fixed per-CHUNK state rebuilds, which
dominate the commandlet export path. The game is bound by per-VOXEL evaluation.
Same symptom, different bottleneck, and nobody had measured the second one.

WHY PIE HUNG
bShuttingDown was never checked inside VoxelMarchingCubesMesher.cpp -- zero
occurrences. A worker inside GenerateMesh could not abort, so 64f5d61's correct
unconditional EndPlay wait was waiting on tiles physically unable to stop. The
hang was not a teardown bug; it was evidence that one tile took tens of seconds.
Added shutdown polling per slab/row in the mesher and through GenerateTileResult;
aborted results carry Epoch + bAborted and ProcessQueue drops them. EndPlay still
waits unconditionally -- the 3s mark only logs now.

  PIE stop   3.409s / 44.759s  ->  4 ms teardown, 38 us worker drain

WHERE THE PER-VOXEL COST WAS
The owner's own comparison was the sharpest instrument available. Profiled both:

  GetDensityAt          TunnelNetwork 12.64 us/sample   SurfaceWorld 1.32
  tunnel candidates evaluated      7.088 per sample                 0
  tunnel-core candidates evaluated 7.201 per sample                 0
  room candidates evaluated        1.000 per sample                 0

  largest buckets: RoomGraphSource 2.57, TunnelCorePosts 0.76,
                   PassageCarveOp 0.22, PassageStructuralPosts 0.20

The three regressing commits map cleanly, and one is innocent:
  7a20eee drainage tree  -- cache-build work, NOT per-voxel
  413c15d landings       -- repeated full passage modification per sample
  55bf807 landing rooms  -- room SDF + tunnel/core evaluation per sample

Fixes: FPassageCarveOp carves only, landing/support moved to the structural MC
post; tunnel core support and SDF fused into one pass; room graph caches shared
per worker on complete deterministic keys; tunnel density cache uses open
addressing with 4096 slots. Room cache rebuilds fell to 9 per 42,875 samples, so
what remains is genuine O(local cached room/tunnel count) work.

  TunnelNetwork 128^3, seed 1337 slot 1, same region:
    342.2 s / 163.161 us/vox   ->   2.623 s / 1.251 us/vox     (130x)

  Geometry SHA-256 aaf95559..066b4, IDENTICAL to the pre-optimisation run at the
  same roughness. 153,692 triangles either way. Verified independently of the
  agent's own report.

NOT FIXED, and the first one is the next real target:
- L4 tiles still cost 16.93 s. Hit rate falls 99.9% (L0) -> 23.7% (L4), but
  1024 -> 4096 slots only moved 18.39s -> 16.93s and the 1,927 misses ARE the
  distinct key count. That is low reuse, not eviction. A bigger cache will not
  save it.
- TunnelNetwork is still ~10x SurfaceWorld (0.354s vs 0.028s on a matched 32^3).
- The live PIE tile table is SurfaceWorld: this checkout's PIE map is configured
  that way. TunnelNetwork went through the same mesher and density path via the
  commandlet, but no TunnelNetwork tile has been timed in an actual game session.
  Export numbers are not presented as game numbers.

Still outstanding, untouched here: the inter-strate passage has intersections it
should not have, routes through open passage when it should not, and should
descend from a low point of the current strate.

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 23:47:53 +02:00
1 parent 64f5d61e08
commit 0576e800c4
15 files changed
+1042 -131

No files matched your search

+77 -1
View File
@@ -31,6 +31,7 @@
#include "Modules/ModuleManager.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "HAL/IConsoleManager.h"
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0)
#if WITH_EDITOR
@@ -38,6 +39,15 @@
#include "VoxelStrateMeasure.h"
#endif
namespace
{
int32 GVoxelForgeProfileTileGeneration = 0;
FAutoConsoleVariableRef CVarVoxelForgeProfileTileGeneration(
TEXT("voxel.ProfileTileGeneration"),
GVoxelForgeProfileTileGeneration,
TEXT("Log worker tile generation time with level/step/cell count."));
}
AVoxelWorld::AVoxelWorld()
{
PrimaryActorTick.bCanEverTick = true;
@@ -855,7 +865,10 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// Signal all async tasks to bail out ASAP
const double EndPlayStartSeconds = FPlatformTime::Seconds();
bShuttingDown.store(true, std::memory_order_release);
UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: shutdown signalled; waiting for %d tasks"),
ActiveTaskCount.load(std::memory_order_relaxed));
// Wait for every running task before destroying UObjects. A timeout here is unsafe: a task
// already inside GenerateTileResult may still be evaluating a cached op stack that points at
@@ -872,6 +885,8 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
}
FPlatformProcess::Yield(); // Give CPU to other threads
}
UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: workers drained in %.6fs"),
FPlatformTime::Seconds() - EndPlayStartSeconds);
// Drain any queued results
FChunkResult Discard;
@@ -965,6 +980,7 @@ void AVoxelWorld::BeginPlay()
Generator->InitializeSettings(Settings);
Mesher->SetGenerator(Generator);
Mesher->SetShutdownFlag(&bShuttingDown);
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
Mesher->SkirtCells = Settings->SkirtCells;
Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off
@@ -1161,6 +1177,13 @@ void AVoxelWorld::ProcessPendingChunks()
{
PendingTiles.Remove(DequeuedChunk.Tile);
// A worker can observe shutdown after entering the mesher. Do not turn that partial
// result into an all-air tile: it must remain eligible for a future generation epoch.
if (DequeuedChunk.bAborted)
{
continue;
}
// ApplyTileResult does epoch check, mark-loaded, capture ingest, empty-release / mesh upload.
// Only a real (visible) upload counts against the per-frame budget — stale/empty drain free.
if (ApplyTileResult(DequeuedChunk))
@@ -1179,7 +1202,7 @@ void AVoxelWorld::ProcessPendingChunks()
bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
{
// Discard results from a previous generation epoch (stale).
if (Result.Epoch != GenerationEpoch)
if (Result.bAborted || Result.Epoch != GenerationEpoch)
{
return false;
}
@@ -2078,11 +2101,44 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY,
FChunkResult& Result)
{
const bool bProfileTile = GVoxelForgeProfileTileGeneration != 0;
const double TileStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0;
auto EmitTileProfile = [&]()
{
if (bProfileTile)
{
UE_LOG(LogTemp, Display,
TEXT("[VoxelForgeTileProfile] level=%d step=%d cells=%d sheet=%d aborted=%d empty=%d seconds=%.6f"),
Tile.Level, Step, Cells, bSheetTile ? 1 : 0, Result.bAborted ? 1 : 0,
Result.bEmpty ? 1 : 0, FPlatformTime::Seconds() - TileStartSeconds);
}
};
Result.Tile = Tile;
Result.Epoch = Epoch;
Result.bAborted = false;
Result.bEmpty = true;
Result.Streams.Reset();
Result.CaptureGrid.Reset();
Result.BandChunkLo = BandChunkLo; // strate content cut (MIN/MAX = uncut)
Result.BandChunkHi = BandChunkHi;
// Shared by async workers and the synchronous carve path. An interrupted result is empty but
// explicitly discarded, never an all-air tile that gets marked loaded.
auto AbortResult = [&]()
{
Result.bAborted = true;
Result.bEmpty = true;
Result.Streams.Reset();
Result.CaptureGrid.Reset();
};
if (ShouldAbortWork())
{
AbortResult();
EmitTileProfile();
return;
}
// T1.d — TRIVIAL-TILE REJECT: ~84 % des tuiles générées sortaient vides (tout-roc /
// tout-air) en payant quand même le pré-échantillonnage complet. Le classifieur prouve
// (bornes exactes sur le treillis du mesher + gardes conservatives) qu'une tuile est
@@ -2096,6 +2152,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile);
INC_DWORD_STAT(STAT_VoxelForgeTilesClassified);
const EVoxelTileClass Verdict = Generator->ClassifyTile(OriginVoxels, Step, Cells);
if (ShouldAbortWork())
{
AbortResult();
EmitTileProfile();
return;
}
if (Verdict == EVoxelTileClass::AllSolid)
{
INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllSolid);
@@ -2123,6 +2185,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
bWantCapture ? &Result.CaptureGrid : nullptr,
BandVoxLo, BandVoxHi);
INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed);
if (ShouldAbortWork())
{
AbortResult();
EmitTileProfile();
return;
}
}
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
@@ -2132,6 +2200,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams);
Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>();
BuildTileStreamSet(*Result.Streams, MeshData);
if (ShouldAbortWork())
{
AbortResult();
EmitTileProfile();
return;
}
Result.bEmpty = false;
// F17 — the mesher classified every triangle semantically (sky-cap vs ground, per
@@ -2143,6 +2217,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
Result.bHasCeilingTris = MeshData.NumCeilingTriangles > 0;
Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles;
}
EmitTileProfile();
}
// Section-group key shared by every tile component ("the" tile geometry group).