From 2990009f3ba0506cd06a72161cc1384988c2bccb Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sun, 13 Sep 2026 04:48:44 +0200 Subject: [PATCH] perf(generation): memoise room player-fit points -- 3.8x less worker CPU in game The sampler (8e40bdd) showed ~3/4 of in-game worker CPU inside BuildChunkCache -> VF_FindPlayerFitPointForRoom, recomputed for the same rooms on every per-worker cache rebuild. The search reads only the strate params, the room site, the strate Z bounds and the warp seed, so its answer is pure. It is now memoised process-wide. - The key is exact, 97 words: every FStrateGenerationParams field via VF_STRATE_PARAM_FIELDS (83 scalar fields, audited: no pointers or TArrays), the room centre/radii/hash/bOrigin, both Z bounds, the warp seed and a key version. The hash only picks a shard; lookup compares every word. A miss is the worst case, and eviction just recomputes the same value. - 4096 sets x 4 ways, a per-slot reader/writer state, a per-set lock only on miss (single flight), 7.1 MB fixed. The search algorithm is unchanged. - voxel.PlayerFitMemo (CVar + command line), default on. The logs confirm =0 reaches the path. - Diagnostics: the SdfCacheBuild count moves to BuildChunkCache entry, per-tile cache_builds in the tile profile, tile coordinates in the startup trace, and memo stats logged at EndPlay. Game path, two runs each, sampler off (Codex; checked against its report): worker CPU LOD0 ready p50 / p95 tiles applied static off -> on 183.7 -> 48.2 s (3.81x) 109 / 178 -> 103 / 172 ms 841 = 841 moving off -> on 256.9 -> 124.7 s (2.06x) 104 / 192 -> 78 / 157 ms 2226 -> 2348 Hit rate 99.95%, 463 real searches in the moving run, 0 evictions. Field and capability: per-tile triangle counts are identical on/off for 841/841 tiles (669,834 triangles), the export OBJ SHA-256 is unchanged (b3e5f4c3..9b377), and player-fit 20,830 / walk-reachable 10,909 are unchanged. Next, measured by this round: coarse tiles thrash the 4-chunk room-cache window. BuildChunkCache calls per tile are 1.7 at LOD0, 18 at LOD2, 633 at LOD3 and 1,062 at LOD4. Known gap, closed in the next round: nothing yet asserts that VF_STRATE_PARAM_FIELDS covers every FStrateGenerationParams field. A field added to the struct but not to the list would be missing from the memo key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VZS94UH8bSsBeg5sZaxe2Y --- .../Private/VoxelCaveMorphology.cpp | 485 +++++++++++++++++- Source/VoxelForge/Private/VoxelGenerator.cpp | 5 - .../VoxelForge/Private/VoxelStartupTrace.cpp | 3 +- Source/VoxelForge/Private/VoxelWorld.cpp | 21 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 7 + Source/VoxelForge/Public/VoxelStartupTrace.h | 3 + 6 files changed, 515 insertions(+), 9 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 3cd5c49..eec5b4c 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -36,6 +36,12 @@ #include "VoxelNoise.h" // Pure FBM used by the slab landing query #include "VoxelPassageGeometry.h" #include "VoxelTerrainOpDefinition.h" +#include "HAL/IConsoleManager.h" +#include "HAL/PlatformProcess.h" +#include "Misc/CommandLine.h" +#include "Misc/Parse.h" + +#include namespace { @@ -2700,6 +2706,422 @@ namespace return false; } + // The room-fit result is pure with respect to the complete parameter value, room site, Z + // bounds, and warp seed. Keep the complete key explicit instead of hashing the USTRUCT's + // object representation: the latter would include padding and would become unsafe if a + // pointer/TArray field were ever added. +#define VF_PLAYER_FIT_PARAM_COUNT_LERP(Name) +1 +#define VF_PLAYER_FIT_PARAM_COUNT_SNAP(Name) +1 + static constexpr int32 VF_PlayerFitParamWordCount = + 0 VF_STRATE_PARAM_FIELDS( + VF_PLAYER_FIT_PARAM_COUNT_LERP, + VF_PLAYER_FIT_PARAM_COUNT_SNAP); +#undef VF_PLAYER_FIT_PARAM_COUNT_LERP +#undef VF_PLAYER_FIT_PARAM_COUNT_SNAP + + static constexpr int32 VF_PlayerFitVectorWordCount = + static_cast(sizeof(FVector::FReal) / sizeof(uint32)); + static_assert( + VF_PlayerFitVectorWordCount == 1 || VF_PlayerFitVectorWordCount == 2, + "player-fit memo expects float or double FVector components"); + static constexpr int32 VF_PlayerFitExtraKeyWordCount = + 3 * VF_PlayerFitVectorWordCount + 8; + static constexpr int32 VF_PlayerFitKeyWordCount = + VF_PlayerFitParamWordCount + VF_PlayerFitExtraKeyWordCount; + static constexpr uint32 VF_PlayerFitMemoKeyVersion = 0x00010001u; + + struct FVFPlayerFitMemoKey + { + uint32 Words[VF_PlayerFitKeyWordCount] = {}; + }; + + template + FORCEINLINE void VF_AppendPlayerFitMemoBits( + FVFPlayerFitMemoKey& Key, + int32& InOutWordIndex, + const T& Value) + { + static_assert( + sizeof(T) <= sizeof(uint64), + "player-fit memo key fields must be scalar values no wider than uint64"); + constexpr int32 WordCount = + static_cast((sizeof(T) + sizeof(uint32) - 1) / sizeof(uint32)); + for (int32 WordIndex = 0; WordIndex < WordCount; ++WordIndex) + { + Key.Words[InOutWordIndex + WordIndex] = 0u; + } + FMemory::Memcpy( + Key.Words + InOutWordIndex, + &Value, + sizeof(T)); + InOutWordIndex += WordCount; + } + + static FVFPlayerFitMemoKey VF_MakePlayerFitMemoKey( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + float StrateTopZ, + float StrateBottomZ, + uint32 WarpSeed) + { + FVFPlayerFitMemoKey Key; + int32 WordIndex = 0; + +#define VF_PLAYER_FIT_KEY_APPEND_LERP(Name) \ + VF_AppendPlayerFitMemoBits(Key, WordIndex, Params.Name); +#define VF_PLAYER_FIT_KEY_APPEND_SNAP(Name) \ + VF_AppendPlayerFitMemoBits(Key, WordIndex, Params.Name); + VF_STRATE_PARAM_FIELDS( + VF_PLAYER_FIT_KEY_APPEND_LERP, + VF_PLAYER_FIT_KEY_APPEND_SNAP) +#undef VF_PLAYER_FIT_KEY_APPEND_LERP +#undef VF_PLAYER_FIT_KEY_APPEND_SNAP + + // FVector is double-valued in the UE5 build used by this plugin. Append each component + // as raw words so the site key preserves its exact input bits on either float/double build. + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.X); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.Y); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Center.Z); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.RadiusXY); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.RadiusZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.Hash); + VF_AppendPlayerFitMemoBits(Key, WordIndex, Site.bOrigin); + VF_AppendPlayerFitMemoBits(Key, WordIndex, StrateTopZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, StrateBottomZ); + VF_AppendPlayerFitMemoBits(Key, WordIndex, WarpSeed); + VF_AppendPlayerFitMemoBits(Key, WordIndex, VF_PlayerFitMemoKeyVersion); + check(WordIndex == VF_PlayerFitKeyWordCount); + return Key; + } + + FORCEINLINE uint32 VF_HashPlayerFitMemoKey(const FVFPlayerFitMemoKey& Key) + { + uint32 Hash = 0xD14C4B7Du; + for (int32 WordIndex = 0; WordIndex < VF_PlayerFitKeyWordCount; ++WordIndex) + { + Hash ^= Key.Words[WordIndex] + + 0x9e3779b9u + + (Hash << 6) + + (Hash >> 2); + } + return VoxelHash::Mix(Hash); + } + + // A slot has an atomic reader/writer state, but its key/result payload stays ordinary data. + // Readers increment the low counter before touching the payload; writers set the high bit, + // wait for existing readers, replace the payload, then publish the valid bit. This avoids + // a seqlock data race and avoids the ABA window of a finite sequence number. + struct FVFPlayerFitMemoSlot + { + static constexpr uint32 ReaderMask = (1u << 30) - 1u; + static constexpr uint32 ValidBit = 1u << 30; + static constexpr uint32 WriterBit = 1u << 31; + + std::atomic State; + std::atomic KeyHash; + FVFPlayerFitMemoKey Key; + FVector Result = FVector::ZeroVector; + bool bFound = false; + + FVFPlayerFitMemoSlot() + : State(0u) + , KeyHash(0u) + { + } + + bool TryAcquireRead() + { + uint32 Current = State.load(std::memory_order_acquire); + for (;;) + { + if ((Current & ValidBit) == 0u + || (Current & WriterBit) != 0u + || (Current & ReaderMask) == ReaderMask) + { + return false; + } + if (State.compare_exchange_weak( + Current, + Current + 1u, + std::memory_order_acquire, + std::memory_order_relaxed)) + { + return true; + } + } + } + + void ReleaseRead() + { + State.fetch_sub(1u, std::memory_order_release); + } + + bool AcquireWrite() + { + uint32 Current = State.load(std::memory_order_acquire); + for (;;) + { + if ((Current & WriterBit) != 0u) + { + FPlatformProcess::YieldThread(); + Current = State.load(std::memory_order_acquire); + continue; + } + + const bool bWasValid = (Current & ValidBit) != 0u; + if (State.compare_exchange_weak( + Current, + Current | WriterBit, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + while ((State.load(std::memory_order_acquire) & ReaderMask) != 0u) + { + FPlatformProcess::YieldThread(); + } + return bWasValid; + } + } + } + + }; + + struct FVFPlayerFitMemoSetLock + { + std::atomic bLocked; + + FVFPlayerFitMemoSetLock() + : bLocked(false) + { + } + + void Lock() + { + bool bExpected = false; + while (!bLocked.compare_exchange_weak( + bExpected, + true, + std::memory_order_acquire, + std::memory_order_relaxed)) + { + bExpected = false; + FPlatformProcess::YieldThread(); + } + } + + void Unlock() + { + bLocked.store(false, std::memory_order_release); + } + }; + + struct FVFPlayerFitMemo + { + static constexpr int32 NumSets = 4096; + static constexpr int32 NumWays = 4; + static constexpr int32 Capacity = NumSets * NumWays; + static_assert((NumSets & (NumSets - 1)) == 0, "player-fit memo sets must be a power of two"); + static_assert((NumWays & (NumWays - 1)) == 0, "player-fit memo ways must be a power of two"); + + FVFPlayerFitMemoSlot Slots[Capacity]; + FVFPlayerFitMemoSetLock SetLocks[NumSets]; + std::atomic ReplacementCursor[NumSets]; + + std::atomic Requests{0}; + std::atomic Hits{0}; + std::atomic Misses{0}; + std::atomic Searches{0}; + std::atomic Evictions{0}; + std::atomic Bypasses{0}; + std::atomic OccupiedEntries{0}; + + FVFPlayerFitMemo() + { + for (int32 SetIndex = 0; SetIndex < NumSets; ++SetIndex) + { + ReplacementCursor[SetIndex].store(0u, std::memory_order_relaxed); + } + } + + static FORCEINLINE int32 SlotIndex(uint32 KeyHash, int32 Way) + { + const int32 SetIndex = static_cast(KeyHash & (NumSets - 1)); + return SetIndex * NumWays + Way; + } + + bool TryFind( + const FVFPlayerFitMemoKey& InKey, + uint32 InKeyHash, + FVector& OutPoint, + bool& OutFound) + { + for (int32 Way = 0; Way < NumWays; ++Way) + { + FVFPlayerFitMemoSlot& Slot = Slots[SlotIndex(InKeyHash, Way)]; + // A stale hash can only cause a miss. Exact key comparison below is still + // mandatory before returning the cached result. + if (Slot.KeyHash.load(std::memory_order_relaxed) != InKeyHash + || !Slot.TryAcquireRead()) + { + continue; + } + + bool bMatches = true; + for (int32 WordIndex = 0; + WordIndex < VF_PlayerFitKeyWordCount; + ++WordIndex) + { + if (Slot.Key.Words[WordIndex] != InKey.Words[WordIndex]) + { + bMatches = false; + break; + } + } + + if (bMatches) + { + OutPoint = Slot.Result; + OutFound = Slot.bFound; + Slot.ReleaseRead(); + return true; + } + Slot.ReleaseRead(); + } + return false; + } + + void Publish( + int32 SetIndex, + const FVFPlayerFitMemoKey& InKey, + uint32 InKeyHash, + bool bFound, + const FVector& Point) + { + int32 SelectedWay = INDEX_NONE; + for (int32 Way = 0; Way < NumWays; ++Way) + { + const FVFPlayerFitMemoSlot& Slot = Slots[SlotIndex(InKeyHash, Way)]; + if ((Slot.State.load(std::memory_order_acquire) + & FVFPlayerFitMemoSlot::ValidBit) == 0u) + { + SelectedWay = Way; + break; + } + } + if (SelectedWay == INDEX_NONE) + { + SelectedWay = static_cast( + ReplacementCursor[SetIndex].fetch_add( + 1u, std::memory_order_relaxed) + & (NumWays - 1)); + } + + FVFPlayerFitMemoSlot& Slot = Slots[SlotIndex(InKeyHash, SelectedWay)]; + const bool bWasValid = Slot.AcquireWrite(); + Slot.Key = InKey; + Slot.Result = Point; + Slot.bFound = bFound; + Slot.KeyHash.store(InKeyHash, std::memory_order_relaxed); + Slot.State.store(FVFPlayerFitMemoSlot::ValidBit, std::memory_order_release); + if (bWasValid) + { + Evictions.fetch_add(1u, std::memory_order_relaxed); + } + else + { + OccupiedEntries.fetch_add(1u, std::memory_order_relaxed); + } + } + + template + bool FindOrCompute( + const FVFPlayerFitMemoKey& InKey, + FVector& OutPoint, + ComputeFn&& Compute) + { + const uint32 KeyHash = VF_HashPlayerFitMemoKey(InKey); + Requests.fetch_add(1u, std::memory_order_relaxed); + + bool bFound = false; + FVector CachedPoint = FVector::ZeroVector; + if (TryFind(InKey, KeyHash, CachedPoint, bFound)) + { + OutPoint = CachedPoint; + Hits.fetch_add(1u, std::memory_order_relaxed); + return bFound; + } + + const int32 SetIndex = static_cast(KeyHash & (NumSets - 1)); + SetLocks[SetIndex].Lock(); + if (TryFind(InKey, KeyHash, CachedPoint, bFound)) + { + SetLocks[SetIndex].Unlock(); + OutPoint = CachedPoint; + Hits.fetch_add(1u, std::memory_order_relaxed); + return bFound; + } + + FVector ComputedPoint = FVector::ZeroVector; + bFound = Compute(ComputedPoint); + Searches.fetch_add(1u, std::memory_order_relaxed); + Misses.fetch_add(1u, std::memory_order_relaxed); + Publish(SetIndex, InKey, KeyHash, bFound, ComputedPoint); + SetLocks[SetIndex].Unlock(); + + OutPoint = ComputedPoint; + return bFound; + } + + void ResetStats() + { + Requests.store(0u, std::memory_order_relaxed); + Hits.store(0u, std::memory_order_relaxed); + Misses.store(0u, std::memory_order_relaxed); + Searches.store(0u, std::memory_order_relaxed); + Evictions.store(0u, std::memory_order_relaxed); + Bypasses.store(0u, std::memory_order_relaxed); + } + }; + + static FVFPlayerFitMemo GPlayerFitMemo; + static TAutoConsoleVariable CVarVoxelForgePlayerFitMemo( + TEXT("voxel.PlayerFitMemo"), + 1, + TEXT("Memoize deterministic room player-fit points; 0 disables the memo."), + ECVF_Default); + + bool VF_FindPlayerFitPointForRoomMemoized( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + float StrateTopZ, + float StrateBottomZ, + uint32 WarpSeed, + FVector& OutPoint) + { + if (CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread() == 0) + { + GPlayerFitMemo.Bypasses.fetch_add(1u, std::memory_order_relaxed); + return VF_FindPlayerFitPointForRoom( + Params, Site, StrateTopZ, StrateBottomZ, WarpSeed, OutPoint); + } + + const FVFPlayerFitMemoKey Key = VF_MakePlayerFitMemoKey( + Params, Site, StrateTopZ, StrateBottomZ, WarpSeed); + return GPlayerFitMemo.FindOrCompute( + Key, + OutPoint, + [&](FVector& ComputedPoint) + { + return VF_FindPlayerFitPointForRoom( + Params, + Site, + StrateTopZ, + StrateBottomZ, + WarpSeed, + ComputedPoint); + }); + } + float VF_EvaluateSlabLandingDensity( const FSlabGenerationParams& Params, uint32 Seed, @@ -4305,6 +4727,63 @@ FVector VoxelCaveMorphology::ApplyCaveWarp( return VF_ApplyCaveWarp(WorldPoint, Params, Seed); } +void VoxelCaveMorphology::ConfigurePlayerFitMemoFromCommandLine() +{ + int32 CommandLineValue = CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread(); + const bool bCommandLineValue = FParse::Value( + FCommandLine::Get(), + TEXT("voxel.PlayerFitMemo="), + CommandLineValue); + if (bCommandLineValue) + { + CVarVoxelForgePlayerFitMemo->Set( + CommandLineValue != 0 ? 1 : 0, + ECVF_SetByCommandline); + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelPlayerFitMemo] configured enabled=%d source=%s sets=%d ways=%d ") + TEXT("capacity=%d key_words=%d entry_bytes=%llu table_bytes=%llu"), + CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread() != 0 ? 1 : 0, + bCommandLineValue ? TEXT("commandline") : TEXT("cvar/default"), + FVFPlayerFitMemo::NumSets, + FVFPlayerFitMemo::NumWays, + FVFPlayerFitMemo::Capacity, + VF_PlayerFitKeyWordCount, + static_cast(sizeof(FVFPlayerFitMemoSlot)), + static_cast(sizeof(FVFPlayerFitMemo))); +} + +void VoxelCaveMorphology::ResetPlayerFitMemoStats() +{ + GPlayerFitMemo.ResetStats(); +} + +void VoxelCaveMorphology::LogPlayerFitMemoStats() +{ + const uint64 Requests = GPlayerFitMemo.Requests.load(std::memory_order_relaxed); + const uint64 Hits = GPlayerFitMemo.Hits.load(std::memory_order_relaxed); + const uint64 Misses = GPlayerFitMemo.Misses.load(std::memory_order_relaxed); + const double HitRate = Requests > 0 + ? static_cast(Hits) / static_cast(Requests) + : 0.0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelPlayerFitMemo] enabled=%d requests=%llu hits=%llu misses=%llu ") + TEXT("hit_rate=%.6f searches=%llu bypasses=%llu entries=%llu/%d ") + TEXT("bytes=%llu evictions=%llu"), + CVarVoxelForgePlayerFitMemo.GetValueOnAnyThread() != 0 ? 1 : 0, + static_cast(Requests), + static_cast(Hits), + static_cast(Misses), + HitRate, + static_cast(GPlayerFitMemo.Searches.load(std::memory_order_relaxed)), + static_cast(GPlayerFitMemo.Bypasses.load(std::memory_order_relaxed)), + static_cast(GPlayerFitMemo.OccupiedEntries.load(std::memory_order_relaxed)), + FVFPlayerFitMemo::Capacity, + static_cast(sizeof(FVFPlayerFitMemo)), + static_cast(GPlayerFitMemo.Evictions.load(std::memory_order_relaxed))); +} + FVoxelPassageLanding VF_BuildPassageLanding( const FVector& InStandingPoint, float MouthRadius, @@ -4729,6 +5208,10 @@ void VoxelCaveMorphology::BuildChunkCache( uint32 Seed, int32 StrateIndex, const TArray* TerrainOps) { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter(VoxelDensityProfile::ECounter::SdfCacheBuild); + } VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::RoomGraphBuild); // Clear previous data (arrays keep their allocation for reuse) @@ -5150,7 +5633,7 @@ void VoxelCaveMorphology::BuildChunkCache( Room.RadiusZ, Room.Hash, false}; - Room.bHasPlayerFitPoint = VF_FindPlayerFitPointForRoom( + Room.bHasPlayerFitPoint = VF_FindPlayerFitPointForRoomMemoized( InParams, Site, InParams.StrateTopWorldZ, diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 57922c4..4b63cc3 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -2745,11 +2745,6 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (bNeedRebuild) { - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::SdfCacheBuild); - } // The lowered evaluator uses the same window-invariance contract as the prepared // room-graph source, but keeps a four-chunk XY window per worker. A tile worker can // visit neighbouring chunk keys in an arbitrary order; the larger deterministic diff --git a/Source/VoxelForge/Private/VoxelStartupTrace.cpp b/Source/VoxelForge/Private/VoxelStartupTrace.cpp index 299c591..50f8654 100644 --- a/Source/VoxelForge/Private/VoxelStartupTrace.cpp +++ b/Source/VoxelForge/Private/VoxelStartupTrace.cpp @@ -260,9 +260,10 @@ void RecordTile(const FTileSample& Sample) GState->Samples.Add(Sample); ++GState->TileRecordsWritten; WriteEventLocked(*GState, TEXT("tile"), FString::Printf( - TEXT("\"level\":%d,\"verdict\":%d,\"empty\":%d,\"triangles\":%d," + TEXT("\"tile\":[%d,%d,%d],\"level\":%d,\"verdict\":%d,\"empty\":%d,\"triangles\":%d," "\"request_to_apply_s\":%s,\"queue_wait_s\":%s,\"worker_queue_s\":%s,\"result_queue_s\":%s," "\"generation_s\":%s,\"classify_s\":%s,\"mesh_s\":%s,\"stream_s\":%s,\"apply_s\":%s"), + Sample.TileX, Sample.TileY, Sample.TileZ, Sample.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0, Sample.Triangles, *Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds), *Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds), diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 9556e94..e178941 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -17,6 +17,7 @@ #include "VoxelStartupTrace.h" #include "VoxelStackSampler.h" #include "VoxelStats.h" +#include "VoxelCaveMorphology.h" // IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc // APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était // complet par transitivité seulement : on l'inclut explicitement, c'est exactement la fragilité @@ -999,6 +1000,7 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) FVoxelStackSampler::Get().StopAndWrite(); bSampleStacksStarted = false; } + VoxelCaveMorphology::LogPlayerFitMemoStats(); LogStreamingLatencySummary(); PendingTiles.Empty(); PendingTileCancellation.Empty(); @@ -1058,6 +1060,8 @@ void AVoxelWorld::BeginPlay() VoxelForgeStartupTrace::BeginFromCommandLine(); VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("BeginPlay")); Super::BeginPlay(); + VoxelCaveMorphology::ConfigurePlayerFitMemoFromCommandLine(); + VoxelCaveMorphology::ResetPlayerFitMemoStats(); bShuttingDown.store(false, std::memory_order_relaxed); LOD0ReadySamples.Reset(); ObsoleteTileAbortCount = 0; @@ -1084,6 +1088,7 @@ void AVoxelWorld::BeginPlay() int32 CommandLineProfile = GVoxelForgeProfileDensity; int32 CommandLineProfileFull = GVoxelForgeProfileDensityFull; int32 CommandLinePerfAttribution = GVoxelForgePerfAttribution; + int32 CommandLineProfileTileGeneration = GVoxelForgeProfileTileGeneration; int32 CommandLineUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut; int32 CommandLineOuterClassifierMode = GVoxelForgeOuterClassifierMode; int32 CommandLineSampleStacks = GVoxelForgeSampleStacks; @@ -1099,6 +1104,13 @@ void AVoxelWorld::BeginPlay() { GVoxelForgePerfAttribution = CommandLinePerfAttribution; } + if (FParse::Value( + FCommandLine::Get(), + TEXT("voxel.ProfileTileGeneration="), + CommandLineProfileTileGeneration)) + { + GVoxelForgeProfileTileGeneration = CommandLineProfileTileGeneration; + } if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseBlockEarlyOut="), CommandLineUseBlockEarlyOut)) { GVoxelForgeUseBlockEarlyOut = CommandLineUseBlockEarlyOut; @@ -1111,6 +1123,7 @@ void AVoxelWorld::BeginPlay() { GVoxelForgeSampleStacks = CommandLineSampleStacks; } + GVoxelForgeProfileTileGeneration = GVoxelForgeProfileTileGeneration != 0 ? 1 : 0; GVoxelForgeUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0 ? 1 : 0; GVoxelForgeOuterClassifierMode = FMath::Clamp(GVoxelForgeOuterClassifierMode, 0, 1); GVoxelForgeSampleStacks = FMath::Clamp( @@ -2523,6 +2536,9 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) if (bTraceTile) { VoxelForgeStartupTrace::FTileSample Sample; + Sample.TileX = Result.Tile.Coord.X; + Sample.TileY = Result.Tile.Coord.Y; + Sample.TileZ = Result.Tile.Coord.Z; Sample.Level = Result.Tile.Level; Sample.Verdict = Result.ClassifyVerdict; Sample.bEmpty = bResultEmpty; @@ -3843,7 +3859,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector "rooms=%d tunnels=%d joins=%d pits=%d chimneys=%d " "whole_mixed=%u whole_solid=%u whole_air=%u final_nodes=%u " "split_nodes=%u max_depth=%u block_builds=%llu block_samples=%llu " - "block_ops=%llu block_active=%llu block_pruned=%llu"), + "block_ops=%llu block_active=%llu block_pruned=%llu cache_builds=%llu"), Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, Step, Cells, bSheetTile ? 1 : 0, Result.bAborted ? 1 : 0, Result.bEmpty ? 1 : 0, ClassifyVerdict, ClassifySeconds, MeshSeconds, StreamSeconds, @@ -3870,7 +3886,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockSamples)), static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockOperators)), static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockActiveOperators)), - static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockPrunedOperators))); + static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockPrunedOperators)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::SdfCacheBuild))); if (bProfileOps) { diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index cf5770b..0e9ee06 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -872,6 +872,13 @@ struct FTunnelCoreWorldEvaluation namespace VoxelCaveMorphology { + // The player-fit memo is process-wide and uses a fixed-size table. These controls are kept + // beside the cave source so the command-line value is applied before generation workers start, + // while the CVar remains available for an in-process A/B switch. + VOXELFORGE_API void ConfigurePlayerFitMemoFromCommandLine(); + VOXELFORGE_API void ResetPlayerFitMemoStats(); + VOXELFORGE_API void LogPlayerFitMemoStats(); + // The room graph's seed is the world seed salted by the strate index. Keep this formula // shared by BuildChunkCache and landing-site queries so a query cannot inspect another // strate's room layout. / La seed du graphe des salles est la seed monde salée par l'index diff --git a/Source/VoxelForge/Public/VoxelStartupTrace.h b/Source/VoxelForge/Public/VoxelStartupTrace.h index b0ec06e..dac49a0 100644 --- a/Source/VoxelForge/Public/VoxelStartupTrace.h +++ b/Source/VoxelForge/Public/VoxelStartupTrace.h @@ -9,6 +9,9 @@ namespace VoxelForgeStartupTrace { struct VOXELFORGE_API FTileSample { + int32 TileX = 0; + int32 TileY = 0; + int32 TileZ = 0; int32 Level = -1; int32 Verdict = -1; // Mixed=0, AllSolid=1, AllAir=2, -1=not classified (sheet/early path) bool bEmpty = true;