From f36bc1f9b25ffbf81dca3183276fa8a8fab67519 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Wed, 23 Sep 2026 19:04:13 +0200 Subject: [PATCH] wip(streaming): priority by class and level -- near ring 53.6s -> 1.2s, but crossing test fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduling rule: CollisionCritical -> Edited -> LOD0 -> Horizon, with coarse work capped so 4 task slots stay reserved for near work; result queues follow the same order; epoch and cancellation handling unchanged. Measured at MaxClipLevel=5 / RenderDistanceChunks=768: spawn ring request-to-ready 53.632 s -> 1.171 s; spawn ring from trace start 68.252 -> 62.549 s; moving first complete ring 66.808 -> 63.917 s, warm ring p50 0.267 s, per-tile moving LOD0 p50 69.4 -> 62.0 ms; full settle of 10,841 tiles 110.7 -> 106.9 s. Gate stress PASS (139.99 m, 0 full stops); static perf PASS. ⚠️ NOT SAFE TO MERGE YET: the strate-crossing test fails in both patched runs with capsule_overlaps_pending_floor -- the exact guarantee the gate exists for. Codex suspects the owner's current DA_Settings (seal 50, clip level 5) rather than this change, since older passing captures used different settings. Control needed: same test, same assets, on 370c39e. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- Source/VoxelForge/Private/VoxelWorld.cpp | 225 ++++++++++++++++++++--- Source/VoxelForge/Public/VoxelSettings.h | 7 + Source/VoxelForge/Public/VoxelWorld.h | 28 ++- 3 files changed, 232 insertions(+), 28 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 94859f4..d4420d3 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -561,6 +561,9 @@ void AVoxelWorld::RegenerateAllChunks() if (ActiveTaskCount.load(std::memory_order_relaxed) == 0) { FChunkResult StaleResult; + while (CriticalProcessQueue.Dequeue(StaleResult)) {} + while (EditedProcessQueue.Dequeue(StaleResult)) {} + while (NearProcessQueue.Dequeue(StaleResult)) {} while (ProcessQueue.Dequeue(StaleResult)) {} PendingTiles.Empty(); PendingTileCancellation.Empty(); @@ -1138,6 +1141,18 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) } // Drain any queued results FChunkResult Discard; + while (CriticalProcessQueue.Dequeue(Discard)) + { + if (Discard.bObsolete) { ++ObsoleteTileAbortCount; } + } + while (EditedProcessQueue.Dequeue(Discard)) + { + if (Discard.bObsolete) { ++ObsoleteTileAbortCount; } + } + while (NearProcessQueue.Dequeue(Discard)) + { + if (Discard.bObsolete) { ++ObsoleteTileAbortCount; } + } while (ProcessQueue.Dequeue(Discard)) { if (Discard.bObsolete) { ++ObsoleteTileAbortCount; } @@ -3606,6 +3621,9 @@ void AVoxelWorld::Tick(float DeltaTime) } } ProcessPendingChunks(); + // Near-field evidence is independent of the full horizon settle point. Record one completion + // for each desired-set epoch after this frame's result drain, including moving crossings. + RecordLOD0RingReadyIfComplete(); // The startup trace stops at the same point the streaming policy declares itself settled: // every desired key is loaded, no generation task remains, and the game-thread result drain @@ -4188,8 +4206,8 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi } // Preserve the existing critical floor ordering for the next submit pass, and make an - // immediate game-thread request when no mesh/cook is already in flight. LoadTile's true flag - // is the established BackgroundHigh streaming priority. + // immediate game-thread request when no mesh/cook is already in flight. The critical work + // class uses the established BackgroundHigh streaming priority. CriticalDesiredTiles.Remove(SupportTile); CriticalDesiredTiles.Insert(SupportTile, 0); if (PendingTiles.Contains(SupportTile) || LoadedTiles.Contains(SupportTile)) @@ -4208,7 +4226,7 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi } const int32 PendingBefore = PendingTiles.Num(); - LoadTile(SupportTile, /*bHighPriority*/ true); + LoadTile(SupportTile, EVoxelTileWorkPriority::CollisionCritical); if (PendingTiles.Num() > PendingBefore) { bAllChunksLoaded = false; @@ -4570,6 +4588,56 @@ void AVoxelWorld::RecordLOD0ReadySample( LOD0ReadySamples.Add(Sample); } +void AVoxelWorld::RecordLOD0RingReadyIfComplete() +{ + if (!VoxelForgeStartupTrace::IsActive() + || DesiredEpoch == 0 + || LastLOD0RingReadyEpoch == DesiredEpoch) + { + return; + } + + int32 DesiredLOD0Count = 0; + for (const FVoxelTileKey& Key : DesiredSorted) + { + if (Key.Level != 0) continue; + ++DesiredLOD0Count; + if (!LoadedTiles.Contains(Key)) + { + return; + } + } + if (DesiredLOD0Count == 0) + { + return; + } + + // LoadedTiles remains populated during an async remesh, so also require that no current + // level-0 request is still in flight. Obsolete requests from an older desired epoch do not + // hold the current ring back; their cancellation/epoch fence is handled by the normal drain. + for (const FVoxelTileKey& Key : PendingTiles) + { + if (Key.Level == 0 && IsDesired(Key)) + { + return; + } + } + + LastLOD0RingReadyEpoch = DesiredEpoch; + const uint64 ReadyCycles = FPlatformTime::Cycles64(); + const double DurationSeconds = DesiredEpochStartCycles != 0 + && ReadyCycles >= DesiredEpochStartCycles + ? FPlatformTime::ToSeconds64(ReadyCycles - DesiredEpochStartCycles) + : 0.0; + VoxelForgeStartupTrace::RecordEvent(TEXT("lod0_ring_ready"), FString::Printf( + TEXT("\"desired_epoch\":%u,\"center_chunk\":[%d,%d,%d],\"lod0_tiles\":%d," + "\"duration_s\":%s"), + DesiredEpoch, + DesiredEpochCenterChunk.X, DesiredEpochCenterChunk.Y, DesiredEpochCenterChunk.Z, + DesiredLOD0Count, + *FString::SanitizeFloat(DurationSeconds))); +} + void AVoxelWorld::LogStreamingLatencySummary() const { TArray RequestToReady; @@ -4854,7 +4922,16 @@ void AVoxelWorld::ProcessPendingChunks() int32 ResultsDrained = 0; bool bTimeBudgetHit = false; FChunkResult DequeuedChunk; - while (ProcessQueue.Dequeue(DequeuedChunk)) + const auto DequeueNextResult = [&]() -> bool + { + // Mirror the admission order. A horizon result that completed earlier must not consume + // the game-thread apply budget ahead of collision support, an edit, or ordinary LOD0. + return CriticalProcessQueue.Dequeue(DequeuedChunk) + || EditedProcessQueue.Dequeue(DequeuedChunk) + || NearProcessQueue.Dequeue(DequeuedChunk) + || ProcessQueue.Dequeue(DequeuedChunk); + }; + while (DequeueNextResult()) { ++ResultsDrained; const TSharedPtr Cancellation = @@ -5239,6 +5316,8 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below ++DesiredStamp; // les upserts ci-dessous marquent le crossing courant ++DesiredEpoch; + DesiredEpochStartCycles = FPlatformTime::Cycles64(); + DesiredEpochCenterChunk = Center; const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; @@ -5381,6 +5460,18 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla DesiredSorted.Insert(Key, 0); } } + + if (VoxelForgeStartupTrace::IsActive()) + { + int32 LOD0Count = 0; + for (const FVoxelTileKey& Key : DesiredSorted) + { + if (Key.Level == 0) ++LOD0Count; + } + VoxelForgeStartupTrace::RecordEvent(TEXT("lod0_ring_begin"), FString::Printf( + TEXT("\"desired_epoch\":%u,\"center_chunk\":[%d,%d,%d],\"lod0_tiles\":%d"), + DesiredEpoch, Center.X, Center.Y, Center.Z, LOD0Count)); + } } void AVoxelWorld::CancelObsoleteTileWork() @@ -5550,11 +5641,21 @@ int32 AVoxelWorld::GetMaxConcurrentTasks() const return FMath::Clamp(Asset, 1, CoreCap); } +int32 AVoxelWorld::GetNearTaskReserve(int32 MaxTasks) const +{ + const int32 AuthoredReserve = Settings ? Settings->NearTaskReserve : 4; + // Keep at least one slot usable by coarse work even when an authored reserve is larger than + // the effective task budget. A one-slot budget has no meaningful reservation to make. + return FMath::Clamp(AuthoredReserve, 0, FMath::Max(0, MaxTasks - 1)); +} + void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APawn* PlayerPawn, const FVector& PlayerHeading) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks); const int32 MaxTasks = GetMaxConcurrentTasks(); + const int32 NearTaskReserve = GetNearTaskReserve(MaxTasks); + const int32 CoarseTaskCap = FMath::Max(1, MaxTasks - NearTaskReserve); if (GVoxelForgeProfileTileGeneration != 0 && !GVoxelForgeStreamingBudgetReported) { @@ -5566,12 +5667,15 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw UE_LOG(LogTemp, Display, TEXT("[VoxelForgeStreamingBudget] logical_cores=%d automatic_core_cap=%d " "authored_core_cap=%d asset_task_cap=%d effective_generation_cap=%d " + "near_task_reserve=%d coarse_task_cap=%d " "max_mesh_applies=%d max_apply_ms=%.3f max_unloads=%d"), LogicalCores, AutomaticCap, AuthoredCoreCap, AssetCap, MaxTasks, + NearTaskReserve, + CoarseTaskCap, Settings ? Settings->MaxMeshAppliesPerFrame : 4, Settings ? Settings->MaxMeshApplyMilliseconds : 2.0f, Settings ? Settings->MaxUnloadsPerFrame : 6); @@ -5885,8 +5989,9 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw } //========================================================================= - // Submit pending work (budgeted, critical floor prefix then nearest-first). Once everything desired is loaded, - // do the "settled" cull of the deferred LOD-transition tiles, then go idle. + // Submit pending work (budgeted, collision-critical -> edits -> LOD0 -> ascending coarse + // levels). Coarse work is capped below the full task budget so a later near request always + // has reserved admission; far work still runs continuously in the remaining slots. //========================================================================= if (!bAllChunksLoaded) { @@ -5900,7 +6005,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw { if (PendingTiles.Num() >= MaxTasks) break; if (PendingTiles.Contains(T) || LoadedTiles.Contains(T)) continue; - LoadTile(T, /*bHighPriority*/ true); + LoadTile(T, EVoxelTileWorkPriority::CollisionCritical); ++Submitted; } @@ -5914,24 +6019,50 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw if (!IsDesired(T)) { It.RemoveCurrent(); continue; } // obsolete — drop if (PendingTiles.Contains(T)) { continue; } // in flight — retry after it lands It.RemoveCurrent(); - LoadTile(T, /*bHighPriority*/ true); + LoadTile(T, EVoxelTileWorkPriority::Edited); ++Submitted; } + // NEAR TERRAIN — all ordinary level-0 work is promoted after the absolute support/edit + // prefixes. It uses the near queue and BackgroundHigh so both worker execution and result + // application stay ahead of a completed horizon backlog. for (const FVoxelTileKey& T : DesiredSorted) { + if (T.Level != 0) continue; if (PendingTiles.Num() >= MaxTasks) break; if (PendingTiles.Contains(T)) continue; // in flight if (LoadedTiles.Contains(T)) continue; // already loaded (level is in the key — no LOD remesh) - LoadTile(T); + LoadTile(T, EVoxelTileWorkPriority::LOD0); ++Submitted; } + // FAR FIELD — preserve the clipmap's level ordering explicitly. DesiredSorted is + // distance-sorted for the critical/near work, but a level pass prevents a level-5 horizon + // tile from being admitted ahead of an unfinished level-1/2 bridge tile. + int32 MaxDesiredLevel = 0; + for (const FVoxelTileKey& T : DesiredSorted) + { + MaxDesiredLevel = FMath::Max(MaxDesiredLevel, T.Level); + } + for (int32 Level = 1; Level <= MaxDesiredLevel; ++Level) + { + for (const FVoxelTileKey& T : DesiredSorted) + { + if (T.Level != Level) continue; + if (PendingTiles.Num() >= CoarseTaskCap) break; + if (PendingTiles.Contains(T)) continue; // in flight + if (LoadedTiles.Contains(T)) continue; // already loaded + LoadTile(T); + ++Submitted; + } + if (PendingTiles.Num() >= CoarseTaskCap) break; + } + // Bande de strate changée : re-gen budgétée des tuiles grossières concernées (le vieux // mesh reste visible jusqu'au résultat — même schéma que RemeshDirtyChunks). for (auto It = BandRemeshQueue.CreateIterator(); It; ++It) { - if (PendingTiles.Num() >= MaxTasks) break; + if (PendingTiles.Num() >= CoarseTaskCap) break; const FVoxelTileKey T = *It; It.RemoveCurrent(); if (!IsDesired(T) || PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue; @@ -5948,12 +6079,13 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw GVoxelForgeMaxPendingTilesObserved, MaxTasks, Submitted); } if (GVoxelForgeProfileTileGeneration != 0 - && PendingTiles.Num() >= MaxTasks + && PendingTiles.Num() >= CoarseTaskCap && !GVoxelForgeGenerationCapHitReported) { UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeStreamingBudget] generation_cap_hit=1 pending=%d effective=%d"), - PendingTiles.Num(), MaxTasks); + TEXT("[VoxelForgeStreamingBudget] coarse_generation_cap_hit=1 pending=%d " + "coarse_cap=%d effective=%d near_reserve=%d"), + PendingTiles.Num(), CoarseTaskCap, MaxTasks, NearTaskReserve); GVoxelForgeGenerationCapHitReported = true; } @@ -5973,19 +6105,24 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw } } -void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) +void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority WorkPriority) { if (PendingTiles.Contains(Tile)) return; const int32 MaxTasks = GetMaxConcurrentTasks(); // T2.d — core-clamped - if (PendingTiles.Num() >= MaxTasks) + const bool bNearPriority = WorkPriority != EVoxelTileWorkPriority::Horizon; + const int32 CoarseTaskCap = FMath::Max(1, MaxTasks - GetNearTaskReserve(MaxTasks)); + const int32 AdmissionCap = WorkPriority == EVoxelTileWorkPriority::Horizon + ? CoarseTaskCap : MaxTasks; + if (PendingTiles.Num() >= AdmissionCap) { if (GVoxelForgeProfileTileGeneration != 0 && !GVoxelForgeGenerationCapHitReported) { UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeStreamingBudget] generation_cap_hit=1 pending=%d effective=%d"), - PendingTiles.Num(), MaxTasks); + TEXT("[VoxelForgeStreamingBudget] generation_cap_hit=1 pending=%d admission=%d " + "effective=%d near_reserve=%d tile_level=%d"), + PendingTiles.Num(), AdmissionCap, MaxTasks, GetNearTaskReserve(MaxTasks), Tile.Level); GVoxelForgeGenerationCapHitReported = true; } return; // Budget full — wait for a task to finish. @@ -6048,7 +6185,15 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) Empty.Epoch = TaskEpoch; Empty.DesiredEpoch = TaskDesiredEpoch; Empty.RequestStartCycles = RequestStartCycles; - ProcessQueue.Enqueue(MoveTemp(Empty)); + Empty.WorkPriority = WorkPriority; + if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical) + CriticalProcessQueue.Enqueue(MoveTemp(Empty)); + else if (WorkPriority == EVoxelTileWorkPriority::Edited) + EditedProcessQueue.Enqueue(MoveTemp(Empty)); + else if (WorkPriority == EVoxelTileWorkPriority::LOD0) + NearProcessQueue.Enqueue(MoveTemp(Empty)); + else + ProcessQueue.Enqueue(MoveTemp(Empty)); return; } BandChunkLo = MeshBandChunkLo; // pour la résolution matériaux sol/cap dans ApplyMeshToTile @@ -6081,7 +6226,15 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) Empty.RequestStartCycles = RequestStartCycles; Empty.BandChunkLo = BandChunkLo; Empty.BandChunkHi = BandChunkHi; - ProcessQueue.Enqueue(MoveTemp(Empty)); + Empty.WorkPriority = WorkPriority; + if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical) + CriticalProcessQueue.Enqueue(MoveTemp(Empty)); + else if (WorkPriority == EVoxelTileWorkPriority::Edited) + EditedProcessQueue.Enqueue(MoveTemp(Empty)); + else if (WorkPriority == EVoxelTileWorkPriority::LOD0) + NearProcessQueue.Enqueue(MoveTemp(Empty)); + else + ProcessQueue.Enqueue(MoveTemp(Empty)); return; } } @@ -6091,20 +6244,22 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) PendingTileCancellation.Add(Tile, Cancellation); ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); - // BackgroundNormal priority: gen runs on background workers that YIELD to foreground + // BackgroundNormal priority: coarse gen runs on background workers that YIELD to foreground // (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare // core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom). // At background priority the frame keeps its cores; gen just fills in around it. - // FLOOR/DIG RESPONSIVENESS: critical support requests and player carves launch at BackgroundHigh - // (bHighPriority) — still a background worker (yields to the frame, keeps the invariant) but - // jumps AHEAD of ordinary pending streaming gen. - const UE::Tasks::ETaskPriority TaskPriority = bHighPriority + // NEAR RESPONSIVENESS: collision support, edits, and every level-0 tile launch at + // BackgroundHigh — still a background worker (yields to the frame, keeps the invariant) but + // jumps AHEAD of ordinary pending streaming gen. Admission reserves slots for this class and + // the result queues apply it in collision -> edit -> LOD0 order. + const UE::Tasks::ETaskPriority TaskPriority = bNearPriority ? UE::Tasks::ETaskPriority::BackgroundHigh : UE::Tasks::ETaskPriority::BackgroundNormal; UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, - RequestStartCycles, TaskDesiredEpoch, Cancellation]() + RequestStartCycles, TaskDesiredEpoch, Cancellation, + WorkPriority]() { // RAII: decrement the counter on every exit path. struct FTaskGuard @@ -6125,9 +6280,17 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) Canceled.Epoch = TaskEpoch; Canceled.DesiredEpoch = TaskDesiredEpoch; Canceled.RequestStartCycles = RequestStartCycles; + Canceled.WorkPriority = WorkPriority; Canceled.bAborted = true; Canceled.bObsolete = true; - ProcessQueue.Enqueue(MoveTemp(Canceled)); + if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical) + CriticalProcessQueue.Enqueue(MoveTemp(Canceled)); + else if (WorkPriority == EVoxelTileWorkPriority::Edited) + EditedProcessQueue.Enqueue(MoveTemp(Canceled)); + else if (WorkPriority == EVoxelTileWorkPriority::LOD0) + NearProcessQueue.Enqueue(MoveTemp(Canceled)); + else + ProcessQueue.Enqueue(MoveTemp(Canceled)); }; if (ShouldAbortWork()) return; @@ -6140,6 +6303,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) FScopedVoxelStackRegistration StackRegistration(Tile.Level); FChunkResult Result; Result.DesiredEpoch = TaskDesiredEpoch; + Result.WorkPriority = WorkPriority; const uint64 GenerationStartCycles = FPlatformTime::Cycles64(); Result.GenerationStartCycles = GenerationStartCycles; GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, @@ -6173,7 +6337,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) if (!ShouldAbortWork()) { - ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload + if (WorkPriority == EVoxelTileWorkPriority::CollisionCritical) + CriticalProcessQueue.Enqueue(MoveTemp(Result)); + else if (WorkPriority == EVoxelTileWorkPriority::Edited) + EditedProcessQueue.Enqueue(MoveTemp(Result)); + else if (WorkPriority == EVoxelTileWorkPriority::LOD0) + NearProcessQueue.Enqueue(MoveTemp(Result)); + else + ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload } }, TaskPriority); } diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 972887c..d9c5131 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -53,6 +53,13 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) int32 MaxGenerationWorkerCores = 0; + // Keep this many task slots available for collision-critical, edited, and level-0 terrain while + // coarse horizon work is in flight. A positive reserve prevents a full far-field queue from + // delaying a new near tile because running tasks cannot be preempted. The runtime clamps the + // value below the effective task budget; 4 is the conservative default for the ship settings. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0", ClampMax = "16")) + int32 NearTaskReserve = 4; + // Nombre max de meshes appliqués (upload GPU) par frame. Seuls les vrais applies // comptent (chunks vides/périmés se vident gratuitement). À 32³ chaque apply est léger // (~0.1 ms) — tunable en live sur l'asset : montez (8-16) si le remplissage traîne, diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 3712034..e8d5b6d 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -88,6 +88,14 @@ struct FVoxelStreamingAnchor * - BeginPlay: Initialize generator, mesher * - Tick: Update chunks around player position */ +enum class EVoxelTileWorkPriority : uint8 +{ + Horizon = 0, + LOD0 = 1, + Edited = 2, + CollisionCritical = 3 +}; + struct FChunkResult { FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level) @@ -108,6 +116,7 @@ struct FChunkResult uint64 GenerationStartCycles = 0; uint64 GenerationEndCycles = 0; uint64 ApplyStartCycles = 0; + EVoxelTileWorkPriority WorkPriority = EVoxelTileWorkPriority::Horizon; int32 ClassifyVerdict = -1; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; int32 NumCeilingTriangles = 0; int32 NumVertices = 0; uint64 GeometryBytes = 0; @@ -313,6 +322,8 @@ public: * capped to (logical cores − 2) so small CPUs don't thrash on a flat 16 (background * priority stops frame starvation, not the context-switch overhead). */ int32 GetMaxConcurrentTasks() const; + /** Reserve task slots for collision-critical/edit/LOD0 work; far work uses the remainder. */ + int32 GetNearTaskReserve(int32 MaxTasks) const; //========================================================================= // TERRAIN MODIFICATION (player carving & filling) @@ -738,7 +749,8 @@ public: * * @param ChunkCoord - Which chunk to load */ - void LoadTile(const FVoxelTileKey& Tile, bool bHighPriority = false); + void LoadTile(const FVoxelTileKey& Tile, + EVoxelTileWorkPriority WorkPriority = EVoxelTileWorkPriority::Horizon); /** * Worker-side gen for one tile: classify → GenerateMesh/GenerateSheetMesh → BuildTileStreamSet. @@ -871,6 +883,7 @@ public: void RecordLOD0ReadySample(uint64 RequestStartCycles, uint64 GenerationStartCycles, uint64 GenerationEndCycles, uint64 ApplyStartCycles, uint64 ReadyCycles, uint64 CollisionSubmittedCycles); + void RecordLOD0RingReadyIfComplete(); void LogStreamingLatencySummary() const; // (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap @@ -884,6 +897,12 @@ public: // mode is NOT safe for multiple producers — concurrent Enqueues race on the tail // link and silently drop results, which leaks PendingChunkCoord slots until the // budget is exhausted and streaming stalls permanently. Mpsc guards the producer side. + // Result queues mirror the scheduling order: collision support first, then edited tiles, + // then ordinary LOD0, then the horizon. This keeps a support result from waiting behind a + // completed near-field backlog while preserving one consumer on the game thread. + TQueue CriticalProcessQueue; + TQueue EditedProcessQueue; + TQueue NearProcessQueue; TQueue ProcessQueue; TSet PendingTiles; // tiles with a gen task in flight // Game-thread-owned map; workers retain the shared token and only read its atomic flag. @@ -1005,6 +1024,13 @@ public: double CollisionCookSeconds = 0.0; }; TArray LOD0ReadySamples; + // Trace-only ring timing: each desired-set rebuild starts a new epoch. A ring is complete + // when every current desired level-0 key has reached LoadedTiles and no level-0 request for + // that epoch remains pending. This measures visible near-field readiness independently of the + // much larger horizon settle point. + uint64 DesiredEpochStartCycles = 0; + FIntVector DesiredEpochCenterChunk = FIntVector::ZeroValue; + uint32 LastLOD0RingReadyEpoch = MAX_uint32; // Headless validation hook. These are intentionally non-UPROPERTY state and are enabled only // by explicit command-line switches; they do not alter ordinary player movement or streaming.